摘要:Java 内存模型(Java Memory Model,JMM)是并发编程体系中最难啃也最核心的一块:它连接了 Java 源码、JVM、CPU、缓存和主存之间的完整链路,决定了可见性、原子性和有序性。本文用一张全景架构图贯穿全文,从 CPU 多级缓存与缓存一致性协议讲起,逐步深入主内存与工作内存、8 种内存交互操作、重排序、内存屏障、happens-before 规则,再到 volatile、synchronized、final、双重检查锁定的源码级分析,最后给出大量面试高频题和真实故障案例。建议先收藏,再逐节阅读。
1. 为什么面试官总爱问 Java 内存模型
几乎每一个中高级 Java 工程师岗位,无论是一面、二面还是终面,都会绕不开这样几个问题:
volatile 到底保证了什么?它能保证原子性吗?
synchronized 和 volatile 有什么区别?
什么是重排序?为什么会有重排序?
happens-before 规则有哪些?
双重检查锁定为什么要加 volatile?
final 字段为什么可以保证可见性?
CPU 的 MESI 协议和 JMM 有什么关系?
这些问题看似零散,其实都指向同一个知识体系:Java 内存模型(JMM)。如果你只背了几条结论,而没有理解 JMM 背后的抽象与实现,面试官只要多问一层“为什么”,就很容易被打回原形。反过来,如果你真正看懂了本文开头那张图,并且能把它讲清楚,那么关于 JMM 的讨论很容易从十分钟拉长到一个小时,而且全程由你掌控节奏。
本文的目标,就是用一张全景架构图作为主线,把 JMM 涉及的所有关键知识点串联起来。我们先建立整体印象,再自上而下逐层拆解。
2. 一张图看懂整个 JMM 体系
在深入细节之前,先看这张“内存模型全景图”。它同时包含了硬件层、JVM 层和 Java 语言层三部分,是本文后续所有章节的地图。
这张图可以用一句话概括:Java 源码通过 JMM 抽象层屏蔽了底层 CPU 缓存的差异,而 JMM 的正确性最终又必须依赖内存屏障和缓存一致性协议在硬件上的落地。
面试时,如果你能从这张图的任意一层切入,再横向关联到其他层,比如从 volatile 聊到 Store Buffer,从 synchronized 聊到 MESI,从 final 聊到 StoreStore 屏障,就很容易让面试官觉得你不是在背八股文,而是真正理解了并发体系的底层脉络。
3. 先看硬件:CPU 多级缓存与缓存一致性
JMM 不是凭空发明的。它之所以存在,根本原因在于现代 CPU 的多级缓存结构会让不同核心看到不一致的数据。理解硬件层,是理解后续所有抽象规则的前提。
3.1 为什么 CPU 需要多级缓存
计算机系统里存在一个经典矛盾:CPU 的计算速度极快,而主存的访问速度相对极慢,两者之间差了若干个数量级。如果 CPU 每次读写数据都要直接访问主存,处理器大部分时间都会处于等待状态,性能会严重下降。
为了解决这个问题,现代 CPU 在核心和主存之间引入了多层缓存:
寄存器:位于 CPU 核心内部,速度最快,容量最小。
L1 Cache:每个核心独享,通常分为指令缓存和数据缓存,访问延迟约 1 个时钟周期左右。
L2 Cache:每个核心独享,容量比 L1 大,速度稍慢。
L3 Cache:多个核心共享,容量更大,速度比 L2 慢。
主存 RAM:所有核心共享,容量最大,速度最慢。
缓存的存在大幅提升了性能,但带来了一个严重问题:同一个数据可能同时存在于多个核心的私有缓存中。如果一个核心修改了缓存中的数据,而另一个核心的缓存中还保留着旧值,那么两个核心就会对同一份数据产生不同的认知。
3.2 缓存一致性问题示例
假设有一个共享变量x = 0,线程 A 运行在核心 0 上,线程 B 运行在核心 1 上,两个线程同时执行x++。理想情况下,执行两次自增后x应该等于 2。但实际可能发生这样的过程:
核心 0 从主存读取
x = 0,放入自己的 L1 缓存。核心 1 也从主存读取
x = 0,放入自己的 L1 缓存。核心 0 执行自增,自己缓存中的
x变成 1,但还没写回主存。核心 1 执行自增,自己缓存中的
x也变成 1,同样还没写回主存。两边把值写回主存后,
x最终是 1,而不是 2。
这就是典型的缓存一致性问题。在 Java 层面,x++对应的“读取、加一、写回”三个动作不是原子的,多个线程交错执行时就会丢失更新。要保证正确性,必须引入某种同步机制。
3.3 MESI 缓存一致性协议
硬件层面解决缓存一致性的一种主流方案是MESI 协议。MESI 是四种缓存行状态的缩写:
M(Modified,已修改):缓存行数据有效,但与主存不一致,且该数据只存在于当前缓存中。
E(Exclusive,独占):缓存行数据有效,与主存一致,且该数据只存在于当前缓存中。
S(Shared,共享):缓存行数据有效,与主存一致,且该数据可能同时存在于多个缓存中。
I(Invalid,无效):缓存行数据无效,不能再使用。
MESI 的核心思想是:当一个核心要修改某条缓存行时,必须先通过总线通知其他核心把对应缓存行置为无效状态,从而保证同一时刻只有一个核心能拥有可修改的副本。当一个核心读取数据时,如果其他核心存在 Modified 状态的副本,则需要先把该副本刷新到主存或直接转移数据。
MESI 保证了单条缓存行读写的一致性,但带来了两个性能问题:
当核心要写入一个处于 Shared 状态的缓存行时,必须先向总线发送 Invalid 请求,并等待其他核心确认,这段时间核心只能等待。
当一个核心收到失效请求时,如果它很忙,可能延迟处理,导致发送方继续等待。
于是硬件又引入了两个异步优化组件:Store Buffer和Invalidate Queue。
3.4 Store Buffer 和 Invalidate Queue
Store Buffer(存储缓冲):当核心要写入数据时,它不直接写缓存,而是先把写入操作放入 Store Buffer,然后立即继续执行后续指令。等收到其他核心对失效请求的确认后,再把 Store Buffer 中的数据写入缓存。这样 CPU 就不用阻塞等待失效确认了。
但 Store Buffer 的引入带来了一个经典问题:写操作对当前核心之外的观察者不可见,甚至当前核心自己也可能读到旧值。考虑下面的伪代码:
java
// 初始状态:a = 0, b = 0 // 核心 0 执行: a = 1; b = 1; // 核心 1 执行: while (b == 0) {} print(a);如果核心 0 把a = 1和b = 1都放进了 Store Buffer,并且先执行b = 1的写回,那么核心 1 可能看到b == 1,但此时a在核心 1 的缓存中仍然是 0,于是打印出 0。这就是重排序的硬件根源之一。Java 的 volatile 关键字之所以需要内存屏障,很大程度上就是为了处理 Store Buffer 带来的这种乱序可见性问题。
Invalidate Queue(失效队列):当核心收到其他核心的失效请求时,它不立即处理,而是把请求放入 Invalidate Queue,然后回复确认。这样发送方可以更快地继续执行。但这也导致一个核心的缓存可能暂时保留着已经被标记为“即将失效”的数据,从而读到过期值。
Store Buffer 和 Invalidate Queue 共同说明了一件事:仅仅依靠 MESI 协议,并不能保证程序按源码顺序看到正确的数据。我们需要更强的内存模型约束,也就是内存屏障。
4. 何为内存模型:从硬件到语言的抽象
不同 CPU 架构对重排序的支持程度不同:有些 CPU 允许较多重排序,有些则比较保守。如果 Java 程序员要针对每种 CPU 写不同的并发代码,那几乎是不可维护的。因此,Java 引入了自己的内存模型,在语言层面定义了一套统一的规则。
4.1 内存模型的定义
内存模型(Memory Model)是一组规则,它规定了在并发环境下,多个线程对共享内存的读写操作之间,哪些是允许的,哪些是不允许的,以及一个线程的写操作在什么条件下对其他线程可见。
可以简单理解为:内存模型定义了“正确的并发程序”长什么样,以及编译器和处理器在优化时可以“乱来”到什么程度。只要程序最终能满足内存模型的约束,编译器和 CPU 就可以自由地做重排序、缓存等优化。
4.2 JMM 的核心抽象:主内存与工作内存
JMM 屏蔽了不同硬件缓存的差异,抽象出两个概念:
主内存(Main Memory):所有线程共享的内存区域,存储所有的共享变量。可以近似理解为物理主存,但严格来说 JMM 的主内存是一个抽象概念,它可以是物理主存的一部分,也可以是 CPU 缓存的一部分,具体取决于 JVM 和底层平台。
工作内存(Working Memory):每个线程私有的内存区域,存储该线程使用到的共享变量的副本。工作内存是 JMM 的抽象概念,它实际上可能对应 CPU 寄存器、CPU 缓存或主存中的一部分。
JMM 规定:
线程对共享变量的所有操作都必须在工作内存中进行,不能直接读写主内存。
线程之间无法直接访问对方的工作内存,线程之间的变量传递必须通过主内存完成。
这句话非常重要。它意味着:线程 A 修改了变量后,必须先把新值写回主内存;线程 B 要读取这个新值,必须先到主内存中读取并加载到自己的工作内存。如果线程 A 没有写回,或者线程 B 没有重新读取,线程 B 就看不到线程 A 的修改。这正是 Java 中可见性问题的本质来源。
4.3 一个经典的可见性问题
下面的代码演示了没有正确同步时,变量的修改可能永远不会被其他线程看到:
java
public class VisibilityDemo { private static boolean flag = false; public static void main(String[] args) throws InterruptedException { new Thread(() -> { while (!flag) { // 忙等待 } System.out.println("线程 A 退出循环"); }, "线程A").start(); Thread.sleep(1000); new Thread(() -> { flag = true; System.out.println("线程 B 已经将 flag 置为 true"); }, "线程B").start(); } }理论上,线程 B 把flag置为true后,线程 A 应该退出循环。但在没有同步的情况下,JIT 编译器可能做如下优化:线程 A 反复读取flag,由于这个操作看起来“每次结果都一样”,编译器可能把读取操作提升到循环外,变成只读一次;或者线程 A 一直读取自己工作内存中的旧值,而线程 B 修改的新值没有及时写回或传递到线程 A 的工作内存。结果是线程 A 可能永远停不下来。
解决办法是给flag加上volatile修饰,或者使用synchronized、Lock等同步机制。具体的原理在后面的章节展开。
4.4 关于“主内存本质”的常见误区
很多资料把主内存直接等同于物理主存,把工作内存直接等同于 CPU 缓存,这种说法有助于入门理解,但不够严谨。更准确的理解是:
JMM 定义的是一套抽象规则,它并不规定主内存和工作内存具体落在哪块物理介质上。
JVM 实现可以根据平台特点,把主内存映射到主存、L3 缓存甚至某块共享缓存;把工作内存映射到寄存器、L1、L2 缓存等。
作为 Java 程序员,只需要遵守 JMM 的规则,不需要关心底层具体映射。
在面试中如果能主动补充这一层,会显得理解更深入,而不是停留在“主内存就是主存”的表面。
5. JMM 的 8 种内存交互操作
JMM 定义了主内存与工作内存之间的 8 种原子操作,用来精确描述一个变量是如何在两边流转的。理解这 8 个操作,是理解 volatile 读写语义和 happens-before 规则的基础。
5.1 8 种操作的定义
| 操作 | 作用对象 | 含义 |
|---|---|---|
| lock | 主内存变量 | 把一个变量标识为某个线程独占的状态 |
| unlock | 主内存变量 | 把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定 |
| read | 主内存变量 | 把一个变量的值从主内存传输到线程的工作内存,为后续 load 做准备 |
| load | 工作内存变量 | 把 read 操作从主内存得到的值放入工作内存的变量副本中 |
| use | 工作内存变量 | 把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到需要使用该变量的字节码指令时执行 |
| assign | 工作内存变量 | 把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到赋值字节码指令时执行 |
| store | 工作内存变量 | 把工作内存中的一个变量的值传输到主内存,为后续 write 做准备 |
| write | 主内存变量 | 把 store 操作从工作内存中得到的值放入主内存的变量中 |
这 8 种操作必须满足若干规则,例如:read 和 load 必须成对出现;store 和 write 必须成对出现;不允许一个线程丢弃它最近的 assign 操作;不允许一个线程无原因地把数据从工作内存同步回主内存;一个新的变量只能在主内存中“诞生”;一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行;如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值;对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存。
这些规则听起来繁琐,但它们是 volatile、synchronized 语义的“形式化定义”。面试时如果能主动提到“8 种交互操作”,并说出其中几条关键规则,会显得基础非常扎实。
5.2 8 种操作与 volatile 的关系
volatile 变量的读写之所以有特殊语义,正是因为 JMM 对这 8 种操作加了额外约束:
volatile 写:相当于在普通写之后插入 StoreStore 屏障和 StoreLoad 屏障,强制把工作内存中的值立刻 store + write 回主内存,并让其他核心的缓存行失效。
volatile 读:相当于在普通读之前插入 LoadLoad 屏障和 LoadStore 屏障,强制从主内存 read + load 最新的值到工作内存。
换句话说,volatile 通过约束 8 种操作的顺序和时机,实现了“写立刻对其他线程可见、读一定读到最新值”的语义。
6. 重排序:并发问题的另一大来源
除了可见性,JMM 还要处理另一个棘手问题:重排序。重排序是编译器和处理器为了优化性能而调整指令执行顺序的行为,它本身无害,但在并发场景下可能破坏程序的正确性。
6.1 三种重排序
重排序分为三类:
编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令级并行重排序:现代处理器采用指令级并行技术,如果不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。
内存系统重排序:处理器使用缓存和读写缓冲区,使得加载和存储操作看上去可能是在乱序执行。
这三类重排序都会遵守一个共同约束:不改变单线程程序的执行结果。但在多线程环境下,单线程看似正确的重排序,可能导致另一个线程看到意想不到的中间状态。
6.2 数据依赖性
编译器和处理器在重排序时,会遵守数据依赖性:
写后读(Write after Read,WAR):先写后读,不能重排序。
读后写(Read after Write,RAW):先读后写,不能重排序。
写后写(Write after Write,WAW):先写后写,不能重排序。
注意,这里说的数据依赖性只针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。这正是并发问题的根源。
6.3 as-if-serial 语义
as-if-serial 语义的意思是:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器必须遵守 as-if-serial 语义。
为了遵守 as-if-serial 语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
6.4 重排序导致的经典问题
考虑下面这段代码:
java
// 初始状态:x = 0, y = 0 // 线程 A: x = 1; // A1 flag = true; // A2 // 线程 B: while (!flag) {} // B1 int r = x; // B2如果线程 A 的 A1 和 A2 发生重排序,A2 先执行,那么线程 B 可能在 B1 处看到flag == true,但在 B2 处读到的x仍然是 0。这就是重排序导致的可见性问题。
volatile 和 synchronized 都可以禁止这种重排序:volatile 通过内存屏障禁止特定类型的重排序;synchronized 通过 monitorenter 和 monitorexit 保证临界区内的操作不会重排序到临界区外。
7. 内存屏障:JMM 在硬件上的落地手段
JMM 是抽象规则,它最终要通过内存屏障(Memory Barrier)在硬件上落地。内存屏障是 CPU 或编译器层面的一组指令,用于限制特定操作的执行顺序。
7.1 四种基本内存屏障
LoadLoad 屏障:确保 Load1 的数据装载先于 Load2 及其后所有装载指令。典型场景是 volatile 读之后。
StoreStore 屏障:确保 Store1 的数据刷新到主内存先于 Store2 及其后所有存储指令。典型场景是 volatile 写之前。
LoadStore 屏障:确保 Load1 的数据装载先于 Store2 及其后所有存储指令。
StoreLoad 屏障:确保 Store1 的数据刷新到主内存先于 Load2 及其后所有装载指令。这是四种屏障中开销最大的,因为它需要等待所有 Store Buffer 中的写操作完成。
7.2 volatile 的内存屏障插入策略
在 JMM 中,volatile 的写和读分别插入了不同数量的内存屏障:
volatile 写:在写之前插入 StoreStore 屏障,在写之后插入 StoreLoad 屏障。
volatile 读:在读之后插入 LoadLoad 屏障,在读之后插入 LoadStore 屏障。
这些屏障的组合保证了 volatile 的可见性和有序性。面试时如果能准确说出“volatile 写前后分别插什么屏障、volatile 读前后分别插什么屏障”,会是很好的加分点。
7.3 final 字段的内存屏障
final 字段的可见性也依赖内存屏障:
final 写:在构造函数返回前,插入 StoreStore 屏障,确保 final 字段的写先于对象引用的发布。
final 读:在读 final 字段之前,插入 LoadLoad 屏障,确保先读到 final 字段的正确值。
这就是为什么“正确构造的对象,final 字段对其他线程可见”的底层原因,也是双重检查锁定中 final 字段安全的基础。
8. happens-before 规则:JMM 的核心约束
happens-before 是 JMM 中最重要的规则之一,它定义了“一个操作的执行结果对另一个操作可见”的偏序关系。理解 happens-before,是理解 volatile、synchronized、final 等关键字语义的关键。
8.1 happens-before 的定义
如果 A happens-before B,那么 A 的执行结果对 B 可见,并且 A 的执行顺序排在 B 之前。注意,happens-before 并不是说 A 一定在 B 之前执行,而是说 A 的结果必须对 B 可见,且如果 A 和 B 存在依赖关系,它们的执行顺序必须满足依赖。
8.2 八条 happens-before 规则
程序顺序规则:一个线程中的每个操作,happens-before 于该线程中任意后续操作。
监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
volatile 变量规则:对一个 volatile 变量的写,happens-before 于任意后续对这个 volatile 变量的读。
传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
start() 规则:线程 A 执行 ThreadB.start(),那么线程 A 中 ThreadB.start() 之前的所有操作 happens-before 于线程 B 中的所有操作。
join() 规则:线程 A 执行 ThreadB.join() 并成功返回,那么线程 B 中的所有操作 happens-before 于线程 A 从 ThreadB.join() 返回之后的操作。
中断规则:对线程 interrupt() 方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生。
finalize() 规则:一个对象的初始化完成,happens-before 于它的 finalize() 方法的开始。
这八条规则是 JMM 的核心,面试时至少要能说出前四条。
8.3 happens-before 与 JMM 的关系
JMM 通过 happens-before 规则定义了并发程序的内存可见性。只要程序满足 happens-before 规则,JMM 就保证程序在任意处理器平台上都能得到正确结果。反之,如果程序不满足 happens-before 规则,JMM 不保证结果正确。
这就是为什么“没有同步的共享变量,其他线程可能看不到修改”的底层原因:因为两个线程之间不存在 happens-before 关系,JMM 不保证写操作对读操作可见。
9. volatile 关键字深入剖析
volatile 是 JMM 中最重要的关键字之一,它保证了可见性和有序性,但不保证原子性。
9.1 volatile 的可见性
volatile 的可见性来自两个方面:
写操作:volatile 写会把当前线程工作内存中的值立刻刷新到主内存,并让其他线程的缓存行失效。
读操作:volatile 读会从主内存中读取最新的值到工作内存,而不是直接使用工作内存中的旧值。
因此,一个线程写 volatile 变量后,另一个线程读该变量时一定能看到最新值。
9.2 volatile 的有序性
volatile 通过内存屏障禁止重排序:
volatile 写之前:插入 StoreStore 屏障,禁止上面的普通写与下面的 volatile 写重排序。
volatile 写之后:插入 StoreLoad 屏障,禁止上面的 volatile 写与下面的普通读/写重排序。
volatile 读之后:插入 LoadLoad 屏障和 LoadStore 屏障,禁止上面的 volatile 读与下面的普通读/写重排序。
这些屏障保证了 volatile 变量周围的普通读写不会越过 volatile 操作重排序。
9.3 volatile 不保证原子性
volatile 最常见的误区是“volatile 能保证原子性”。实际上,volatile 只能保证单次读或写的原子性,不能保证复合操作的原子性。例如:
java
volatile int count = 0; count++; // 这不是原子的,包含读、加一、写回三步
count++实际上是三步操作:读取 count、加一、写回 count。volatile 不能保证这三步作为一个整体原子执行,所以多线程环境下仍然可能丢失更新。要保证原子性,需要使用 synchronized、Lock 或 AtomicInteger 等原子类。
10. synchronized 关键字深入剖析
synchronized 是 Java 中最基本的同步机制,它保证了原子性、可见性和有序性。
10.1 synchronized 的语义
原子性:synchronized 修饰的代码块或方法,同一时刻只能有一个线程执行,从而保证了原子性。
可见性:synchronized 的 unlock 操作会把工作内存中的值刷新到主内存,加锁操作会清空工作内存中的共享变量,从而保证可见性。
有序性:synchronized 通过 monitorenter 和 monitorexit 保证临界区内的操作不会重排序到临界区外。
10.2 synchronized 与 volatile 的区别
| 对比维度 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证 | 不保证复合操作 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证 | 保证 |
| 阻塞 | 会阻塞 | 不会阻塞 |
| 作用范围 | 方法、代码块 | 变量 |
| 性能 | 较重(有锁升级优化) | 较轻 |
在实际使用中,volatile 适合“一写多读”的状态标志场景,synchronized 适合需要原子性的复合操作场景。
10.3 synchronized 的锁升级
现代 JVM 对 synchronized 做了大量优化,引入了锁升级机制:
偏向锁:只有一个线程访问时,对象头记录偏向线程 ID,无需 CAS。
轻量级锁:多个线程交替访问时,通过 CAS 自旋尝试获取锁,避免阻塞。
重量级锁:竞争激烈时,升级为操作系统互斥量,线程进入阻塞状态。
锁升级的过程是不可逆的,一旦升级到重量级锁就不能降级。理解锁升级,是理解 synchronized 性能特征的关键。
11. final 关键字深入剖析
final 字段的可见性是 JMM 中的一个特殊规则,它保证了“正确构造的对象,final 字段对其他线程可见”。
11.1 final 的写与读
final 写:在构造函数返回前,插入 StoreStore 屏障,确保 final 字段的写先于对象引用的发布。这防止了“构造函数中的 final 写被重排序到对象引用发布之后”。
final 读:在读 final 字段之前,插入 LoadLoad 屏障,确保先读到 final 字段的正确值。这防止了“final 读被重排序到对象引用读之前”。
11.2 final 与安全发布
如果没有 final 的可见性保证,下面的代码可能出问题:
java
public class FinalDemo { private final int x; private int y; public FinalDemo() { x = 1; y = 2; } public static void main(String[] args) { FinalDemo demo = new FinalDemo(); // 如果没有 final 的保证,另一个线程可能看到 x = 0 或 y = 0 } }final 保证了 x 的可见性,但 y 没有 final 修饰,所以 y 的可见性不被保证。这就是为什么“不可变对象要用 final 字段”的原因。
12. 双重检查锁定:JMM 的经典案例
双重检查锁定(Double-Checked Locking,DCL)是 JMM 中最经典的案例之一,它完美地展示了 volatile 的必要性。
12.1 错误的 DCL 写法
java
public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题所在 } } } return instance; } }这个写法的问题是:instance = new Singleton()不是一个原子操作,它分为三步:
分配内存空间。
初始化对象。
将 instance 指向内存空间。
如果发生重排序,步骤 3 可能在步骤 2 之前执行,那么另一个线程在第一次检查时可能看到instance != null,但此时对象还没有初始化完成,导致使用未初始化对象。
12.2 正确的 DCL 写法
java
public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }加上volatile后,volatile 的写会插入 StoreStore 屏障和 StoreLoad 屏障,禁止步骤 3 重排序到步骤 2 之前,从而保证对象初始化完成后才发布引用。
12.3 DCL 的面试回答思路
面试时如果被问到“DCL 为什么要加 volatile”,可以这样回答:
不加 volatile 时,
instance = new Singleton()的三步操作可能发生重排序,导致其他线程看到未初始化完成的对象。加 volatile 后,volatile 的写会在之前插入 StoreStore 屏障、之后插入 StoreLoad 屏障,禁止重排序,保证对象初始化完成后才发布引用。
更深一层,这是 JMM 中 volatile 有序性语义的直接体现。
13. 面试高频题与参考答案
13.1 volatile 保证了什么?能保证原子性吗?
参考答案:volatile 保证了可见性和有序性,但不保证原子性。可见性来自“写立刻刷新到主内存、读从主内存读取最新值”;有序性来自内存屏障禁止重排序;原子性方面,volatile 只保证单次读或写的原子性,不保证count++这种复合操作的原子性。
13.2 synchronized 和 volatile 的区别?
参考答案:synchronized 保证原子性、可见性和有序性,会阻塞;volatile 只保证可见性和有序性,不保证复合操作的原子性,不会阻塞。volatile 适合状态标志,synchronized 适合复合操作。
13.3 什么是重排序?为什么会有重排序?
参考答案:重排序是编译器和处理器为了优化性能而调整指令执行顺序的行为。分为编译器重排序、指令级并行重排序和内存系统重排序。重排序遵守 as-if-serial 语义,即不改变单线程程序的结果。在并发环境下,重排序可能导致可见性和有序性问题。
13.4 happens-before 规则有哪些?
参考答案:程序顺序规则、监视器锁规则、volatile 变量规则、传递性、start() 规则、join() 规则、中断规则、finalize() 规则。前四条是最常考的核心规则。
13.5 双重检查锁定为什么要加 volatile?
参考答案:因为instance = new Singleton()分为分配内存、初始化对象、引用赋值三步,可能发生重排序,导致其他线程看到未初始化完成的对象。加 volatile 后插入内存屏障禁止重排序,保证对象初始化完成后才发布引用。
13.6 final 字段为什么可以保证可见性?
参考答案:final 写会在构造函数返回前插入 StoreStore 屏障,确保 final 字段的写先于对象引用的发布;final 读会在读之前插入 LoadLoad 屏障,确保读到正确的 final 值。这保证了“正确构造的对象,final 字段对其他线程可见”。
13.7 CPU 的 MESI 协议和 JMM 有什么关系?
参考答案:MESI 是硬件层面的缓存一致性协议,保证单条缓存行读写的一致性。但 MESI 不能解决 Store Buffer 和 Invalidate Queue 带来的重排序问题。JMM 通过内存屏障约束编译器和处理器的行为,最终在硬件上落地为 MESI 协议和内存屏障的组合。可以理解为:JMM 是语言层的抽象,MESI 是硬件层的实现,内存屏障是连接两者的桥梁。
14. 真实故障案例
14.1 案例一:没有 volatile 导致线程无法退出
某系统使用一个boolean running标志控制线程退出,但忘记加 volatile。在生产环境中,偶尔出现线程无法退出的问题。原因是 JIT 编译器把while (running)优化成只读一次,或者线程一直读取工作内存中的旧值。加上 volatile 后问题消失。
14.2 案例二:DCL 单例导致 NPE
某系统使用错误的 DCL 单例,没有加 volatile。在高并发场景下,偶尔出现使用单例对象时抛 NPE。原因是重排序导致其他线程拿到未初始化完成的对象。加上 volatile 后问题解决。
14.3 案例三:volatile 不能保证原子性导致计数错误
某系统使用volatile int count做计数器,多线程环境下count++出现丢失更新。原因就是 volatile 不保证复合操作的原子性。改用 AtomicInteger 后问题解决。
15. 总结
Java 内存模型是并发编程的基石,它连接了 Java 源码、JVM、CPU、缓存和主存之间的完整链路。理解 JMM,需要从硬件层的 CPU 缓存和 MESI 协议讲起,经过 JMM 抽象层的主内存、工作内存、8 种交互操作,再到 JVM 实现层的内存屏障和 happens-before 规则,最后落到 volatile、synchronized、final、双重检查锁定等具体关键字。