1. 先搞清楚 JMM 到底在解决什么问题
在聊 Happens-Before 之前,得先掰扯清楚 JMM(Java 内存模型)这玩意到底是干嘛的,不然你只记规则,转身就忘,面试被深挖照样懵。
JMM 是一套抽象的内存访问规范,它规定了一个 Java 程序在多线程环境下,共享变量的读写行为在什么情况下是“可见的”“有序的”。它不是 JVM 实际的内存布局(堆、栈、方法区那些),而是定义了一套底层的内存交互逻辑,用来回答一个核心问题:线程 A 改了数据,线程 B 什么时候一定能看到?
为什么需要这套规范?因为底层硬件不配合。现代 CPU 都有自己的多级缓存(L1、L2、L3),线程执行时不是直接操作主内存,而是先把数据从主内存拷贝到自己的本地缓存,算完再刷回去。于是问题来了:线程 A 在 CPU0 的缓存里改了count,线程 B 在 CPU1 的缓存里读count,如果 A 没把数据刷回主内存、或者刷回去了但 B 的缓存没失效,B 读到的还是旧值。
再加上编译器和 CPU 还会做指令重排序——只要单线程语义不被破坏,它们可以任意调整指令执行顺序来提升性能。单线程没问题,多线程就麻烦了,两个线程之间没有任何约束,执行顺序乱了套,你以为先执行的操作,另一个线程可能感受到了“后执行”的效果。
所以 JMM 的价值就是在这层混沌之上,画出清晰的安全边界。它规定:只要两个操作满足 Happens-Before 关系,JMM 就保证前者对后者可见,且前者的执行顺序在后者之前(对后者而言)。不满足这条关系的操作,JVM 不做任何承诺,你可以把它们的执行结果视作随机的。
这套东西搞明白了,你再看synchronized、volatile、final的全部设计意图,就能串起来了。
2. 八条 Happens-Before 规则逐条拆解
Happens-Before 不是某个工具或语法,它是一套偏序关系集合。JMM 规定了八条规则,前四条是程序员的“保命条款”,后四条是结构性的推论规则。逐条过。
2.1 程序次序规则(Program Order Rule)
同一个线程内,书写在前的操作 Happens-Before 书写在后的操作。
这条是底线。它保证了单线程语义不会被重排序破坏。但注意,它说的是“同一个线程内”。两个线程之间,没有任何一条程序次序规则把它们的操作关联起来。
举个例子,线程 A 里:
int b = 1; // 操作1 int a = b + 1; // 操作2操作1 在操作2 之前执行,这个由程序次序规则保证。就算编译器实际上把它们的执行顺序调换了,并发层面看到的效果也必须是操作1 先于操作2。
这也是 JMM 设计的关键点:对于单线程,不管底层怎么重排,执行结果必须和顺序执行一致(as-if-serial 语义)。这条规则是 Happens-Before 体系的基础,很多传递性判断都要依赖它。
2.2 监视器锁规则(Monitor Lock Rule)
对一个锁的解锁 Happens-Before 于随后对这个锁的加锁。
这是synchronized全部机理的根源。重点在这个“随后”——指时间上的先后顺序。如果线程 A 先执行完synchronized代码块并释放锁,线程 B 随后通过同一把锁进入同步块,那么 A 在释放锁之前的所有操作,对 B 都是可见的。
实战里最常见的用法:
public class Counter { private int count = 0; public synchronized void increment() { count++; } public synchronized int get() { return count; } }线程 A 调用increment()把count改成 100,释放锁;线程 B 再调用get()拿到锁,它不光能看到count == 100,还能看到 A 在increment()里修改的所有共享变量。
这条规则的本质是:锁不仅是互斥的工具,还是内存屏障。释放锁时,JVM 会把当前线程本地缓存里的所有修改强制刷新到主内存;获取锁时,JVM 会让当前线程本地缓存失效,必须从主内存重新拉取。所以,锁覆盖的临界区,天然有“写后读可见”的效果。
注意区分:锁规则要求的是“解锁”在“加锁”之前。两个线程如果用的是不同锁,这条规则完全不成立。还有,如果线程 B 加锁的时间在 A 解锁之前(比如 A 还没退出同步块),B 根本拿不到锁,谈不上面向可见性。
2.3 volatile 变量规则(Volatile Variable Rule)
对一个 volatile 变量的写操作 Happens-Before 于后续对这个 volatile 变量的读操作。
这也是“后续”两个字很关键。线程 A 写一个 volatile 变量,线程 B 之后读到这个变量,那么 A 在写之前的所有操作,对 B 可见。
典型场景——用 volatile 做线程间的执行信号:
public class VolatileExample { private volatile boolean flag = false; private int value = 0; public void writer() { value = 42; // 普通写 flag = true; // volatile 写 } public void reader() { if (flag) { // volatile 读 // 这里能读到 value == 42 } } }这里flag是 volatile。A 线程序先写value,再写flag;B 线程读flag发现为 true,再读value必然得到 42。因为 volatile 写 Happens-Before volatile 读,且同线程内value = 42在flag = true之前,通过传递性(第 2.8 条)可以推出value = 42对 B 可见。
JMM 对 volatile 的底层实现是:写操作会插入内存屏障(StoreStore + StoreLoad),强制把当前线程的修改刷新到主内存;读操作会插入 LoadLoad + LoadStore 屏障,强制从主内存重新加载,并使其后的读操作不重排到 volatile 读之前。
所以 volatile 不只解决可见性,还能禁止特定形式的指令重排序。但它不保证原子性。volatile int count; count++;这条语句包含了读-改-写三步,多个线程同时执行照样会丢更新,必须靠 synchronized 或 AtomicInteger 这类 CAS 工具兜底。
2.4 线程启动规则(Thread Start Rule)
线程对象的 start() 方法 Happens-Before 于被启动线程中的任意操作。
也就是说,主线程在调用start()之前做的一切写操作,对刚启动的子线程都是可见的。看代码:
public class StartExample { private int data = 0; public void startThread() { data = 100; Thread t = new Thread(() -> { // 这里读到 data 必然是 100 System.out.println(data); }); t.start(); } }这个设计意图很清晰:既然要开启一个线程去干活,那这个线程至少应该能看到启动前的“初始条件”。不然子线程一跑起来就看到半初始化的状态,整个程序逻辑就没法写了。
面试里容易被挖的一个点:new Thread()这个动作本身不做同步保证,只有start()才是同步点。如果你把data = 100写在new Thread()之前、start()之后,那子线程能不能看到,就取决于重排序的运气了。
2.5 线程终止规则(Thread Termination Rule)
线程中的所有操作 Happens-Before 于其他线程检测到这个线程已经终止(Thread.join() 返回、Thread.isAlive() 返回 false)。
这条规则保证了:你join()一个线程成功返回后,这个线程里发生的所有写操作,对当前线程可见。
Thread t = new Thread(() -> { result = compute(); // 共享变量 }); t.start(); t.join(); // 这里读 result 一定能看到 compute() 的最新值这个特性在并发编程里非常实用——用join()做“分而治之”的结果汇总,是初阶并发最不容易踩坑的模式之一。比用 volatile 标记线程执行完毕再轮询要稳得多,因为 join 返回是有内存屏障保证的。
2.6 线程中断规则(Thread Interruption Rule)
对线程 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生(通过 interrupted() 或 isInterrupted() 方法)。
也就是线程 A 调用了threadB.interrupt(),那这之前 A 的写操作,当 B 在中断检测点之后的代码里是可见的。
实战中这个用得不算多,但有一个场景很典型:用中断来通知线程停止工作,同时在停止前需要传递一些“最后的状态”。中断检测点就是同步点,检测到中断后,之前那个线程的写操作对当前线程可见。
2.7 对象终结规则(Finalizer Rule)
一个对象的初始化完成(构造函数执行结束)Happens-Before 于它的 finalize() 方法的开始。
这条规则知道即可。它保证了 finalize 能看到构造函数里完成的所有初始化操作,不至于看到一个半初始化状态的对象。现在 finalize 已经被标记为废弃了(Java 9 起进入弃用流程),基本不用在实战里考虑它。
2.8 传递性(Transitivity)
如果 A Happens-Before B,且 B Happens-Before C,那么 A Happens-Before C。
这条是一个推理规则,本身不说具体某个操作之间的约束,而是把前面几条规则串联起来形成链式推导。前面 volatile 例子里value = 42 -> flag = true -> flag 读取 -> value 读取的判断,就是靠传递性把程序次序规则和 volatile 变量规则串在一起的。
3. 实战:用 Happens-Before 分析代码到底哪里出了问题
理论背得再熟,不会用就是假的。我见过太多人张口就背“一个 volatile 的写先行发生于后续的 volatile 读”,但拿到一段出问题的并发代码,完全看不出问题在哪。下面用一个典型例子演示完整分析过程。
3.1 一个真实的可见性事故现场
先看这段代码:
public class VisibilityProblem { private boolean running = true; private int count = 0; public void worker() { while (running) { count++; // 做一些计算 } System.out.println("worker stopped, count = " + count); } public void stop() { running = false; } public static void main(String[] args) throws InterruptedException { VisibilityProblem vp = new VisibilityProblem(); Thread worker = new Thread(vp::worker); worker.start(); Thread.sleep(1000); vp.stop(); worker.join(); System.out.println("main: " + vp.count); } }这段代码在理论上可能出现一个诡异现象:stop()方法执行了,running被改成 false,但 worker 线程死循环不退,或者过了一会儿才退。
用 Happens-Before 怎么分析:
running是普通变量,stop()对running的写操作和 worker 线程对running的读操作之间,没有建立任何 Happens-Before 关系。- 没有锁规则——
stop()不在 synchronized 块里,worker 的循环体也不在同步块里。 - 没有 volatile 规则——
running不是 volatile。 - 没有线程启动/终止规则——操作发生在线程启动之后、尚未 join 返回的阶段。
结论:这是个数据竞争。JMM 对数据竞争下的读写顺序不做任何保证,worker 线程可能永远看不到running的变化。
修复很简单,把running声明为 volatile:
private volatile boolean running = true;这样一来,stop()中的写操作 Happens-Before worker 线程之后的读操作,可见性问题从根上解决。
3.2 加了 volatile 就万事大吉了吗
再给一个升级版的例子,这个在很多公司面试里出现过变体:
public class VolatileAndCount { private volatile boolean flag = false; private int count = 0; public void producer() { count = 100; flag = true; } public void consumer() { if (flag) { System.out.println(count); } } }两个线程分别执行producer()和consumer(),consumer 里最终一定能打印 100 吗?
答案是:如果 consumer 在 producer 执行完 flag 写入之后才读到 flag 为 true,那 count 一定打印 100。因为 volatile 写-读规则 + 程序次序规则的传递性链路是:count = 100(程序次序)→flag = true(volatile 写),然后flag的读(volatile 读)Happens-Beforecount的读(程序次序),最后通过传递性,count = 100Happens-Beforecount的读。
但有个陷阱:如果 consumer 在 producer 的flag = true执行之前就执行了if (flag),读到 false,什么都没打印,这是正常时序交替的问题,不是并发正确性问题。Happens-Before 不保证操作在时间上一定按某个顺序发生,它只保证一旦发生了某个可识别的顺序,那么后续操作对共享变量的可见性有确定结论。
3.3 synchronized 和 volatile 的选型判断
Happens-Before 规则在你做同步工具选型时,可以当一张对照表:
| 需求场景 | 推荐工具 | Happens-Before 依据 | 理由 |
|---|---|---|---|
| 复合操作读-改-写需要原子性 | synchronized / ReentrantLock / Atomic* | 监视器锁规则 | volatile 不保证原子性 |
| 单一布尔位/引用的发布可见性 | volatile | volatile 变量规则 | 轻量、无锁竞争 |
| 多变量状态的一致快照发布 | synchronized / 不可变对象 + volatile 引用 | 监视器锁规则 / volatile 变量规则 | 状态之间存在关联约束 |
| 等待子线程算完再汇总 | Thread.join() | 线程终止规则 | 对结果可见性天然有保障 |
很多初学者有个误区:觉得并发问题都要用锁,反而是过度同步。volatile能解决的那部分可见性问题,用锁反而是浪费。反过来,只在字段上加 volatile 却期望它能保证原子性,是另一种想当然。Happens-Before 规则框架的价值,就在于给你一把尺子去度量:某个字段、某个操作,到底需要哪种同步手段才够。
4. 面试和工程中的高频应用场景拆解
Happens-Before 不只是笔试里的概念题,面试官会把它嵌入到各种并发场景里去考察。这里挑几个高频场景做个深度拆解。
4.1 双重检查锁定(DCL)单例模式的正确写法
DCL 是最经典的 volatile 实战场景:
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 问题点 } } } return instance; } }为什么instance必须是 volatile?因为new Singleton()不是一个原子操作,它在字节码层面大致分三步:
- 分配内存空间
- 调用构造函数,初始化对象
- 把引用指向分配的内存
问题在第 2 步和第 3 步之间:编译器和 CPU 可能把第 3 步重排到第 2 步之前。此时instance指向的内存还没完成初始化,另一个线程第一次检查时看到instance != null,直接返回,然后使用这个“半初始化”的对象,直接炸。
加了 volatile 之后,写 volatile 变量会插入 StoreStore 屏障,禁止把instance = new Singleton()这一步之前的普通写(构造函数里的初始化动作)重排到 volatile 写之后。于是,当 instance 的引用对外可见时,构造函数里的初始化必然已经完成。
用 Happens-Before 的语境来解释:构造函数中的初始写操作,Happens-Before volatile 写instance = ...,之后任意线程对instance的 volatile 读,Happens-Before 它后续对单例对象的访问。整条传递链路是完整的。
4.2 安全发布不可变对象的模式
面试题里还有一类很刁钻的,先发布一个对象引用,然后初始化它。用 Happens-Before 判断能不能被其他线程安全读:
public class LazyInit { private HeavyObject heavy; public void init() { heavy = new HeavyObject(); // 普通写 } public HeavyObject getHeavy() { return heavy; // 普通读 } }如果init()在某个线程执行,另一个线程调用getHeavy(),这里没有任何 Happens-Before 关系。这个对象是不安全发布——其他线程可能看到一个引用已赋值但对象内部状态未完全初始化的半成品。
三个可选的修复路径,分别对应不同的 Happens-Before 规则:
- 给
heavy加 volatile → volatile 变量规则提供可见性保障 - 把
init()和getHeavy()都设为 synchronized → 监视器锁规则提供保障 - 将
HeavyObject设计为不可变(所有字段 final + 构造后不改)→ final 字段有特殊的初始化安全性语义(JMM 保证 final 字段在构造函数中正确赋值后,任意线程通过正确发布的引用读取时,一定能看到正确的 final 值)
实际工程里我更多用 volatile 引用 + 不可变对象的组合,因为锁会引入竞争,而不可变对象加 volatile 引用的方式,读路径完全无锁。
4.3 线程池中任务提交与执行的可见性
线程池场景里,任务提交到execute(),任务内部能看到提交前主线程写入的数据吗?要看具体用的哪个线程池。
用ThreadPoolExecutor.execute()提交任务时,任务最终会被放入一个BlockingQueue。入队操作内部用了锁(比如ReentrantLock), worker 线程从队列里 take 任务也用了同一个锁的锁竞争。根据监视器锁规则:
- 主线程的入队操作(写共享数据 + 入队)涉及锁的加锁与解锁
- worker 线程的 take 操作涉及同一把锁的加锁与解锁
- 主线程的写操作 Happens-Before 解锁,解锁 Happens-Before worker 线程的加锁,加锁 Happens-Before 任务读取
这条链路是成立的,所以通过execute()提交的任务,能看到提交前主线程的写操作。
用ScheduledThreadPoolExecutor.schedule()类似,内部也是走工作队列加锁的机制。但如果你自己写一个裸的Thread直接start(),或者往一个没有任何同步机制的自定义“队列”里放任务,那就另说了。
这里要强调一个容易忽略的点:提交任务本身的动作不算同步点,是队列内部的锁操作构成了 Happens-Before 链。如果你用了无锁队列(比如ConcurrentLinkedQueue),它的可见性保障不是靠锁规则,而是靠其内部对 volatile 变量(head/tail 节点引用)的 volatile 读写规则来支撑。这种情况下,理解底层实现是用哪条规则,比记住“线程池能保证可见性”这种结论更重要。
4.4 状态标志 + 数据快照的发布
工程里面特别常见的需求:一个线程持续更新一组数据,另一个线程需要拿到某一时刻的“快照”。最简单可靠的做法是 volatile 引用指向不可变对象:
public class SnapshotHolder { private volatile Config config; public void update(Config newConfig) { config = newConfig; // volatile 写 } public Config get() { return config; // volatile 读 } }Config设计为不可变,所有字段 final,通过构造函数一次性赋值。这样,写线程只要保证构造Config时所有的 final 字段初始化完成,再把引用赋给 volatile 变量,读线程拿到的引用一定是安全发布的对象。
这里用到的规则链条是:
- 构造函数中的 final 字段写操作 → 有 JMM 的 final 字段语义保证(对象安全发布后读操作可见)
- volatile 引用写 → 其他线程 volatile 读时,建立 Happens-Before 关系
- 最终结果:读线程拿到的是一个完全初始化且最新发布的对象
这是一种把规则用得行云流水的组合招式,比加一堆锁优雅得多,性能也好看。实战中配置热更新、路由表刷新、黑白名单替换,我都用这个模式。
5. 哪些坑我替你踩过了
Happens-Before 听起来很完美,但它有边界和陷阱。以下几点是我在实际调试和写代码过程中踩出来的经验。
5.1 Happens-Before 不保证时间上的先后关系
这是初学者理解上最大的误区。Happens-Before 是一种偏序关系的抽象,并不直接等价于“时间线上 A 一定先于 B 开始执行”。它保证的是内存可见性效果:当 B 观察到某个同步事件后,A 的操作效果对 B 可见。
举个例子,线程 A 在时间点 T1 执行volatileFlag = true,线程 B 在 T2(T2 > T1)执行boolean flag = volatileFlag。直觉上 B 应该读到 true。但如果 B 线程在 T1 之前就已经把 volatileFlag 的值缓存进本地寄存器,并且由于编译器优化,它在循环里根本没做出内存访问指令呢?
实际上 JMM 的语义会逼着 JVM 在 volatile 读指令处不缓存旧值,所以这个例子在真实 JVM 里不会出现可见性问题。但我要强调的是:一个代码场景有没有问题,不能靠时间上的直觉推断,必须回落到是否建立了 Happens-Before 关系。时间顺序是判断“后续”关系的前提,但 Happens-Before 关系本身才是 JMM 承诺的边界。
5.2 数据竞争与非数据竞争的差别
JMM 规则承诺是针对“正确同步”的程序。如果你有一个数据竞争(data race)——即两个线程同时访问同一个共享变量,至少一个线程是写操作,且没有同步机制管理——那 JMM 对你不作任何保证。它不是保证你能看到一个旧值,而是可能出现各种你完全想不到的诡异行为(比如回环现象:一个线程能连续看到同一个变量从某个值变到另一个值,再“变回”旧值这种违反单调性的情况)。
Happens-Before 规则的适用前提就是:代码里没有数据竞争,或者你能证明每一个共享变量的访问都存在 Happens-Before 关系。所以,遇到并发 bug,第一步永远是找出哪些共享变量存在数据竞争,第二步才是套规则判断这个竞争是否会导致问题。
5.3 volatile 读的代价被低估了
很多人以为 volatile 只是不加锁,所以完全没有代价。其实 volatile 写会触发 StoreLoad 屏障,这在 x86 平台上是一个 full fence,代价相当高;volatile 读在某些弱内存模型架构上也不便宜。我见过生产者把高频更新的热变量全标了 volatile,结果吞吐量掉了几个百分点。
正确做法是:能用不可变对象 + volatile 引用解决的问题,不要用多个 volatile 字段。能在一个 volatile 字段里打包多个状态的,就用一个 int 存多个标志位。锁竞争严重的场景下,优先考虑LongAdder或条纹化的分段设计,而不是盲目用 volatile 原子变量。
5.4 别用 final 字段代替一切
final 字段的安全初始化语义有一个前提:对象必须通过正确的方式发布。JMM 规定,如果对象的引用在构造函数中安全发布(即没有让 this 引用逃逸到其他线程),那么任何线程通过正确的方式读到这个引用后,访问其 final 字段时应该能看到正确初始化的值。
但如果你在构造函数中就启动了一个线程,这个线程又去访问对象的字段,那就是不安全的 this 逃逸。这种情况锁和 volatile 都救不了你,因为问题出现的时机太早了。所以我的建议是:不要在构造函数里启动线程,不要在构造函数里向外部注册监听器。等构造完全结束,再执行发布动作。
6. 用 Happens-Before 指导并发代码审查
在团队里做代码审查时,我养成了一个习惯:一旦涉及多线程共享变量,心里自动跑一遍 Happens-Before 检查清单。这套清单可以分享出来:
- 这个共享变量是哪种类型的:普通变量、volatile、final、还是由锁保护的变量?
- 写线程的操作与读线程的操作之间,有没有任何同步动作可以建立 Happens-Before 链?
- 如果有锁,两边的锁是不是同一把?解锁和加锁的时序是否满足“随后”条件?
- 如果有 volatile,读写之间的时序是否满足“后续”条件?
- 是否存在复合操作(读-改-写),仅靠可见性规则无法保证原子性?
- 对象发布路径是否涉及 this 逃逸?
- 如果通过线程池交接数据,依赖的是队列内部的哪种同步机制?它是怎么保证可见性的?
这套检查的核心价值在于,你不用死记八股文条款,只需要用“这个读写之间有没有建立关系”的思维模式去推演代码。八条规则就是推演时能用的全部“道具”。
实际上我接手过不少并发线上事故,最终定位下来,绝大多数都可以归因到:某个共享变量在两个线程之间的访问链路里,存在一段没有被任何 Happens-Before 关系覆盖的空档。找到那个空档,问题就解决了百分之八十。
7. 最后的实操建议
再补充一个非常具体的建议,特别适合正在准备秋招或者跳槽面试的朋友。光背规则没用,把规则变成自己的推理能力,最好的办法是找一个现成的并发 bug 列表,逐个用 Happens-Before 关系画一遍链路图。
我自己常用的一种练习方式:拿到一段并发代码,先不运行,纯看代码,手动找出所有共享变量;然后用笔画出每个变量从写线程到读线程的完整路径;再在路径上标注每一步有没有建立起 Happens-Before 关系;最后把路径里没有任何同步机制覆盖的环节标红——那就是 bug 的潜在爆发点。
熟练之后,看到任何并发场景,你都会有肌肉记忆:这不是背规则,而是形成了一种“并发安全审查”的思维方式。这个思维方式,才是 Happens-Before 整个知识体系最值钱的部分。很多东西你记不住是正常的,但有了这个思维,你随时能自己推导出来——这比会背八条规则重要得多。