前面讲到,现代计算机系统引入了高速缓存来作为内存与处理器之间的缓冲,基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但是也为计算机系统带来了更高的复杂度,引入了一个新的问题:缓存一致性(Cache Coherence)。

为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写共享变量时要根据协议来进行操作,这类协议有 MSI、MESI、MOSI、Synapse、Firefly 及 Dragon Protocol 等。统称为 缓存一致性协议。
image.png
上图为硬件架构下通用的内存模型,它可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理机器可以拥有不一样的内存模型,而 Java 虚拟机也有自己的内存模型,并且与上图中的内存访问操作及硬件的缓存访问操作具有高度的可类比性。

什么是 JMM

Java 虚拟机规范中定义了 Java 内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言,比如 C 和 C++ 等,直接使用物理硬件和操作系统的内存模型。由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错。而 Java 内存模型则为开发人员屏蔽了硬件及操作系统的不一致性,但直至 JDK 5(实现了 JSR-133)发布后,Java 内存模型才终于成熟、完善起来。

1. 主内存与工作内存

Java 内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量包括了实例字段静态字段构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享。为了获得更好的性能,Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

Java 内存模型规定了所有的变量都存储在 主内存(类比物理硬件中的主内存,但物理上它仅是虚拟机内存的一部分),每条线程还有自己的 工作内存(类比物理硬件中的高速缓存),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成。线程、主内存、工作内存三者的交互关系如下图所示:
img.jpeg
这里所说的主内存、工作内存与 Java 内存区域中的堆、栈、方法区等并不是同一个层次的对内存的划分,如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。

2. 内存间的交互操作

关于主内存与工作内存间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这类的实现细节,Java 内存模型中定义了以下 8 种操作来完成。Java 虚拟机会保证下面提及的每一种操作都是原子的、不可再分的(对于 double 和 long 类型的变量来说,在某些平台上允许有例外)。

  • lock:作用于主内存变量,把一个变量标识为一条线程独占的状态
  • unlock:作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定

  • read:作用于主内存变量,把一个变量的值从主内存传输到线程的工作内存

  • load:作用于工作内存变量,把 read 操作从主内存中得到的变量值放入工作内存的变量副本

  • use:作用于工作内存变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作

  • assign:作用于工作内存变量,把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作

  • store:作用于工作内存变量,把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用

  • write:作用于主内存变量,把 store 操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 readload 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 storewrite 操作。Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行,即 read 与 load 之间、store 与 write 之间是可插入其他指令的。
jvm_memory_thread.png
上述定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦,后来 Java 设计团队大概也意识到了这个问题,将 Java 内存模型的操作简化为 read、write、lock 和 unlock 四种,但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变。

3. 解决原子性、可见性和有序性

Java 内存模型其实就是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的,下面我们逐个来看一下哪些操作实现了这三个特性:

原子性(Atomicity)
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write 这六个,我们大致可以认为基本数据类型的访问、读写都是具备原子性的。long、double 的非原子性协定是例外,因为 long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位)。
image.png
如果应用场景需要一个更大范围的原子性保证,Java 内存模型还提供了 lockunlock 操作来满足,尽管虚拟机未把 lock 和 unlock 操作直接开放给用户使用,但却提供了更高层次的字节码指令 monitorenter 和 monitorexit 来隐式地使用这两个操作。反映到 Java 代码中就是 synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。区别是 volatile 的特殊规则保证了新值能立即同步回主内存中,以及每次使用前立即从主内存刷新。因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。

除 volatile 外 Java 还有两个关键字能实现可见性,它们是 synchronized 和 final。同步块的可见性是由【对一个变量执行 unlock 操作前,必须先把此变量同步回主内存中】这条规则获得的。而 final 关键字的可见性是指:被 final 修饰的字段在构造器中一旦被初始化完成,并且在构造器中没有把 this 引用传递出去(在构造器中没有把 this 赋值给共享变量或作为方法参数传递给其他方法),那在其他线程中就能看见 final 字段的值。

有序性(Ordering)
Java 语言提供了 volatile 和 synchronized 两个关键字来保证线程间操作的有序性,volatile 关键字本身就包含了禁止指令重排序的语义,而 synchronized 则是由【一个变量在同一个时刻只允许一条线程对其进行 lock 操作】这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。

4. 内存屏障

内存屏障是对一类仅针对内存读、写操作指令(instruction)的跨处理器架构的比较底层的抽象。内存屏障是被插入到两个指令之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。它在指令序列中就像是一堵墙(因此被称为屏障)一样使其两侧的指令无法“穿越”它(一旦穿越了就是重排序了)。但为了实现禁止重排序的功能,这些指令也往往具有一个副作用——刷新处理器缓存、冲刷处理器缓存,从而保证了可见性

按照内存屏障所起的作用来说,可以将内存屏障划分为以下几种:

按照可见性保障来划分,内存屏障可分为 加载屏障(Load Barrier)和 存储屏障(Store Barrier)。加载屏障的作用是刷新处理器缓存,存储屏障的作用冲刷处理器缓存。Java 虚拟机会在释放锁对应的机器码指令之后插入一个存储屏障,这就保障了写线程在释放锁之前在临界区中对共享变量所做的更新对读线程的执行处理器来说是可同步的;相应地,Java 虚拟机会在申请锁对应的机器码指令之后临界区开始之前的地方插入一个加载屏障,使得读线程的执行处理器能够将写线程对相应共享变量所做的更新从其他处理器同步到该处理器的高速缓存中。因此,可见性的保障是通过写线程和读线程成对地使用存储屏障和加载屏障实现的。

按照有序性保障来划分,内存屏障可以分为 获取屏障(Acquire Barrier)和 释放屏障(Release Barrier)。获取屏障的使用方式是在一个读操作之后插入该屏障,作用是禁止该读操作与其后的任何读写操作之间进行重排序,这相当于在进行后续操作前先要获得相应共享数据的所有权(这也是该屏障的名称来源)。释放屏障的使用方式是在一个写操作之前插入该内存屏障,其作用是禁止该写操作与其前面的任何读写操作之间进行重排序。这相当于在对相应共享数据操作结束后释放所有权(这也是该屏障的名称来源)。

比如 Java 虚拟机会在 MonitorEnter(它包含了读操作)对应的机器码指令之后临界区开始之前的地方插入一个获取屏障,并在临界区结束后 MonitorExit(它包含了写操作)对应的机器码指令之前的地方插入一个释放屏障。因此,这两种屏障就像是三明治的两层面包片把火腿夹住一样把临界区中的代码包住:
image.png

5. Happens-Before

如果 Java 内存模型中所有的有序性都仅靠 volatile 和 synchronized 来完成,那么有很多操作都将会变得非常啰嗦,但我们在编写 Java 并发代码时并没有察觉到这一点,这是因为在 Java 语言中有一个 Happens-Before 的原则。这个原则是判断数据是否存在竞争,线程是否安全的非常有用的手段,也是对早期语言规范中含糊的可见性概念的一个精确定义。

Happens-Before 关系是 Java 内存模型中定义的两项操作之间的偏序关系,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的。比如说操作 A 先行发生于操作 B,其实就是说在发生操作 B 之前,操作 A 产生的影响能被操作 B 观察到。因此比较正式的说法是:Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则。

下面是 Java 内存模型定义的 Happens-Before 关系,这些 Happens-Before 关系在 Java 语言中无须任何同步手段保障就能成立。如果两个操作间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,表示虚拟机可以对它们随意地进行重排序优化。

  • 程序次序规则(Program Order Rule):在一个线程内,按照控制流顺序,书写在前面的操作 happens-before 书写在后面的操作。控制流顺序不是程序代码顺序,因为要考虑分支、循环等结构。

  • 管程锁定规则(Monitor Lock Rule):一个 unlock 操作 happens-before 后面对同一个锁的 lock 操作。这里必须强调的是【同一个锁】,而【后面】是指时间上的先后。

  • volatile 变量规则(Volatile Variable Rule):对一个 volatile 变量的写操作 happens-before 后面对这个变量的读操作,这里的【后面】同样是指时间上的先后。

  • 线程启动规则(Thread Start Rule):Thread 对象的 start() 方法 happens-before 此线程的每一个动作。

  • 线程终止规则(Thread Termination Rule):线程中的所有操作都 happens-before 对此线程的终止检测,我们可以通过 Thread::isAlive() 的返回值检测线程是否已经终止执行。

  • 线程中断规则(Thread Interruption Rule):对线程 interrupt() 方法的调用 happens-before 被中断线程的代码检测到中断事件的发生,可通过 Thread::interrupted() 方法检测到是否有中断发生。

  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束) happens-before 它的 finalize() 方法的开始。

  • 传递性(Transitivity):如果操作 A happens-before 操作 B,操作 B happens-before 操作 C,那就可以得出操作 A happens-before 操作 C 的结论。

5.1 案例一

下面演示如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全。

  1. private int value = 0;
  2. pubilc void setValue(int value){
  3. this.value = value;
  4. }
  5. public int getValue(){
  6. return value;
  7. }

对于上面的代码,假设存在线程 A 和 B,线程 A 先(时间上的先后)调用了 setValue(1),然后线程 B 调用了同一个对象的 getValue(),那么线程 B 收到的返回值是什么?

由于两个方法分别由线程 A 和 B 调用,不在同一个线程中,所以程序次序规则在这里不适用;由于没有同步块所以管程锁定规则也不适用;由于 value 变量没有被 volatile 关键字修饰,所以 volatile 变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起。因此尽管线程 A 在操作时间上先于线程 B,但这里的操作却不是线程安全的。

那怎么修复这个问题呢?

我们至少有两种比较简单的方案可以选择:要么把 getter、setter 方法都定义为 synchronized 方法,这样就可以套用管程锁定规则;要么把 value 定义为 volatile 变量,由于 setter 方法对 value 的修改不依赖 value 的原值,满足 volatile 关键字使用场景,这样就可以套用 volatile 变量规则来实现先行发生关系。

5.2 案例二

我想举 Brian Goetz 提供的一个经典用例,使用 volatile 作为守卫对象,实现某种程度上的轻量级的同步。该代码需采用 JDK 5 以上版本运行,请看代码片段:

  1. Map configOptions;
  2. volatile boolean initialized = false;
  3. // Thread A
  4. public void executeA() {
  5. configOptions = new HashMap();
  6. initialized = true;
  7. }
  8. // Thread B
  9. public void executeA() {
  10. while (!initialized) {
  11. sleep(100);
  12. }
  13. // use configOptions
  14. }

在上面这段代码中,由于 Happens-Before 原则,以及 JSR-133 重新定义的 JMM 模型,能够保证线程 B 获取到的 configOptions 是更新后的数值,即便 configOptions 没有用 volatile 修饰。

参考上面 Happens-Before 原则中的 volatile 变量规则和传递性规则:对一个 volatile 变量的写操作 Happens-Before 于后续对这个 volatile 变量的读操作。并且,如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。由此可以知道:
未命名文件.png
从图中可以看到:

  • “configOptions=new HashMap()” Happens-Before 写变量 “initialized=true”,这是程序次序规则
  • 写变量 “initialized=true” Happens-Before 读变量 “initialized=true”,这是 volatile 变量规则
  • 由传递性规则可得到 “configOptions=new HashMap()” Happens-Before 读变量 “initialized=true”

因此,如果线程 B 读到了 initialized=true,那么线程 A 设置的 configOptions 就对线程 B 是可见的。这就是 在 1.5 版本对 volatile 语义的增强,这个增强意义重大,能够起到守护其上下文的作用。线程 A 对 volatile 变量的赋值,会强制将该变量自己和当时其他变量的状态都刷出缓存,为线程 B 提供可见性。当然,这也是以一定的性能开销作为代价的,但毕竟带来了更加简单的多线程行为。