【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障

Kotlin 开发者社区 3天前

很多时候,千言万语不如一张图:

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型

 

停停停,发错了,看下面的 JVM 内存模型图:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_02

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_03

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_04

 

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_05

 

 

下面是摘的文章,写的很详细:

 

Java 内存模型

 

​Java内存模型​​规范了Java虚拟机与计算机内存是如何协同工作的。Java虚拟机是一个完整的计算机的一个模型,因此这个模型自然也包含一个内存模型——又称为Java内存模型。

如果你想设计表现良好的并发程序,理解Java内存模型是非常重要的。Java内存模型规定了如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

原始的Java内存模型存在一些不足,因此Java内存模型在Java1.5时被重新修订。这个版本的Java内存模型在Java8中仍在使用。

Java内存模型内部原理

Java内存模型把Java虚拟机内部划分为​​线程栈​​​和​​堆​​。这张图演示了Java内存模型的逻辑视图。

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_06

每一个运行在Java虚拟机里的线程都拥有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其它线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程 拥有每个本地变量的独有版本。

 

所有原始类型的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,但是它不能共享这个原始类型变量自身。

堆上包含在Java程序中创建的所有对象,无论是哪一个对象创建的。这包括原始类型的对象版本。如果一个对象被创建然后赋值给一个局部变量,或者用来作为另一个对象的成员变量,这个对象任然是存放在堆上。

下面这张图演示了调用栈和本地变量存放在线程栈上,对象存放在堆上。

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_07

一个本地变量可能是原始类型,在这种情况下,它总是“呆在”线程栈上。

  • 一个本地变量也可能是指向一个对象的一个引用。在这种情况下,引用(这个本地变量)存放在线程栈上,但是对象本身存放在堆上。
  • 一个对象可能包含方法,这些方法可能包含本地变量。这些本地变量任然存放在线程栈上,即使这些方法所属的对象存放在堆上。
  • 一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。
  • 静态成员变量跟随着类定义一起也存放在堆上。
  • 存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个本地变量的私有拷贝。

 

下图演示了上面提到的点:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_08

 

两个线程拥有一些列的本地变量。其中一个本地变量(Local Variable 2)执行堆上的一个共享对象(Object 3)。这两个线程分别拥有同一个对象的不同引用。这些引用都是本地变量,因此存放在各自线程的线程栈上。这两个不同的引用指向堆上同一个对象。

注意,这个共享对象(Object 3)持有Object2和Object4一个引用作为其成员变量(如图中Object3指向Object2和Object4的箭头)。通过在Object3中这些成员变量引用,这两个线程就可以访问Object2和Object4。

这张图也展示了指向堆上两个不同对象的一个本地变量。在这种情况下,指向两个不同对象的引用不是同一个对象。理论上,两个线程都可以访问Object1和Object5,如果两个线程都拥有两个对象的引用。但是在上图中,每一个线程仅有一个引用指向两个对象其中之一。

因此,什么类型的Java代码会导致上面的内存图呢?如下所示:


 

1. public class MyRunnable implements Runnable() {
2.
3. public void run() {
4. methodOne();
5. }
6.
7. public void methodOne() {
8. int localVariable1 = 45;
9.
10. MySharedObject localVariable2 =
11. MySharedObject.sharedInstance;
12.
13. //... do more with local variables.
14.
15. methodTwo();
16. }
17.
18. public void methodTwo() {
19. Integer localVariable1 = new Integer(99);
20.
21. //... do more with local variable.
22. }
23. }
24.
25.
26. public class MySharedObject {
27.
28. //static variable pointing to instance of MySharedObject
29.
30. public static final MySharedObject sharedInstance =
31. new MySharedObject();
32.
33.
34. //member variables pointing to two objects on the heap
35.
36. public Integer object2 = new Integer(22);
37. public Integer object4 = new Integer(44);
38.
39. public long member1 = 12345;
40. public long member1 = 67890;
41. }

如果两个线程同时执行run()方法,就会出现上图所示的情景。run()方法调用methodOne()方法,methodOne()调用methodTwo()方法。

​methodOne()​​声明了一个原始类型的本地变量和一个引用类型的本地变量。

每个线程执行​​methodOne()​​​都会在它们对应的线程栈上创建​​localVariable1​​​和​​localVariable2​​​的私有拷贝。​​localVariable1​​​变量彼此完全独立,仅“生活”在每个线程的线程栈上。一个线程看不到另一个线程对它的​​localVariable1​​私有拷贝做出的修改。

每个线程执行​​methodOne()​​​时也将会创建它们各自的​​localVariable2​​​拷贝。然而,两个​​localVariable2​​​的不同拷贝都指向堆上的同一个对象。代码中通过一个静态变量设置​​localVariable2​​​指向一个对象引用。仅存在一个静态变量的一份拷贝,这份拷贝存放在堆上。因此,​​localVariable2​​​的两份拷贝都指向由​​MySharedObject​​​指向的静态变量的同一个实例。​​MySharedObject​​实例也存放在堆上。它对应于上图中的Object3。

注意,​​MySharedObject​​​类也包含两个成员变量。这些成员变量随着这个对象存放在堆上。这两个成员变量指向另外两个​​Integer​​​对象。这些​​Integer​​对象对应于上图中的Object2和Object4.

注意,​​methodTwo()​​​创建一个名为​​localVariable​​​的本地变量。这个成员变量是一个指向一个​​Integer​​​对象的对象引用。这个方法设置​​localVariable1​​​引用指向一个新的​​Integer​​​实例。在执行​​methodTwo​​​方法时,​​localVariable1​​​引用将会在每个线程中存放一份拷贝。这两个​​Integer​​​对象实例化将会被存储堆上,但是每次执行这个方法时,这个方法都会创建一个新的​​Integer​​​对象,两个线程执行这个方法将会创建两个不同的​​Integer​​​实例。​​methodTwo​​​方法创建的​​Integer​​对象对应于上图中的Object1和Object5。

还有一点,​​MySharedObject​​​类中的两个​​long​​类型的成员变量是原始类型的。因为,这些变量是成员变量,所以它们任然随着该对象存放在堆上,仅有本地变量存放在线程栈上。

硬件内存架构

现代硬件内存模型与Java内存模型有一些不同。理解内存模型架构以及Java内存模型如何与它协同工作也是非常重要的。这部分描述了通用的硬件内存架构,下面的部分将会描述Java内存是如何与它“联手”工作的。

下面是现代计算机硬件架构的简单图示:

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_09

 

一个现代计算机通常由两个或者多个CPU。其中一些CPU还有多核。从这一点可以看出,在一个有两个或者多个CPU的现代计算机上同时运行多个线程 是可能的。每个CPU在某一时刻运行一个线程是没有问题的。这意味着,如果你的Java程序是多线程的,在你的Java程序中每个CPU上一个线程可能同 时(并发)执行。

每个CPU都包含一系列的寄存器,它们是CPU内内存的基础。CPU在寄存器上执行操作的速度远大于在主存上执行的速度。这是因为CPU访问寄存器的速度远大于主存。

每个CPU可能还有一个​​CPU缓存层​​。实际上,绝大多数的现代CPU都有一定大小的缓存层。CPU访问缓存层的速度快于访问主存的速度,但通常比访 问内部寄存器的速度还要慢一点。一些CPU还有多层缓存,但这些对理解Java内存模型如何和内存交互不是那么重要。只要知道CPU中可以有一个缓存层就 可以了。

一个计算机还包含一个主存。所有的CPU都可以访问主存。主存通常比CPU中的缓存大得多。

通常情况下,当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中。它甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到缓存中,然后在某个时间点将值刷新回主存。

当CPU需要在缓存层存放一些东西的时候,存放在缓存中的内容通常会被刷新回主存。CPU缓存可以在某一时刻将数据局部写到它的内存中,和在某一时 刻局部刷新它的内存。它不会再某一时刻读/写整个缓存。通常,在一个被称作“​​cache lines​​”的更小的内存块中缓存被更新。一个或者多个缓存行可能被读到缓存,一个或者多个缓存行可能再被刷新回主存。

Java内存模型和硬件内存架构之间的桥接

上面已经提到,Java内存模型与硬件内存架构之间存在差异。硬件内存架构没有区分线程栈和堆。对于硬件,所有的线程栈和堆都分布在主内中。部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的寄存器中。如下图所示:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_10

 

 

当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。主要包括如下两个方面:

-线程对共享变量修改的可见性

-当读,写和检查共享变量时出现race conditions

下面我们专门来解释以下这两个问题。

共享对象可见性

如果两个或者更多的线程在没有正确的使用​​volatile​​声明或者同步的情况下共享一个对象,一个线程更新这个共享对象可能对其它线程来说是不接见的。

想象一下,共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中。然后修改了这个对象。只要CPU缓存没有被刷新会主 存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。

下图示意了这种情形。跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去。

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_缓存_11

 

 

解决这个问题你可以使用Java中的​​volatile​​​关键字。​​volatile​​关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。

Race Conditions

如果两个或者更多的线程共享一个对象,多个线程在这个共享对象上更新变量,就有可能发生race conditions。

想象一下,如果线程A读一个共享对象的变量count到它的CPU缓存中。再想象一下,线程B也做了同样的事情,但是往一个不同的CPU缓存中。现在线程A将​​count​​​加1,线程B也做了同样的事情。现在​​count​​已经被增在了两个,每个CPU缓存中一次。

如果这些增加操作被顺序的执行,变量​​count​​应该被增加两次,然后原值+2被写回到主存中去。

然而,两次增加都是在没有适当的同步下并发执行的。无论是线程A还是线程B将​​count​​修改后的版本写回到主存中取,修改后的值仅会被原值大1,尽管增加了两次。

下图演示了上面描述的情况:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_12

解决这个问题可以使用​​Java同步块​​。一个同步块可以保证在同一时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码块中所有被访问的变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。        

(原文:http://www.tianshouzhi.com/api/tutorials/mutithread/66)

 

 

从CPU到Java内存模型

 

1.1 从CPU内存模型说起

CPU主要用于指令的执行和运算,程序运行时的数据主要保存在内存(RAM)中;因为CPU的速度远快于主存,为了提高CPU的利用率(减少读写内存时的空等情况),会在CPU和内存之间加入一层或多层高速缓存。

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_13

 

缓存的引入极大地提高了CPU性能,但是也不可避免地引入了缓存一致性的问题。在CPU层面,其内存模型主要确定了:当前CPU写入数据后,其他CPU什么时候能够看到

有些CPU有很强的内存模型(strong memory model),能够让所有的CPU在任何时候、任何指定的内存地址上,都可以看到完全相同的值。而一些CPU则有较弱的内存模型(weaker memory model),在这种CPU中,必须使用内存屏障(一种特殊的指令)来刷新本地CPU缓存并使本地CPU缓存无效,目的是为了让当前CPU能够看到其他CPU的写操作,或者让其他CPU能看到当前CPU的写操作。内存屏障在高级语言中对程序员是不可见的。

1.2 Java内存模型的引入

为了屏蔽底层CPU的内存模型的各种差异化,Java抽象出了一个自己的内存模型,Java内存模型(Java Memory Model, JMM)确定了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。
Java内存模型主要确定了:当前线程写入数据后,其他线程什么时候能够看到

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_14

 

Java内存模型中定义的8种操作

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

  Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了被盖线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

  下图时线程、主内存、工作内存三者的交互关系:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_缓存_15

  对于主内存宇工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型定义了一下8种操作来完成,虚拟机实现时必须保证一下每一种操作都是原子的:

 

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_缓存_16

 

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

  如果要把一个变量从主内存复制到工作内存,就要顺序执行read和load操作,如果要把变量从工作内存同步回主内存,就要顺序执行store和write操作。Java内存模型只要求必须顺序执行,而没有保证是连续执行。Java内存模型还规定了在执行上述8种基本操作时必须满足的规则:

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

  对于volatile型变量,Java内存模型定义了一些特殊的访问规则。当一个变量定义为volaitile之后,将具备两种特性:

  1.保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的,普通变量的值在线程间传递均需要通过主内存来完成。volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要同故宫加锁来保证原子性:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束

  2.禁止制定重排序优化。


 

 

值得注意的是,这里关注的不再是CPU,而是Java线程,每个线程会有自己的工作内存(本地内存),从主内存(共享内存/堆内存)读取数据后,会拷贝一份至工作内存,下一次读的时候,会通过工作内存进行读取。至于工作内存的具体实现,可以是CPU中的高速缓存,也可能是其他一些缓存介质。Java程序员并不需要操心这些,Java内存模型保证了代码在各种硬件和编译器的优化的情况下,依然能够被正确执行。

什么叫正确执行呢?比如synchronized同步块如果只有一个线程能够进入这叫正确执行,如果两个线程进入,那就不叫正确执行了,因为不符合synchronized在JMM中规定的语义了。
JMM给synchronized、volatile、线程开启等赋予了特定的语义(在代码中代表的含义),底层的实现必须满足这些语义。

2. 优化带来的重排序

如果出现重排序:前面的写挪到了读后面了,自然是“看不见”这个操作了。Java内存模型往往通过确定操作的前后顺序来保证一些特定的操作可见(写完能被其他线程看见);本节先来看看Java程序中可能涉及到重排序。

Java代码执行到最终被可见,其过程主要包括:

  1. Java代码被前端编译器编译为class字节码;
  2. 解析执行一定次数后,会被JIT编译器编译为本地机器码(汇编指令);
  3. CPU将指令加载至寄存器,进行解码指令执行;
  4. 执行结果可以被其他CPU可见,这里涉及到CPU本身的缓存一致性处理方式。

而上面中的每一步,都有可能会涉及重排序,主要包含:

  1. 编译器(前后端编译)优化导致的重排序;
  2. CPU并行执行指令导致的重排序;
  3. CPU缓存(一致性)优化,使得其他CPU对操作不可见导致的重排序。

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_缓存_17

以上优化的前提是必须遵守as-if-serial(就好像是串行):即不管如何重排序,(单线程)程序的执行结果都不能改变

2.1. 编译器优化重排序

比如在某个线程中的程序代码:

a = 1; // 指令1
b = 1; // 指令2
c = a + b; // 指令3

如果编译后没有做任何优化,指令顺序为:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_18

原指令顺序

因为指令1和指令2没有依赖关系,编译器可能会根据一定的优化规则,对这两个指令进行重排序,比如编译后的指令顺序可以为2-1-3:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_19

编译器重排序


这里顺便一提,重排序只是编译器优化的一种表现,其他一些优化可能使得程序执行的行为显得格外诡异。就好比以下这段经典的死循环代码:

public class SubThread extends Thread {
private static boolean flag = false;

public static void main(String[] args) throws Exception {
new SubThread().start(); // 开始执行子线程的循环
Thread.sleep(100); // 保证循环代码运行次数足够多,触发JIT编译
flag = true;
}

public void run() {
while (!flag) { }; // 死循环,尽管flag变为true也不会停止
}

}

这段代码执行后可能会进入死循环,这并不是什么可见性/重排序等导致的问题,而是编译器优化导致的。编译器会认为这段循环代码在(单线程)运行中,flag变量不会被改变,从而优化为:

// 原指令:while (!flag) { };

// 优化后的指令:
if(flag) { // 只读一次
while (true) { }; // 死循环
}

防止出现死循环的方式有很多,比如:
(1)flag声明为​​​volatile​​​变量;
(2)循环体中增加打印语句(打印方法中会包含​​​synchronized​​​);
(3)循环体中增加​​​Thread.yeild()​​让出时间片等会引起线程切换方法;

以上动作就像是Java代码给编译器的“暗示”:flag的变量是有可能会变化的,从而编译器不会再进行类似优化。
因为JMM规定读volatile变量和进入同步块前会清空工作内存(保证可见性,具体参考);线程切换(上下文切换)也会导致CPU高速缓存被清空。

除此之外,也可以禁用JIT编译器(-Xint或-Djava.compiler=NONE),禁用后JVM每次都会解释执行,也不会出现死循环的情况。

2.2 指令级并行重排序

为了提高指令执行速度,现代处理器采用了指令级并行技术(Instruction-Level Parallism,ILP)来将多条指令重叠执行。

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_20

 

 

2.3 缓存优化重排序

我们来看一个多核CPU中指令的执行情况:

// 初始化 a=0, flag=false
// CPU-0 执行
a = 1;
flag = true;

// CPU-1 执行
while ( !flag ) continue; // 未初始化成功,进行空等
assert a == 1

由于对缓存的优化,有时候会出现assert failed(即​​a != 1​​​)的情况。
​​​a = 1​​​虽然比​​flag = true​​​先执行(执行后CPU-0看到的值都是最新的);然而​​flag​​​有可能先于​​a​更早地刷入主存,从而被其他CPU看到。CPU-1读到最新的​​flag = true​​​后,while循环结束后,如果此时​​a​​​对cpu-1尚未可见,会读到内存中的旧值​​0​​。

这时候会出现一种有意思的现象:
从CPU-0的角度看,执行顺序为:​​​a = 1​​​ > ​​flag = true​​​;
从CPU-1的角度看,执行顺序为:​​​flag = true​​​ > ​​a = 1​​(因为flag最新值先可见);这种不及时可见导致的重排序也有人称“伪重排序”。

现代的CPU的缓存一致性多是这种最终一致性(允许短时间内不一致)。上例中​​a=1​​​始终会被其他cpu所见,只不过是时间问题而已。
如果需要保证其可见性(写完后即刻可见),则需要在特定位置加入内存屏障指令(Memory barrier),对应到Java程序则是需要加上volatile、synchronized等的同步手段。

为什么叫“缓存优化”呢?在一般的缓存一致性协议中(MSI、MESI等),其协议通常是强一致性的。但是有时候这种强一致性不是必要的,即使没有强一致也不会影响(单线程)执行结果,所以很多CPU缓存往往会引入Store Buffer、Invalidate Queue等手段进行优化(类似缓存的缓存),从而导致了这种可见性的问题。具体可以参考:Why Memory Barriers.

3. happends-before

3.1 happends-before与允许的重排序

JMM规范(JSR-133)对happends-before的简单定义是这样的:

If one action happens-before another, then the first is visible to and ordered before the second.
如果一个操作(action) happens-before 另一个操作,则第一个对第二个可见,且第一个排在第二个之前。

简言之,A happens-before B,A先执行,B后执行,A禁止重排序到B后面。
然而JSR-133中也屡次提到,两个操作之间存在 happens-before 关系并不意味着这些操作必须以这种顺序发生;如果说优化(重排序)后,不影响执行结果(和原来没重排序的结果一样),那么这种优化重排序是允许的

我们这里先看一种不允许优化的情况:当两个操作存在happens-before 关系的,且存在冲突访问(Conflicting Accesses )时,禁止重排序

冲突访问指的是:两个操作访问同一个共享变量,且这两个操作中有一个为写操作时,就被认为存在冲突访问(或者叫数据依赖性)。这种情况下,重排序往往会破坏原来的执行结果。
比如:​​​(1)a = 1; (2)b = 1; (3)c = a + b;​​​,(1)和(2)允许重排序,因为没有冲突访问;但是(1)和(3)不允许重排序,因为(1)写a,(3)读a,这两者存在冲突访问,如果重排序后,结果就不为​​c == 2​​了。

JMM定义的happens-before规则不少,我们先来看一个简单的:
【规则1】程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作
简单理解就是:代码都是从上往下执行的,前面的代码 happens-before 后面的代码。

举个例子:

public class RecordDemo {
static int a = 0;
static boolean flag = false;

// 线程A执行write()
static void write() {
a = 1;
flag = true;
}

// 线程B执行read()
static void read() {
if (flag) {
int i = a;
...
}
}
}

线程A:​​a = 1​​​ happens-before ​​flag = true​​​;
线程B:​​​if(flag)​​​ happens-before ​​i = a​​;

值得注意的是,线程A和线程B的操作之间是没有任何happens-before关系的。而因为 happens-before 有时候是允许重排序优化,这就有可能会出现一些诡异的情况:i == 0

“按道理讲”,如果能够进入if块,说明​​flag == true​​​了,那么线程A中的​​a = 1​​​肯定执行了;
然而虽然​​​a = 1​​​ happens-before ​​flag = true​​​,​​a = 1​​​和​​flag = true​​却是不存在冲突访问的,JMM允许两者进行重排序,此时整体的执行序列可能为:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_21

这时候就得到 i == 0 这种诡异的结果了。
如果期望进入​​​if块​​​后读到的a一定是1的话(这个才是符合我们日常认识的),那么就必须引入同步手段了,在此之前,我们先来看看happens-before的另外两条规则:
【规则2】volatile变量规则:对一个volatile变量的写,happens-before 于任意后续对这个volatile变量的读;
【规则3】传递性规则:如果 A happens-before B,且 B happens-before C,那么A happens-before C;

我们引入一个传说中轻量级同步的volatile,将flag声明为volatile变量:

public class RecordDemo {
static int a = 0;
static volatile boolean flag = false;
...其他不变
}

根据【规则2】如果线程A先执行​​flag=true​​​,线程B再执行​​if(flag)​​​ (读flag),此时 ​​flag=true​​​ happens-before ​​if(flag)​​​;
再看【规则1】​​​a = 1​​​ happens-before ​​flag = true​​​;​​if(flag)​​​ happens-before ​​i = a​​​;
结合【规则3】推断出:​​​a = 1​​​ happen-before ​​i = a​​​;这两者时存在冲突访问的,禁止重排序,也就是说,​​i = a​​​ -> ​​a = 1​​ 这样的顺序是禁止的。自然也就不会出现 i == 0这种诡异的情形了。

【规则2】就如同一座桥梁将两个线程之间的happen-before关系给连接起来,synchronized也有类似的规则。

当然如果线程B先执行​​if(flag)​​​,线程A再执行​​flag=true​​,此时不满足【规则2】,两个线程之间又没什么关系了;此时 i == 0 这种诡异的情况也不会发生(因为if块压根不会进入了),这个是我们“期待”的。

JMM就是通过制定这样的happens-before规则,来保证加入同步手段后(比如volatile/synchronized/final等)能确定多个线程之间的同步顺序;
在没有类似同步手段/访问冲突的限制时,又允许其底层进行各种各样的优化,尽量提高Java程序的效率。

3.2 完整的happens-before规则

 

完整的happen-before规则包含了:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁(synchronized);
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C;
  5. start规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作;
  6. join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回;
  7. 中断规则:线程A调用线程B的interrupt() happens-before 线程A发现B被中断(B抛出异常或者A通过isInterrupted() / interrupted()检测到B被中断)
  8. 终结规则:一个对象的构造函数的结束happends-before于这个对象finalizer的开始。

看着挺多规则,但是这些都是为了执行结果能够“符合我们日常认识”,没有必要刻意去记,了解volatile/synchroized/final/Thread.start()/join()等代表的意思就足够了,这些规则大都是为了这些语义服务的。

happens-before 内存模型在JSR-133中被称为“Java内存模型的近似模型”,单纯 happens-before 内存模型可能会导致一些因果关系错乱的问题。完整的Java内存模型其实是 happens-before 内存模型的加强版,个人认为其加强的目的主要还是为了代码执行顺序能够“符合日常认识”。由于完整的Java内存模型过于复杂,个人水平有限,就不再细谈了。

4. volatile与内存屏障

如果说happens-before是用来(理论上)描述JMM规定的先后顺序的话,那么内存屏障可能就是真正的实现者了。

4.1 volatile语义

我们先来看看volatile的语义:

  1. 可见性,读到的值永远都是最新的(写完之后能即刻被后续的读看到):
    1.1 volatile写后,会把该线程的工作内存刷新到主内存;
    1.2 volatile读前,会将该线程的工作内存置为无效,从而从主内存读;
  2. 禁止重排序,当存在两个操作涉及到volatile时:
    2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序;
    2.2 第一个操作为volatile读,第二个为普通变量的读/写时,禁止重排序;
    2.3 第二个操作为volatile写,第一个为普通变量的读/写时,禁止重排序;

这里的语义1其实也可以归为“重排序”(具体参考本文#2.3),本节主要讨论volatile禁止重排序的情况。语义2.1 其实就是本文 #3.1 中涉及的例子,语义2.2和2.3是volatile在JDK1.5之后增强的语义,目的是消除类似DCL单例读到[未完全初始化对象]的“违背常理”的情况。

4.2 使用内存屏障阻止重排序

编译器会根据volatile/synchronized/final等的语义,在特定的位置插入内存屏障。当遇到特定的内存屏障指令时,处理器将禁止其对应的重排序,保证屏障前面的操作可以被后面的操作可见。

根据前后两个(读写)操作的组合,一共可以细为四种内存屏障,插入屏障后,会禁止前一个操作重排序到后一个的后面

操作

内存屏障

内存含义(可见性)

读读

Load1, LoadLoad, Load2

确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入

写写

Store1, StoreStore, Store2

确保Store1的数据在Store2以及后续Store指令操作相关

数据之前对其它处理器可见(例如向主存刷新数据)

读写

Load1, LoadStore, Store2

确保Load1的数据在Store2和后续Store指令被刷新之前读取

写读

Store1, StoreLoad, Load2

确保Store1的数据在被Load2和后续的Load指令读取之前

对其他处理器可见

有了这些屏障,我们要满足volatile/Synchronized/final等的内存语义就简单了,比如“2.1 第一个操作为volatile写,第二个为volatile读时,禁止重排序”,我们只需要 在volatile写和volatie读之间插入一个 StoreLoad 屏障即可。
其他的也类似,JMM完整的屏障插入要求如下表:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_22

注:MonitorEnter/Exit对应于synchronized同步块的进入和退出

以volatile为例,下图展示的是一个保守的策略,实际上可以做一些优化,比如两个一样的屏障只需保留一个;分析程序发现volatile写前不会出现普通读的话,LoadStore屏障可以移除等:

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_主存_23

 

volatile涉及的内存屏障

 

这四种屏障对应的底层实现,不同的处理器架构(指令集)处理是不一样的。比如在一些非常强的处理器内存模型中,可能压根就不会有处理器重排序优化,自然用不上这些屏障;但是稍弱一些的内存模型,则需要通过相应的内存屏障指令告诉处理器哪些是需要禁止的,哪些是允许优化的。

以我们常用的 x86-64 架构来说,其内存模型是比较强的,读读/写写/读写均不会出现重排序优化,但是写读(Store, Load)是存在重排序优化的(参考Intel® 64 Architecture Memory Ordering #2),如果需要禁止这种重排序,则需要插入StoreLoad屏障,具体实现指令可以为​​mfence​​​或​​lock addl​​等。


参考资料:
[1] JSR-133 (中文版)
[2] JSR 133 (Java Memory Model) FAQ
[3] The JSR-133 Cookbook for Compiler Writers
[4] 译:Why Memory Barriers.
[5] 计算机科学速成课 P1-P9
[6] 《Java并发编程的艺术》

 

 

Kotlin 开发者社区

国内第一Kotlin 开发者社区公众号,主要分享、交流 Kotlin 编程语言、Spring Boot、Android、React.js/Node.js、函数式编程、编程思想等相关主题。

【文章精选集锦】Java 内存模型与 volatile :happens-before,重排序,内存屏障_java内存模型_24

Kotlin 开发者社区