Java内存模型

在现代计算机中,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 有两个语义:
  • volatile 可以保证线程间变量的可见性。
  • 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,虚拟机没有将操作直接开放给用户使用,但提供了更高层次的字节码指令,monitorenterm 和 monitorexit 来隐式使用这两个操作,对应于 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 内存模型规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

扫码领红包

微信赞赏支付宝扫码领红包

发表回复

后才能评论