你真的了解JMM吗?

3,479 阅读13分钟

引言

在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,举例说明变量在多个CPU之间的共享。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

一、JMM(Java Memory Model)

java虚拟机规范定义java内存模型屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的并发效果。

java内存模型规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

注意:我们这里强调的是共享变量,不是私有变量。

java内存模型规定了所有的变量都存储在主内存中(JVM内存的一部分)。每条线程都有自己的工作内存,工作内存中保存了该线程使用的主内存中共享变量的副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量;工作内存在线程间是隔离的,不能直接访问对方工作内存中的变量。所以在多线程操作共享变量时,就通过JMM来进行控制。

我们来看一看线程,工作内存、主内存三者的交互关系图。

二、JMM的8种内存交互操作

9龙就疑问,JMM是如何保证并发下数据的一致性呢?

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态。
  • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load (载入):作用于工作内存的变量,它把read操作从主存中得到变量放入工作内存的变量副本中。
  • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值赋值给工作内存的变量副本中,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store (存储):作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用。
  • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

如果是将变量从主内存复制到工作内存,必须先执行read,后执行load操作;如果是将变量从工作内存同步到主内存,必须先执行store,后执行write。JMM要求read和load, store和write必须按顺序执行,但不是必须连续执行,中间可以插入其他的操作。

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

三、volatile

很多并发编程中都使用了volatile,你知道为什么一个变量要使用volatile修饰吗?

volatile有两个语义:

  1. volatile可以保证线程间变量的可见性。
  2. volatile禁止CPU进行指令重排序。

volatile修饰的变量,如果某个线程更改了变量值,其他线程可以立即观察到这个值。而普通变量不能做到这一点,变量值在线程间传递均需要主内存来完成。如果线程修改了普通变量值,则需要刷新回主内存,另一个线程需要从主内存重新读取才能知道最新值。

3.1、volatile只能保证可见性,不能保证原子性

虽然volatile只能保证可见性,但不能认为volatile修饰的变量可以在并发下是线程安全的。

public class VolatileTest {
    /**
     * 进行自增操作的变量
     * 使用volatile修饰
     */

    private static volatile int count;

    public static void main(String[] args) {
        int threadNums = 2000;
        ExecutorService service = Executors.newCachedThreadPool();
        for (int i = 0; i < threadNums; i++) {
            service.execute(VolatileTest::addCount);
        }
        System.out.println(count);
        service.shutdown();
    }

    private static void addCount() {
        count++;
    }
}
//输出结果
//1994

我们可以从例子中看出,共享变量使用了volatile修饰,启动2000个线程对其进行自增操作,如果是线程安全的,结果应该是2000;但结果却小于2000。证明volatile修饰的变量并不能保证原子性,如果想保证原子性,还需要额外加锁。

3.2、volatile禁止指令重排序

虽然程序从表象上看到是按照我们书写的顺序进行执行,但由于CPU可能会由于性能原因,对执行指令进行重排序,以此提高性能。

比如我们有一个方法是关于“谈恋爱”的方法。伪代码如下

{
    //线程A执行1,2,3
    //1、先认识某个女生,有好感
    //2、开展追求
    //3、追求成功

    //线程B,等待线程A追求成功后开始进入甜蜜的爱情
    while(!追求成功){
        sleep();
    }
    //一起看电影,吃饭,牵手,接吻,xxx
}

我们看到线程A需要执行3步,由于cpu执行重排序优化,可能执行顺序变为1、3、2,乱套了,刚认识别人就成功了,接着就牵手,接吻,然后可能再执行追求的过程。。。。。。。。不敢想象,我还只是个孩子啊。这就是指令重排序可能在多线程环境下出现的问题。

如果我们使用volatile修饰“追求成功”的变量,则可以禁止CPU进行指令重排序,让谈恋爱是一件轻松而快乐的事情。

volatile使用内存屏障来禁止指令重排序。
在每个volatile写操作的前面插入一个StoreStore屏障,在每个volatile写操作的后面插入一个StoreLoad屏障。

在每个volatile读操作的后面插入一个LoadLoad屏障,在每个volatile读操作的后面插入一个LoadStore屏障。

四、原子性、可见性、顺序性

我们看到JMM围绕这三个特征来建立的。

4.1、原子性

JMM提供了read、load、use、assign、store、write六个指令直接提供原子操作,我们可以认为java的基本变量的读写操作是原子的(long,double除外,因为有些虚拟机可以将64位分为高32位,低32位分开运算)。对于lock、unlock,虚拟机没有将操作直接开放给用户使用,但提供了更高层次的字节码指令,monitorentermmonitorexit来隐式使用这两个操作,对应于java的synchronized关键字,因此synchronized块之间的操作也具有原子性

4.2、可见性

我们上面说了线程之间的变量是隔离的,线程拿到的是主存变量的副本,更改变量,需要刷新回主存,其他线程需要从主存重新获取才能拿到变更的值。所有变量都要经过这个过程,包括被volatile修饰的变量;但volatile修饰的变量,可以在修改后强制刷新到主存,并在使用时从主存获取刷新,普通变量则不行。

除了volatile修饰的变量,synchronized和final。synchronized在执行完毕后,进行unlock之前,必须将共享变量同步回主内存中(执行store和write操作)。前面规则其中一条。

而final修饰的字段,只要在构造函数中一旦初始化完成,并且没有对象逃逸(指对象为初始化完成就可以被别的线程使用),那么在其他线程中就可以看到final字段的值。

4.3、有序性

有序性在volatile已经详细说明了。可以总结为,在本线程观察到的结果,所有操作都是有序的;如果多线程环境下,一个线程观察到另一个线程的操作,就说杂乱无序的。

java提供了volatile和synchronized两个关键字保证线程之间的有序性,volatile使用内存屏障,而synchronized基于lock之后,必须unlock后,其他线程才能重新lock的规则,让同步块在在多线程间串行执行。

五、Happends-Before原则

先行发生是java内存模型中定义的两个操作的顺序,如果说操作A先行发生于线程B,就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值,发送了消息,调用了方法等。

我们举个例子说一下。

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

我们还是定义A线程执行 i = 1 先行发生于 线程B执行的 j = i;那么我们可以确定,在线程B执行之后,j的值是1。因为根据先行发生原则,线程A执行之后,i的值为1,可以被B观察到;并且线程A执行之后,线程B执行之前,没有线程对i的值进行变更。

这时候我们考虑线程C,如果我们还是保证线程A先行发生于B,但线程C出现在A与B之间,那么,你可以确定j的值是多少吗?答案是否定的。因为线程C的结果也可能被B观察到,这时候可能是1,也可能是2。这就存在线程安全问题。

在JMM下具有一些天然的先行发生关系,这些原则在无须任何同步协助下就已经存在,可以直接使用。如果两个操作之间的关系不在此列,并且无法从以下先行发生原则推导出来,它们就没有顺序性保证,虚拟机就会进行随意的重排序。

  • 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。

  • 锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。

  • volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作

  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作

  • 线程终止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必晚于线程中所有操作

  • 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生

  • 对象终止规则(Finalizer Rule):一个对象的初始化方法先于执行它的finalize()方法

  • 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C

总结

本篇详细总结了Java内存模型。再来品一品这句话。

java内存模型规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量

各位看官,如果觉得9龙的文章对你有帮助,求点赞,求关注。如果转载请注明出处。

本篇主要总结于:

深入理解Java虚拟机++JVM高级特性与最佳实践