Happens-Before 原则
候选人小周在面试京东 P6 时,面试官问了一个延伸问题:
"Happens-Before 是什么意思?它和'先后'有什么区别?"
小周说:"就是保证前面的操作先执行,后面的后执行。"面试官追问:"那单线程中后面的代码一定能'看到'前面的结果吗?"
小周说:"那当然..."面试官说:"不一定。编译器优化可能重排序。那 Happens-Before 和重排序是什么关系?"
小周彻底答不上来了...
一、核心问题:什么是 Happens-Before 🔴
1.1 问题拆解
1.2 ❌ 错误示范
候选人原话 A:"Happens-Before 就是 A 在 B 之前执行。"
问题诊断:这是最常见的误解。Happens-Before 是程序层面的可见性保证,不是时间上的先后。JVM 可以对指令重排序,只要不影响单线程结果,但必须保证 Happen-Before 的顺序。
候选人原话 B:"只要代码写在前面,就一定在后面代码之前执行。"
问题诊断:编译器优化可以重排序,但重排序不能违反 Happens-Before。所以 A Happens-Before B 不代表 A 在 B 之前执行,只代表如果 A 先执行(按程序顺序),B 一定能看到 A 的结果。
1.3 标准回答
P5 级别:准确理解定义
Happens-Before 是 Java 内存模型(JMM)中定义的两个操作之间的偏序关系,表示前一个操作的结果对后一个操作可见。
关键点:Happens-Before 是语言层面的规则,不是硬件或编译器层面的实际执行顺序。它的核心价值在于:给程序员提供保证,而不是给 JVM 提供约束。
这句话有点绕,举个例子:
按程序顺序,A Happens-Before B。但 JVM 在单线程中可以将 A 和 B 重排序(因为它们没有依赖关系),只要重排序后程序结果不变(A 先算还是 B 先算,最终 b = 2)。但重排序不能违反 Happens-Before——如果 B 能看到 A 的结果,那么重排序后的效果必须和顺序执行一样。
在多线程场景下:
根据 volatile 变量规则:对 volatile 变量的写 Happens-Before 对该变量的读。所以 B Happens-Before C。根据传递性,A Happens-Before B Happens-Before C,因此 data 的写入对 D 一定可见。
即使 JVM 对线程 A 中的操作 A 和 B 重排序,volatile 写屏障保证了在写 flag 之前,data 的值已经刷新到主内存(或通过 lock 指令使其他 CPU 缓存失效)。
P6 级别:八大规则与实际运用
JMM 定义的八大 Happens-Before 规则:
程序顺序规则(Program Order Rule) 同一线程中,按代码书写顺序,前面的操作 Happens-Before 后面的操作。
注意:JVM 仍可能重排序 ① 和 ②,只要重排序不影响该线程的结果。
监视器锁规则(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 其他线程检测到该线程终止。
中断规则(Interruption Rule)
interrupt()调用 Happens-Before 被中断线程检测到中断(通过isInterrupted()或抛出InterruptedException)。终结规则(Finalizer Rule) 对象的构造函数结束 Happens-Before 该对象的
finalize()方法开始。传递性规则(Transitivity) 如果 A Happens-Before B,B Happens-Before C,则 A Happens-Before C。
P7 级别:编译器重排序与内存屏障
为什么需要 Happens-Before?
因为现代编译器和 CPU 会进行指令重排序,目的是提升性能。但重排序不能违反 Happens-Before——这是 JMM 规定的"安全线"。
编译器的重排序类型:
- 编译器重排序:JVM 在编译阶段重新安排指令顺序
- CPU 重排序:CPU 流水线执行、指令并行可能打乱指令顺序
- 内存重排序:Store Buffer 和 Invalidate Queue 导致"存储-加载"乱序
内存屏障的作用:
x86 架构下,volatile 写使用
lock; mov指令,隐含storestore + storeload屏障;volatile 读使用mov指令,隐含loadload + loadstore屏障。一个容易被忽视的点:Happens-Before 不保证顺序一致性(Sequential Consistency)。顺序一致性要求所有操作按某种全局顺序执行,但 JMM 只要求每个线程内部按程序顺序执行,且跨线程操作满足 Happens-Before。
【面试官心理】 这道题我通常用来筛选有没有真正理解并发底层原理的候选人。能背出规则列表的占 80%,能说清重排序和屏障关系的占 30%,能在实际代码中正确运用的占 10%。能提到"as-if-serial"语义和 Happens-Before 关系的候选人,说明对编译器优化理论有了解。
1.4 追问升级
追问 1:final 字段在 JMM 中有什么特殊保证?
JSR-133 对 final 字段有特殊规则:
- 构造函数中写入 final 字段 Happens-Before 该对象的引用被发布(在构造函数返回前,final 字段必须写入完成)
- 禁止在构造函数中逸出 this 引用(否则 final 字段的保证失效)
这个规则使得不可变对象(Immutable Object)可以在无锁的情况下被安全共享。
追问 2:synchronized 的 Happens-Before 语义和 volatile 有什么不同?
synchronized 通过 monitor lock/unlock 提供更强的保证:
- volatile 只保证可见性和有序性(写屏障+读屏障)
- synchronized 还保证原子性(互斥,同一时间只有一个线程执行临界区)
- synchronized 的 unlock 会强制刷新所有共享变量到主内存(不只是被 volatile 修饰的变量)
所以
synchronized (lock) { x = 1; }后,x的修改对其他进入同步块的线程可见,即使x不是 volatile。
二、Happens-Before 与 as-if-serial 🟡
2.1 as-if-serial 语义
as-if-serial:编译器必须保证单线程程序的执行结果与"指令按程序顺序执行"的结果一致。这是编译器优化的理论基础——可以在不违反 as-if-serial 的前提下自由重排序。
Happens-Before 与 as-if-serial 的关系:
- as-if-serial 是给编译器的约束:你怎么优化都行,只要单线程结果对
- Happens-Before 是给程序员的保证:只要你遵守这些规则,多线程结果就是对的
两者是互补的:一个约束编译器,一个保证程序正确性。
2.2 JMM 的设计哲学
JMM 的目标不是让程序员写出"和顺序执行一样的多线程程序",而是让程序员能通过 Happens-Before 规则推理出程序的正确性。
这意味着:即使程序在底层被重排序得天翻地覆,只要遵循 Happens-Before 规则,结果就是正确的。程序员不需要关心底层怎么优化,只需要记住规则。
三、生产避坑
3.1 错误的单例模式
场景:双重检查锁定(Double-Checked Locking)不使用 volatile,在某些 JIT 编译和 CPU 架构下导致对象引用不为 null 但对象未初始化完成。
根因:new Singleton() 的指令重排序——先分配内存并赋值引用(变成非 null),后执行构造函数。在高并发下,其他线程可能在构造函数执行前看到非 null 的 instance。
正确做法:加 volatile。
3.2 错误的消息通知模式
面试加分点:能说出"JMM 的 happens-before 和因果一致性(causal consistency)的关系"——JMM 满足 happens-before 一致性,但不满足因果一致性。因果一致性允许"先因后果"不成立的情况,只要求因果相关的操作有序。JDK 9 的 VarHandle 提供了更强的内存排序操作(如 setRelease、getAcquire),可以达到获取-释放语义(Acquire-Release Semantics)。
面试陷阱:被问到"线程 A 中 a=1,线程 B 中 b=2,a 和 b 之间有 Happens-Before 关系吗"时,答"有"是大错特错。跨线程的 Happens-Before 必须通过同步机制(锁/volatile/线程启动/join 等)建立。没有任何同步的两个线程间的操作完全可能重排序或不可见。