Java内存模型-JMM解析

1.前言

在解析JMM之前,我们首先要明确,java并发编程说到底就是为了处理两个关键问题:

  • 线程之间通信
  • 线程之间同步

我们先简要概述一下,在彻底了解了java内存模型之后,我们可以往更深层次进行探究,那么开始:

  • 线程通信指线程之间的信息交互,由于线程里的内容是线程私有的,所以必须通过一些手段达到信息交换的目的,这里有两种:共享内存消息传递,其中共享内存会在本文重点介绍,并且也是java并发采取的模式
  • 线程同步指不同线程之间操作的相对顺序,有过多线程基础的同学很快指出,synchronized关键字和lock锁或者volatile关键字,我们需要在代码中明确写出该在哪里进行同步,从而让线程互斥执行

java的并发采用内存模型,并且java的线程通信对我们程序员来说是透明的,在开发中可能会遇到各种问题,因此需要弄清楚java的内存模型

2.java内存模型的定义

2.1为何定义

《Java虚拟机规范》,定义了一种Java内存模型(Java Memory Model) 来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的访问效果,在JDK5之后,Java内存模型才终于成熟完善

2.2 主内存和工作内存

java内存模型的主要目的是:

  • 定义程序中各种变量的访问规则(虚拟机中把变量值存储到内存和从内存中取出这样的底层细节)

我们这里说的变量和我们在程序代码里写的变量不同,这里的变量指实例变量,静态字段,和构成数组对象的元素(文章后面提到的变量都值这里所说的变量),但是不包括局部变量和方法参数,因为前面的变量都是线程之间共享,存储在堆空间中(JKD8),被线程共享,而后面的变量都是线程私有的,jvm创建线程时会为每个线程创建一个,也叫虚拟机栈,这些变量存储在线程的局部变量表中,是线程私有的,有疑惑的同学请看我的这篇

Java内存模型规定了所有的变量都存储主内存,注意这里的主内存,是依附在物理上的内存,jvm就运行在物理内存上(我的电脑是16G),所以这里的主内存就是虚拟机的一部分,除此之外,每条线程还有自己的工作内存(本地内存),工作内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化线程的,工作内存中保存了被该线程使用的在主内存中的变量副本,线程对变量的读取赋值等操作都必须在工作内存中进行,不能直接在主内存中进行读写,如图所示:
在这里插入图片描述

2.3内存间的交互操作

经过上面的介绍,我们可以模拟出两个线程通信的方式,线程A和线程B通信:

  • 线程A将工作内存中操作过的共享变量刷新到主内存中去
  • 线程B到主内存中获取被线程A操作过的变量

将上面两步更细致的划分,就可以探索接下来的一部分: 主内存和工作内存的交互协议,这个协议将主内存与工作内存的变量交互定义为了以下8中操作:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一个线程独占的状态;
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传送到线程中的工作内存,以便随后的load动作使用;
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存中的变量;
  • store(存储):作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便随后的write操作;
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值写入主内存的变量中。

我们通过一张图来详细描述这个过程,包括手写的lock和unlock:
在这里插入图片描述

Java内存模型对这八种操作制定了以下规则:

  • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须
    write
  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
  • 不允许一个线程将没有assign的数据从工作内存同步回主内存
  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解
  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

需要注意的是:read和load ,store和write必须按照顺序来,但不要求连续,也就是这两组操作可以插入其他指令,比如主内存中有a,b两个变量,可以是这种顺序:read a,read b,load b,load a;

2.4 volatile的出现

在2.3中通过图示描述了工作内存和主内存是如何进行变量交互的,这种模式下很可能出现问题:
比如主内存存在一个变量num = 1,线程A对其操作,复制其副本到工作内存,此时线程B也对num进行操作比如num = 2,并且成功,但此时A线程还是原先num的值,这就出现了问题,导致线程不安全,这时候Java虚拟机提供了解决方案:提供了volatile来解决,它是一个轻量级的同步机制,在进行volatile详细讲解前,先通俗的说一下它的作用:

  • 保证了变量对所有线程可见,可见指的是当一个线程修改了这个变量的值,新值对其他线程来说是可以立刻得知,举个例子:线程A修改了一个变量值,而这个变量刚好在线程B中也有一份,那么A修改过后B线程就会得知变量已经修改了,进而操作修改过后的值,普通变量就只能循规蹈矩的等待线程Awrite进主内存,然后B再修改,而且无法保证安全性

这里需要提出一个问题:虽然被volatile修饰的变量对所有线程可见,对volatile变量的修改都能立刻反应到其他线程中,但是却无法保证基于volatile变量的运算在并发条件下是安全的,怎么理解这句话呢,我们通过一个例子来说明:

public class Demo {
    private volatile static int num = 0;
    public static void add(){
        num++;
    }
    public static void main(String[] args) {
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 0; j < 1000 ; j++) {
                    add();
                }
            }).start();
        }
        while (Thread.activeCount()>2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + " " + num);
    }
}

输出结果:main 19210
我们这段程序的目的是开通20个线程对同一资源进行每个线程1000次的自增操作,按道理结果应该是20000,为什么结果会变小?答案是volatile虽然保证了变量在程序间的可见性,但是并不能保证该变量的运算的原子性,要解决这个问题可以使用另外更加重量级的同步机制: synchronized或者lock,我们来看一下为什么num++为什么不是一个原子性操作,我们反编译刚才的代码,我们取出add()方法的字节码文件:

  public static void add();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: getstatic     #2                  // Field num:I
         3: iconst_1
         4: iadd
         5: putstatic     #2                  // Field num:I
         8: return
      LineNumberTable:
        line 13: 0
        line 14: 8

我们可以清楚的看到一个++操作对应的是5行字节码命令,我们可以使用原子类操作来进行代替++操作,对原子类不了解的同学可以看一下:
JUC并发-CAS原子性操作和ABA问题及解决
最后总结一句话:volatile是一个轻量级的同步机制,保证可见性不保证原子性,可以用同步方法或者原子类来解决原子性问题

3.原子性,可见性,有序性

并发的三个特征分别是原子性,可见性,有序,而Java内存模型就是围绕着在并发过程中如何处理这三个特征来建立的,本来这些概念不需要再说的,但在了解了java内存模型止后再来看确实有更好的理解:

  • 原子性是指不可再分的最小操作指令,即单条机器指令,原子性操作任意时刻只能有一个线程,因此是线程安全的。
    Java内存模型中通过read、load、assign、use、store和write这6个操作保证变量的原子性操作。

    long和double这两个64位长度的数据类型java虚拟机并没有强制规定他们的read、load、store和write操作的原子性,即所谓的非原子性协定,但是目前的各种商业java虚拟机都把long和double数据类型的4中非原子性协定操作实现为原子性。所以java中基本数据类型的访问读写是原子性操作。

    对于大范围的原子性保证需要通过lock和unlock操作以及synchronized同步块来保证。

  • 可见性是指当一个线程修改了共享变量的值,其他线程可以立即得知这个修改。
    Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。Java中通过volatilefinalsynchronized这三个关键字保证可见性:

    • volatile:通过刷新变量值确保可见性。
    • synchronized:同步块通过变量lock锁定前必须清空工作内存中变量值,重新从主内存中读取变量值,unlock解锁前必须把变量值同步回主内存来确保可见性。
    • final:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用传递进去,那么在其他线程中就能看见final字段的值,无需同步就可以被其他线程正确访问。对于final文章后部分会有专门讨论
  • 有序性是指:在线程内部,所有的操作都是有序执行的,而在线程之间,因为工作内存和主内存同步的延迟,操作是乱序执行的。Java通过volatile和synchronized关键字确保线程之间操作的有序性。

    • volatile禁止指令重排序优化实现有序性。
    • synchronized通过一个变量在同一时刻只允许一个线程对其进行lock锁定操作来确保有序性。

注意:synchronized都满足以上三个特征,看似是一种万能的解决方案,但是注意使用它的时候会出现性能问题

4.指令重排序

前面提到了volatile禁止指令重排序优化实现有序性。什么是指令重排,简单的来说:你写的程序,计算机并不是按照你写的那样去执行的。
在java代码执行的时候,编译器和处理器常常会对指令做重排序,这些排序可分为三种类型:

  • 编译器优化的重排序
  • 指令集并行的重排序
  • 内存系统的重排序

它们之间的顺序:

  • 源代码–>编译器优化的重排–> 指令并行也可能会重排–> 内存系统也会重排—> 执行

关于指令重排,我们知道有这个概念就行了,也无法深入,涉及到计算机底层,来看一段代码:

int x = 1; // 1
int y = 2; // 2
x = x + 5; // 3
y = x * x; // 4

这些语句我们希望的执行顺序是从上到下依次执行,但是在经过一系列指令重排之后,它的顺序可能是1324或者2134,但是对最终的结果没有影响,但在多线程条件下,指令重排可能会导致一些问题:

  • 线程A有这样一段代码:x = a,b = 1,线程B有这样一段代码:y = b,a = 2(假设abxy默认值都是零),按照我们的设想,正确结果应该是x = 0;y = 0
  • 考虑指令重排的情况下,线程A就变成了:b=1,x=a,线程B变成了:a=2,y=b,指令重排导致的诡异结果: x = 2;y = 1;

出现问题的原因是:由于两个线程中的代码没有数据依赖关系,所以在经过指令重排过后,代码顺序发生改变,导致最终结果也发生改变
数据依赖分为以下三个类型

名称 代码示例 说明
写后读 a=1;b=1 写一个变量后,再读这个变量
写后写 a=1;a=2 写一个变量后,再写这个变量
读后写 a=b;b=1; 读一个变量后,再写这个变量

针对以上三种类型,重排序必定会导致结果发生变化

5.Happens-Before(先行发生原则)

Happens-Before是Java内存模型中一个非常重要的概念,happens-before是判断数据是否存在竞争、线程是否安全的重要依据,想要一个操作执行的结果需要对另一个操作可见,那么你们可以使用 happens-before 规则,我们先来看一段代码:

i = 1//线程A中执行
j = i//线程B中执行
i = 2//线程C中执行

在解释这段代码前,我们要对先行发生原则(Happens-Before)有一个大概的了解:

  • 如果一个操作与另一个操作有Happens-Before关系,那么第一个操作将对第二个操作可见,且第一个操作的顺序要在第二个操作之前

我们回到代码,假如线程A和线程B存在Happens-Before关系,那么A的操作i = 1就先发生于B的j = i,于是我们就可以确定j的值为1,这个时候来了一个线程C,而线程C的操作有不确定性,在A和B的先行关系不变的情况下,假如C在A和B之间发生,这时候B执行操作完了,其j的值是多少?1和2都有可能,因为B和C没有确定先行发生规则,这就不具备多线程的安全性
上面是我们假设的一种情况,这里列举几个常见的Java“天然的”happens-before关系,这些关系没有任何同步器协助就已经存在,可以直接在编码中使用

  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作(也就是说你写的操作,如果是单线程执行,那么前面的操作[程序逻辑上的前]就会happens-before于后面的操作)

  • 监视器锁规则: 一个unlock操作先行发生于后面对同一个锁的lock操作,是针对同一个锁,后面是针对时间上的概念

  • volatile变量规则: 对一个 volatile域的写操作,先行发生于于任意后续对这个volatile域的读操作

  • 传递性:如果 A happens-before B,且 B happens-before C,那么A happens-before C

  • 线程start()规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。也就是start() happens before 线程B中的操作。

  • 线程join()规则:主线程A等待子线程B完成,当子线程B执行完毕后,主线程A可以看到线程B的所有操作。也就是说,子线程B中的任意操作,happens-before join()的返回。

Java语言无需任何同步手段保障就能成立的先行发生规则就只有上面这些,这里需要多提一下happend-before和指令重排序的关系,但是我们之前也提到了,指令重排序可能会使程序结果发生改变,虽然这个机率很小,而volatile可以避免指令重排序,而volatile是满足写后读的happens-before规则,那么volatile是怎么做到的避免指令重排呢?

6.volatile避免指令重排

给大家画个图:

在这里插入图片描述
想要了解更多的请看:volatile内存解析

说明

本文参考书籍:《深入理解Java虚拟机》《Java并发编程的艺术》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章