一,深入理解JMM内存模型

1,什么是可见性

在谈jmm的内存模型之前,先了解一下并发并发编程的三大特性,分别是:可见性,原子性,有序性。可见性指的就是当一个线程修改某个变量的值之后,其他的线程可以立马感知到。

接下来看一个例子,看一个线程改变值之后,另一个线程能否立马感知到这个值被改变了。

public class JmmTest {
    private boolean flag = true;
    private int count = 0;

    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag:"+flag);
    }

    public void load() {
        while (flag) {
            //TODO  业务逻辑
            count++;
        }
        System.out.println(Thread.currentThread().getName() + "跳出循环: count=" + count);
    }

    public static void main(String[] args) throws InterruptedException {
        JmmTest test = new JmmTest();

        // 线程threadA模拟数据加载场景
        Thread threadA = new Thread(() -> test.load(), "threadA");
        threadA.start();

        // 让threadA执行一会儿
        Thread.sleep(1000);
        // 线程threadB通过flag控制threadA的执行时间
        Thread threadB = new Thread(() -> test.refresh(), "threadB");
        threadB.start();
    }
}

可以发现以上操作,线程A先加载这个flag值,由于是true,因此一直处于while循环中空转,但是线程B随后修改了这个值,但是可以发现线程A是还在这个while循环中的,并没有跳出循环,其结果值如下:

threadB修改flag:false

也就是说,在一个正常的多线程之间的通信,是不能够直接的进行通信的,因此这就需要了解JMM的底层原理了

2,什么是JMM

Java Memory Model ,就是JMM的全称,意思是java内存模型。主要用于规范java虚拟机和计算机内存时如何协调工作的,规定了当一个线程改变某个共享变量值后,其他线程需要如何查看以及合适可以查看这个被改变的共享数据。

jmm的内存模型如下,java采用的是共享变量的模型方式,在创建一个共享变量之后,这些共享变量时存储在主内存中的,所有线程都能访问,但是每个线程需要操作这个变量时,需要先将这个值加载到每个线程的工作内存中,即每个线程都有对应栈帧,将这个值加入到局部变量表即可,就成为了共享变量的一个副本,随后线程A才能去修改这个值

【JUC系列-01】深入理解JMM内存模型的底层实现原理-LMLPHP

而由于主内存中的变量都是共享变量,因此为了解决并发问题,在JMM内部又引入了八大原子操作

1,lock:作用于主内存的变量,把一个变量标记为一条线程独占状态
2,unlock:把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
3,read(读取):作用于主内存中,需要先对变量进行副本的拷贝,然后将变量值传输到工作内存中
4,load(载入):在工作内存中,需要对传输过来的副本变量进行一个获取,并且存入到工作内存中
5,use(使用): 需要将获取的变量传给执行引擎
6,assign(赋值):执行引擎会将这个收到的变量赋值给工作内存的变量
7,store(存储):修改这个传过来的副本之后,会将修改的值存储并送到主内存中
8,write(写入):会将这个存储的变量写回到主内存中,即修改主内存的值

如当一个线程去修改主内存中的共享变量的方式如下,比如说内存中的 x = 5 进行 +1 的操作如下图所示,首先线程A会read读取主内存中的x = 5的值,随后将读取到的值load载入到线程A的本地内存中,一般栈帧中存放变量的都是这个局部变量表,随后会通过use的指令使用这个变量,将这个值加入到cpu中,结果cpu内部的运算之后,此时 x = 6,会通过assign方式将这个结果值从cpu返回到本地内存中,随后将这个值返回到主内存中,并通过store的方式将这个值存储,最后将被修改的变量写回到主内存中。

【JUC系列-01】深入理解JMM内存模型的底层实现原理-LMLPHP

同时在使用这八种原子操作时,需要满足以下的规则

  • 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
  • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
  • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
  • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

3,引入volatile

在了解完这个jmm内存模型之后,知道java线程之间是如何进行线程通信的,再回到这个 JmmTest 方法中,现在可以大胆的猜测一下,是不是因为线程B修改完值后,没有人去通知线程A?所以才导致值没有发生变化

因此接下来继续验证,就是直接在这个flag变量前面增加一个关键字 volatile

private volatile boolean flag = true;

其结果如下,可以得出结论,线程A跳出了循环,就是意味着线程A接收到了这个最新的值

threadB修改flag:false
threadA跳出循环: count=399766740

因此查阅了一些资料,以及看了一下hotspot里面关于这个volatile关键字的源码,可以发现这个关键字是通过一个JVM的内存屏障来实现的。

storeload();  //jvm内存屏障,在汇编指令中,对应着lock关键字

内存屏障可以禁止该指令与前面和后面的读写指令重排序,并且可以使其他线程中的本地内存中的该值直接失效,这样其他内存就需要去主内存中获取改值,就能拿到最新的值了。因此volatile是通过内存屏障的方式来实现数据的可见性和有序性的。

除了这个volatile关键字之外,另外像synchronized,lock等这些锁底层都是采用了这个内存屏障来实现,因此这些重量级锁肯定也是可以保证可见性和有序性的,同时由于是重量级操作,除了这两种之外,他们同时还能保证原子性。

除了内存屏障可以保证可见性之外,关键字final也是可以保证可见性的。总而言之能保证可见性的方式只有两种:一种是内存屏障,一种是上下文切换

4,cpu缓存架构

在cpu中,主要由寄存器,程序计数器,高速缓存,逻辑运算单元组成,高速缓存又分了三级缓存,分别是一级缓存、二级缓存和三级缓存,一级缓存中又分为两部分,一个用于存储指令,一个用于存储数据。在inter处理器中,一个cpu又分为两个处理器,因此会存在两个cpu共享一个三级缓存的情况。

【JUC系列-01】深入理解JMM内存模型的底层实现原理-LMLPHP

使用高速缓存主要是减少等待内存的时间,提升CPU的计算能力

接下来根据这个缓存架构再举一个例子,现在有两个线程,分别是线程thread1和线程thread2,假设主内存中有一个值x=100,接下来两个线程同时去读这个100,线程1加对这个值加10,线程2对这个值加20,那么根据JMM的八大原子操作,此时线程1的CPU的值为110,线程2的CPU的值为120,最终会将这个值写回主内存中。

那么此时主内存就会出现两种情况,如果线程1先写回,线程2后写回,那么线程2会将线程1写回的值覆盖掉,此时;如果线程2先写回,线程后写回,那么线程会将线程2写回的值给覆盖掉,这就是经典的线程不安全问题

【JUC系列-01】深入理解JMM内存模型的底层实现原理-LMLPHP

造成这种原因的主要问题,是因为缓存不一致的问题。 即线程1的高速缓存的值和线程2的高速缓存的值不一致所导致的,因此为了解决这种缓存一致性的问题,主要有两种解决方式:嗅探机制、基于目录的机制

5,嗅探机制

再了解完这个导致数据不安全的原因是由于缓存不一致的问题,因此为了解决这个硬件层面的缓存一致性,最流行的还是使用这种嗅探机制

其工作原理如下:就是说如果存在多个缓存被共享的时候,如果有处理器修改了共享变量的值,那么必须传播到其他所有具有该变量的副本中,通过这种传播机制来防止系统违反缓存的一致性。就是说,数据的变更通知是通过总线来完成的。当其他缓存接收到这个通知信息之后,可以选择重新的在主内存中刷新数据,也可以直接让当前缓存中的值直接失效,具体是哪种做法,还得取决于使用哪种缓存一致性协议。

写失效:就是某个处理器将值改完之后,直接通知其他处理器,让其他处理器的缓存值失效

写更新:就是处理器将值修改完之后,在通知其他处理器的时候,直接将值携带上,让其他的处理器缓存值更新

总线的带宽是有效的,因此写失效的使用范围是最广的。MSI、MESI、MOSI、MOESI等是最常见的缓存一致性协议

6,解决缓存一致性的MESI

为了解决缓存一致性,使用最多的方式是这种MESI的方式,总共有四种状态,分别是

  • M:modify,修改状态
  • E:Exclusive,独占状态
  • S:Share,共享状态
  • I:Invalid,失效状态

【JUC系列-01】深入理解JMM内存模型的底层实现原理-LMLPHP

当工作内存将主内存的值加载到高速缓存之后,假设此时只有当前线程thread1加载了X=5,那么此时X是一个Exclusive独占状态,如果此时线程thread2也加载了这个值,那么此时该值则会从一个独占状态变成一个Share共享状态,如果此时线程thread1要修改这个值,那么在修改这个值后,X就会从一个共享状态变为一个Modify修改状态,并且在回显的时候被总线窥探到,总线就会发起请求告诉其他的线程这个被修改的值,让其他的线程缓存里面的改值直接失效Invalid,那么其他线程就可以去获取最新的值。

但是该协议并不是会直接生效,而是需要在特定的时候生效,就是需要一个lock前缀指令才可以满足该协议,如一些常见的volatile,synchronized,lock等关键字。这样才能解决这种缓存一致性的问题。但是volatile并不能保证原子性。

并且在某个线程更新了某个值之后,刷新主内存的线程会立即执行,这样才能让其他已经处于失效的线程立马的回到主内存中去更新改值,从而线程在获取值时减少数据的脏读问题以及长时间等待的问题。

除了缓存一致性协议之外,还有总线一致性协议,由于总线一致性的性能问题,缓存一致性协议才得以出现。

7,JMM内存可见性的保证

在单线程中:由于需要保证 else-if-serial 规范,即不管如何进行指令重排,都必须要保证最终结果的一致性,因此,单线程不存在内存可见性的问题,不管是编译器还是及时处理器等,都必须保证和原始顺序所执行的结果值相同

在正确同步的多线程中:如在加锁的情况下,JMM在内部会禁止指令重排的操作,并且在底层会通过内存屏障的操作来操作底层硬件,从而实现可见性和有序性的操作。

未同步的多线程:JMM不能保证未同步的执行结果与顺序一致性的结果一致。由于在JVM中,存在一些JIT即时编译器以及解释器的一些优化等,因此就会出现指令重排的情况。

x = 10;						y = 100;													
y = 100;          ====>		x = 10;
z = x + 10;					z = x + 10;

举个例子,如在单例模式加锁的双重检测中,需要在对象的前面加一个关键字 volatile,如果不加的话,在new对象的时候,会经历以下步骤:开辟内存空间,堆内存初始化,栈中对象指向堆中对象。这里就会出现一个问题,由于new对象并没有保证这个原子操作,因此就会出现指令重排的情况,就是可能会先指向堆中的对象,再在堆内存中初始化,就是第二步和第三步的顺序可能会发生改变。

public class SingletonTest{
    private volatile static SingletonTest instance = null;
    private SingletonTest() {}

    public static SingletonTest getInstance() {
            if (instance == null) {
                synchronized (SingletonTest.class) {
                    if (instance == null) {
                        //在不加volatile或者其他锁的情况下
                        //可能会出现指令重排的情况
                        instance = new Singleton();
                    }
                }
            }
            return instance;
     }
}

那在多线程的情况下,在第一个线程正好执行到发生指令重排的第二步,就是指向了一个堆中的对象,但还没有初始化,只是经历了实例化,而第二个线程进行第一个if判断的时候,此时并没有加锁,所以发现不为null,就直接return了,但是return的是一个你有进行初始化的一个值,因此返回的对象肯定是有问题的

所以为了解决这个指令重排的问题,就需要在这个对象上面加上volatile这个关键字了,这样就能禁止指令重排了

private volatile static SingletonTest instance = null;

8,内存屏障

在jvm和硬件层面都有实现内存屏障的方式。

在jvm层面,在JSR规范中定义了四种内存屏障,分别是LoadStore,LoadLoad,StoreLoad,StoreStore。Load操作可以当做成是一个read读取操作,Store操作可以当做成是一个写入操作,两个操作之间相当于加了一个一堵墙,从而保证两个操作的顺序不被打乱

LoadStore:在store2指令写入数据之前,保证数据一定被load1指令先写入进去

LoadLoad:在Load2指令读取数据之前,保证数据一定被load1指令先读取出来

StoreLoad:在Load2指令读取数据之前,保证数据一定被Store指令写入进去

StoreStore:在store2指令写入数据之前,保证数据一定被load1指令读取出来

并且以上的写入操作,都是可以实现所有的处理器都可以感知到数据的变化,即保证可见性。当前jvm底层实现内存屏障的方式主要是通过这个StoreLoad方式来实现的。

在硬件层面,也提供了一系列的内存屏障的方式保证数据的一致性,主要是通过ifence和sfence来实现读写屏障,也可以通过Lock前缀来实现这个类似于内存屏障的功能。但是在JMM内存模型中屏蔽了这种底层硬件带来的差异,直接由JVM来为不同的平台生成相应的字节码。

9,为何多线程的累加值总是小于期待值

了解这个JMM的内存模型之后,接下来通过之前的多线程的系列的文章,来对上述这个问题做一个初步的了解。

count++;

由于在java中,实现线程的方式是使用的内核态的方式实现的多线程,也就是说开发者只能通过内核去调用操作系统,再去调用线程,因此开发人员并不能控制线程,因此就不能控制上下文切换等,并且实现线程的方式是抢占式的方式实现,所以在累加操作中,某个值可能只执行了一半,就出现了cpu中时间片的切换,导致这个值被其他线程操作,如果是在多线程的情况下,两个线程同时操作一个值,就会出现这种值被覆盖的问题。因此最终出现的结果会小于期待值

其次是通过JMM模型可知,每个线程都有属于自己的工作区间,但是每个线程在将值修改之后,其他线程并不能感知到,就是无法保证可见性的问题,因此也会出现大量的值被覆盖。所以累加的结构也会小于期待值

因此需要通过加锁的方式强行保证线程间执行顺序,以及需要通过实现内存屏障的方式来实现线程间的可见性和有序性以及原子性。

08-21 08:57