Java-并发.09.《深入理解Java内存模型》笔记

@ref: 《深入理解Java内存模型》


@tldr:

  • 并发编程模型
    • 并发编程模型要解决的两个问题: 通信和同步
    • 两种并发编程模型的: 基于共享内存, 基于消息
  • Java内存模型的抽象
    • Java 内存模型(JMM)的抽象: 主内存和线程的”本地内存”
    • happens-before规则
      • 该规则是 JSR-133 内存模型(JDK 层面定义的)中提出的概念, happens-before 并不是指两个指令执行的先后顺序, 而是两个指令的 内存可见性. 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。
      • 该规则还保证了, 哪些 java 代码能达到 happens-before 的效果:
        • 单线程下顺序执行;
        • 正确使用 volatile, Synchronize 的情况下, 多线程也能提供 happens-before 效果;
  • 重排序
    • 为什么会产生重排序,有哪几种重排序?
    • 在 JMM 上, 重排序必须遵守 as-if-serial 语义: as if serial, 「就像是顺序执行」
      • 在单线程环境下, 保证 a-i-s, 处理器和编译器的重排序优化,不能改变存在数据依赖关系的两个操作的执行顺序
      • 在存在竞争的多线程下, 处理器和编译器不保证 a-i-s, 必须正确使用 lock,volatile 和 final 才可以.
  • 内存屏障
    • 内存屏障指令是 cpu 架构层面定义的, Java 编译器会在生成字节码中插入内存屏障指令来禁止某些重排序, 保证多核环境下代码执行的”一致性”
    • JMM 提供了四种内存屏障, 其中最重要的是 StoreLoad 屏障指令, 它能保证…
  • Java 如何实现多线程环境下的正确同步:
    • Volatile 实现了怎样的内存语义, 是如何实现的?
    • Synchronize 实现了怎样的内存语义, 是如何实现的?
    • ReentrantLock 是如何实现的? CAS 具有跟 Volatile 读写一样的内存语义, 是如何实现的?
    • concurrent包的实现 : 四种方式(CAS 和 volatile)

并发编程模型

在并发编程中,我们需要处理两个关键问题:线程之间如何通信及线程之间如何同步。

通信 是指: 通信是指线程之间以何种机制来交换信息。在共享内存的并发模型里,对于线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

同步 是指: 程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。

Java 并发模型中, 线程的同步采用的是 共享内存 的方式,Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的 Java 程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java 内存模型的抽象

Java 线程之间的通信由 Java 内存模型(本文简称为 JMM)控制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的 本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。
本地内存 是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java 内存模型的抽象示意图如下:

JMM

在 java 中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java 语言规范称之为 formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。

JMM 的 happens-before 规则

从 JDK 5 开始,java 使用新的 JSR-133 内存模型,JSR-133 提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。
这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。与程序员密切相关的 happens-before 规则如下:

  1. 顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁(Monitor)规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. 线程启动法则:在一个线程里,对 Thread.start 的调用会 happens-before 于每个启动线程的动作。
  6. 线程终结法则:线程中的任何动作都 happens-before 于其他线程检测到这个线程已经终结、或者从 Thread.join 调用中成功返回,或 Thread.isAlive 返回 false。
  7. 中断法则:一个线程调用另一个线程的 interrupt happens-before 于被中断的线程发现中断。
  8. 终结法则:一个对象的构造函数的结束 happens-before 于这个对象 finalizer 的开始。

如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原则制定的顺序来执行。如果重排序之后的执行结果与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法。

JMM 顺序一致性

什么是“顺序一致性”内存模型:

顺序一致性模型(sequential consistency)是一个被计算机科学家理想化了的理论参考模型,顺序一致性内存模型有两大特性:

  1. 一个线程中的所有操作必须按照程序的顺序来执行。 (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。
  2. 在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。

JMM 的顺序一致性保证:

JMM 提供的顺序一致性内存模型是一种”面向程序员的内存模型”(Programmer-centric model),JMM 对正确同步的多线程程序的内存一致性做了如下保证:

如果程序是正确同步的(正确使用了 lock,volatile 和 final),程序的执行将具有顺序一致性(sequentially consistent)– 即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(这对于程序员来说是一个极强的保证)。

这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile 和 final)的正确使用。

synchronized 提供的顺序一致性效果:

在 JMM 中,临界区内的代码可以重排序(但 JMM 不允许临界区内的代码“逸出”到临界区之外)。JMM 会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。

虽然线程 A 在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程 B 根本无法“观察”到线程 A 在临界区内的重排序。

重排序

什么是重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。
重排序分三种类型:

  1. 编译器优化 的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行 的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统 的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

  1. 上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。
  2. 写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!

遵守 as-if-serial 语义

as-if-serial: 翻译就是「就像是顺序执行」.

编译器和处理器对重排序准守 as-if-serial 语义,as-if-serial 的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器/runtime/处理器都必须遵守 as-if-serial 语义。

为了遵守 as-if-serial 语义,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不能改变存在数据依赖关系的两个操作的执行顺序。比如 a=b; b=1; 以及 a=1; b=a;,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

编译器和处理器仅指在单线程环境下遵守 as-if-serial,在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。必须通过正确的同步实现.

注意:if 等控制语句没有 数据依赖性,比如代码:

if(flag)
int i = r * r;

其中 ifint i= r * r 是控制依赖关系,但没有数据依赖性。
当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响,提高执行效率。
下面的情形是有可能发生的:处理器可以提前读取并计算 r * r,然后把计算结果临时保存到一个名为 重排序缓冲(reorder buffer ROB) 的硬件缓存中。当接下来 if(flag) 的条件判断为真时,就把该计算结果写入变量 i 中。

通过内存屏障禁止重排序

编译器和处理器必须同时遵守重排规则。由于单核处理器能确保与“顺序执行”相同的一致性,所以在单核处理器上并不需要专门做什么处理,就可以保证正确的执行顺序。但在多核处理器上通常需要使用内存屏障指令来确保这种一致性。在不同的 CPU 架构上内存屏障的实现非常不一样。相对来说 Intel CPU 的强内存模型比 DEC Alpha 的弱复杂内存模型(缓存不仅分层了,还分区了)更简单。

内存屏障提供了两个功能。首先,它们通过确保从另一个 CPU 来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性;其次它们可以实现内存数据可见性,确保内存数据会同步到 CPU 缓存子系统。

Java 编译器在生成指令序列的适当位置会插入 内存屏障(Barriers) 指令来禁止特定类型的处理器重排序。以实现屏障前后指令的可见性。

JMM 把内存屏障指令分为下列四类:

屏障类型 example 实现效果
LoadLoad Load 1; LoadLoad; Load 2; 确保 Load 1 数据的装载,之前于 Load 2 及所有后续装载指令的装载。(禁止 Load 1,Load 2 重排序)
StoreStore Store 1; StoreStore; Store 2; 确保 Store 1 数据对其他处理器可见(刷新到内存),之前于 Store 2 及所有后续存储指令的存储。(禁止 Store 1,Store 2 重排序)
LoadStore Load 1; LoadStore; Store 2; 确保 Load 1 数据装载,之前于 Store 2 及所有后续的存储指令刷新到内存。(禁止 Load 1,Store 2 重排序)
StoreLoad Store 1; StoreLoad; Load 2; 确保 Store 1 数据对其他处理器变得可见(刷新到内存),之前于 Load 2 及所有后续装载指令的装载。StoreLoad Barriers 会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。(禁止 Store 1,Load 2 重排序)

StoreLoad 是一个“全能型”的屏障,它可以保证“先刷新到主内存再访问”。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

Volatile

volatile 变量的特性

  • 可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。可以认为对 volatile 的写是原子的;
  • 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种”依赖当前值”的复合操作不具有原子性,所以仅仅使用 volatile 变量当做同步手段(比如当做锁的计数器) 是不可以的。线程安全的计数器请使用 AtomicInteger

扩展阅读: long 和 double 读写的 #原子性 :
JMM 不保证对 64 位的 long 型和 double 型变量的读/写操作具有原子性,

  • 在一些 32 位的处理器上,如果要求对 64 位数据的读/写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java 语言规范鼓励但不强求 JVM 对 64 位的 long 型变量和 double 型变量的读/写具有原子性。当 JVM 在这种处理器上运行时,会把一个 64 位 long/ double 型变量的读/写操作拆分为两个 32 位的读/写操作来执行。这两个 32 位的读/写操作可能会被分配到不同的总线事务中执行,此时对这个 64 位变量的读/写将不具有原子性。
  • JMM 可以保证 64位环境,volatile long 的原子性:

volatile 读写建立的 happens before 关系

从 JSR-133 开始,volatile 变量的写-读可以实现线程之间的通信。看代码:

class VolatileExample {
int a = 0;
volatile boolean flag = false;

// 线程A执行writer():
public void writer() {
a = 1; //1
flag = true; //2
}

// 线程B执行read():
public void reader() {
if (flag) { //3
int i = a; //4
}
}
}
  • 根据 happens-before①,1 happens-before 2,3 happens-before 4;
  • 根据 volatile 语义,2 happens-before 3;
  • 根据 happens-before④,1 happens-before 4;

上面写 1 happens-before 2,指的是 1 对于 2 可见,但不一定是执行顺序;

volatile 读写的内存语义

volatile 读写的内存语义如下:

  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。

“内存语义”(没找到对应的英文原语)的概念: 可以理解为多核环境下, “同步”(在 Java 里指 Volatile,Synchronize 等)实现的原则, 或者是”能达到的效果”.

volatile 内存语义的实现

下面是 JMM 针对编译器制定的 volatile 重排序规则表:

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写 操作的 前面 插入一个 StoreStore 屏障。
  • 在每个 volatile 写 操作的 后面 插入一个 StoreLoad 屏障。

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

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

① volatile 写插入的内存屏障:

普通读/写操作
StoreStore屏障 //禁止上面的普通写和下面的 Volatile写 重排序
volatile写 // 因为v的写一般作为change标志位,所以v写之前的写,不能排到v写之后
StoreLoad屏障 //禁止上面的Volatile写和下面有可能的 Volatile读写 重排序

jmm-volatile-read-barriers

  • StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

  • 这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。为了保证能正确实现 volatile 的内存语义,在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 volatile 写 - 读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。

② volatile 读插入的内存屏障:

volatile读   // v读一般作为检测标志,也就要求后面的普通读,不能先于v读之前
LoadLoad屏障 // 禁止下面的普通读和上面的 Volatile读 重排序
LoadStore屏障 // 禁止下面的普通写和上面的 Volatile读 重排序
普通读/写

jmm-volatile-write-barriers

Synchronized

有关 Synchronized 的实现, 请参考👉 《Java Tutorials》

Synchronized 的释放-获取建立的 happens before 关系

线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。

Synchronized 释放-获取的内存语义

  • 当线程释放锁时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存中。
  • 当线程获取锁时,JMM 会把该线程对应的本地内存置为无效。从而使得被 monitor 保护的临界区代码必须要从主内存中去读取共享变量。
  • 对比锁释放-获取的内存语义与 volatile 写-读的内存语义,可以看出:锁释放与 volatile 写有相同的内存语义;锁获取与 volatile 读有相同的内存语义。

Synchronized 内存语义的实现

Synchronized 提供的 Monitor 机制可以保证:临界区内的代码可以重排序,但不允许临界区内的代码“逸出”到临界区之外。
JMM 会在退出 Monitor 和进入 Monitor 这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图。虽然线程在临界区内可以做重排序,但其他线程根本无法“观察”到该线程在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

ReentrantLock

ReentrantLock 实现的 happens-before 关系和内存语义与 Synchronized 的一样。
ReentrantLock 实现的基础是 Volatile 变量和 CAS, 上面提到了 Volatile 变量的读/写可以实现”禁止重排序”的效果, CAS 操作同时具有 Volatile 读和写的禁止重排序效果.

「CAS的原理和实现的内存语义」一节介绍了 CAS 是如何同时具有 Volatile 变量的读和写的内存语义的.

ReentrantLock 内存语义的实现解析

回顾 ReentrantLock 的实现,lock() 调用栈如下:

  1. ReentrantLock : lock()
  2. FairSync : lock()
  3. AbstractQueuedSynchronizer : acquire(int arg)
  4. ReentrantLock : tryAcquire(int acquires)

在第 4 步真正开始加锁,tryAcquire方法首先读 volatile 变量 state,
如果state==0, 说明还未加锁, 再尝试CAS(state, 0, 1), 如果 CAS 成功则成功获取到锁;
如果state!=0, 说明已经加锁, 再判断 ExclusiveOwnerThread 是否等于当前线程, 如果等于, 重入该锁(立刻获取到锁)

解锁方法unlock()的方法调用栈如下(公平锁为例):

  1. ReentrantLock : unlock()
  2. AbstractQueuedSynchronizer : release(int arg)
  3. Sync : tryRelease(int releases)

在第 3 步真正开始释放锁,tryRelease方法首先读 volatile 变量 state,
读取到的值-1, 然后把这个减 1 后的值写入 state(这里并没用 CAS 更新), 如果这个减 1 后的值=0, 则把锁状态置为 free

由上可知, 公平锁在释放锁的时候写 Volatile 变量, 在获取锁的时候读取 Volatile 变量, 根据 volatile 的 happens-before 规则:
释放锁的线程在写 volatile 变量之前可见的共享变量,在获取锁的线程读取同一个 volatile 变量后将立即变的对获取锁的线程可见。

CAS 的原理和实现的内存语义

CAS 同时具有 volatile 读和 volatile 写的内存语义。下面我们来分析在常见的 intel x 86 处理器中,CAS 是如何同时具有 volatile 读和 volatile 写的内存语义的。

sun.misc.Unsafe 类的compareAndSwapInt()方法是个 Native 方法, 最终调用到了 JVM 的 C++代码Atomic::cmpxchg()(compare and change),

C++的Atomic::cmpxchg()最终调用的是”compare and change”的汇编代码cmpxchg ,

Atomic::cmpxchg()函数会根据当前处理器的类型来决定是否为 cmpxchg 指令添加 lock 前缀。
如果程序是在多处理器上运行,就为 cmpxchg 指令加上 lock 前缀(汇编代码是这个样子 lock cmpxchg dword ptr[edx], ecx)。intel 的手册对lock前缀的说明如下:

  1. 确保对内存的读-改-写操作原子执行。
  2. 禁止该指令与之前和之后的读和写指令重排序。
  3. 把写缓冲区中的所有数据刷新到内存中。

上面的第 2 点和第 3 点所具有的内存屏障效果,足以同时实现 volatile 读和 volatile 写的内存语义。所以,现在我们终于能明白为什么 JDK 文档说 CAS 同时具有 volatile 读和 volatile 写的内存语义 了。

cmpxchg 和 lock 前缀的解析,详细见 -> Java-并发.05a.JUC-Atomic&CAS

Concurrent 包的实现总结: Volatile 和 CAS

由于 java 的 CAS 同时具有 volatile 读和 volatile 写的内存语义,因此 Java 线程之间的通信现在有了下面四种方式:

  1. A 线程写 volatile 变量,随后 B 线程读这个 volatile 变量。
  2. A 线程写 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  3. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程用 CAS 更新这个 volatile 变量。
  4. A 线程用 CAS 更新一个 volatile 变量,随后 B 线程读这个 volatile 变量。

Java 的 CAS 会使用现代处理器上提供的高效机器级别原子指令,这些原子指令以原子方式对内存执行读-改-写操作,这是在多处理器中实现同步的关键。
同时,volatile 变量的读/写和 CAS 可以实现线程之间的通信。把这些特性整合在一起,就形成了整个 concurrent 包得以实现的基石。
如果我们仔细分析 concurrent 包的源代码实现,会发现一个通用化的实现模式:

  1. 首先,声明共享变量为 volatile;
  2. 然后,使用 CAS 的原子条件更新来实现线程之间的同步;
  3. 同时,配合以volatile 读/写的内存语义CAS 的内存语义,来实现线程之间的通信。

下图是 Java concurrent 包的实现层次结构, 以 Volatile 和 CAS 为基础, JDK 实现了 AQS / Atomic 类 / 非阻塞队列等等基本类, 然后
通过这些基本类实现了重入锁, 阻塞队列, 线程池等..

final

对于 final 域,编译器和处理器要遵守两个重排序规则:

  • 在构造函数内对一个 final 域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。

读写 FINAL 域的重排序规则

① 写: 写 final 域的重排序规则禁止把 final 域的写重排序到构造函数之外。这个规则的实现包含下面 2 个方面:

  • JMM 禁止编译器把 final 域的写重排序到构造函数之外。
  • 编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

② 读: 在一个线程中,初次读对象引用与初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作(注意,这个规则仅仅针对处理器)。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。

FINAL 域是引用类型

对于引用类型,写 final 域的重排序规则对编译器和处理器增加了如下约束:
在构造函数内对一个 final 引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

回到错误的 DCL 单例代码

常见的双重锁检查(Double Checked Locking)的单例代码如下:

public class DoubleCheckedLocking {                 //1
private static Instance instance; //2

public static Instance getInstance() { //3
if (instance == null) { //4:第一次检查
synchronized (DoubleCheckedLocking.class) { //5:加锁
if (instance == null) //6:第二次检查
instance = new Instance(); //7:问题的根源出在这里
} //8
} //9
return instance; //10
} //11
}

但是这样写是有问题的,在多线程并发的情况下,当有某个线程在步骤 4 进行检查的时候发现 instance 非 null,但 instance 却指向一块已经分配但是未初始化的内存。

为什么出现这种情况:
示例代码的第 7 行instance = new Instance()创建一个对象。这一行代码可以分解为如下的三行伪代码:

synchronized (DoubleCheckedLocking.class) {
if (instance == null) {
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:构造函数,初始化对象
instance = memory; //3:设置 instance 指向刚分配的内存地址
}
}

由 JSR-133 的 happens-before 和as-if-serial语义,在单线程里 1 happens-before 3,但 2 不能保证 happens-before 3,

如果发生了 1-3-2 的重排序,调用构造方法初始化对象被重排序到了最后一步,
当线程 A 执行完 3,但还没执行 2 的时候(instance 指向分配好的内存, 但这块内存还未由构造函数初始化),
此时线程 B 开始运行, 到第一次判断 instance==null,线程 B 可能看到 instance 的值,所以不用走下面的 sync, 线程 B 直接获得了这个 instance(一个还未初始化的) 引用。然后就出问题了

所以, 解决方法有两种思路:

  1. 不允许 2 和 3 重排序;
  2. 允许 2 和 3 重排序,但不允许其他线程“看到”这个重排序,也即 1-3-2 都执行完之后其他线程才可以”看到”改变(可见性)。

第一种解决方案是, 将 instance 变量声明成 volatile。
当声明对象的引用为 volatile 后,“问题的根源”的三行伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。

第二种方案, 基于类初始化锁, 代码示例:

public class InstanceFactory {
// 单例放在内部类
private static class InstanceHolder {
public static Instance instance = new Instance();
}

public static Instance getInstance() {
return InstanceHolder.instance ; // 这里将导致 InstanceHolder 类被初始化
}
}

回顾一下 Java 对初始化的规范:

T 是一个类, 首次对 T 的 static 成员属性进行读写的时候, 会触发 T 的初始化
T 是一个外部类, T 被初始化的时候, 其静态内部类 Inner 不会被初始化,

作为内部类, InstanceHolder 不会在外部类初始化时被初始化(可以实现延后初始化),
首次调用 InstanceFactory.getInstance()的时候, 相当于调用了 getstatic指令读取 InstanceHolder 的静态属性, 会导致 InstanceHolder 被初始化,
初始化包括执行 static 代码块, 初始化 static 成员属性, 这些操作代码都被放在一个叫 < clinit >的方法中, 被 JVM 加锁执行.
这个方案的实质是:允许“问题的根源”的三行伪代码中的 2 和 3 重排序,但不允许其他线程(这里指线程 B)“看到”这个重排序。

Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了

Reference