happens-before 规则与内存屏障
问题
写过多线程代码的人应该都遇到过这样的困惑:明明线程 A 先改了变量,线程 B 后读,为什么读到的还是旧值?或者更诡异——线程 B 读到的变量更新了,但另一个变量没更新,a=1; b=1 两个赋值,B 看到 b==1 但 a==0?
这就是 JMM(Java Memory Model)要解决的核心问题:线程间的操作何时可见、顺序如何保证。而 happens-before 规则就是 JMM 给程序员的一张「承诺清单」——告诉你哪些场景下你不需要额外加锁,哪些场景下编译器/CPU 不会乱搞。
happens-before 是什么
happens-before 是 JMM 定义的一组偏序关系规则。如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,且 A 在 B 之前执行。注意这里说的是"执行结果可见",不一定是"时间上先执行完"——JMM 允许重排序,前提是结果符合 happens-before 规则就行。
JMM 定义了 8 条核心规则:
1. 程序顺序规则:同一个线程内,前面的操作 happens-before 后面的操作
2. volatile 规则:对 volatile 变量的写 happens-before 对该变量的读
3. 锁规则:解锁 happens-before 后续的加锁(同一把锁)
4. 传递性:A happens-before B,B happens-before C → A happens-before C
5. 线程启动规则:Thread.start() happens-before 该线程的任何操作
6. 线程终止规则:线程的所有操作 happens-before 其他线程对该线程的 join() 返回
7. 中断规则:interrupt() 调用 happens-before 被中断线程检测到中断
8. 终结器规则:对象的构造函数结束 happens-before finalize() 开始这些规则组合起来,基本覆盖了日常开发中所有需要跨线程通信的场景。
代码示例:用 happens-before 规则推断可见性
看一个实际例子,理解规则 2(volatile 规则)和规则 4(传递性)是如何组合生效的:
public class HappensBeforeExample {
private int a = 0;
private volatile boolean flag = false;
// 线程 A 执行
public void writer() {
a = 1; // 操作 1
flag = true; // 操作 2(volatile 写)
}
// 线程 B 执行
public void reader() {
if (flag) { // 操作 3(volatile 读)
int result = a; // 操作 4
// result 一定是 1,不是 0
}
}
}为什么 result 一定是 1?用 happens-before 规则推导:
- 程序顺序规则:操作 1 happens-before 操作 2(同一个线程)
- volatile 规则:操作 2(volatile 写)happens-before 操作 3(volatile 读)
- 传递性:操作 1 happens-before 操作 2,操作 2 happens-before 操作 3 → 操作 1 happens-before 操作 3
- 程序顺序规则:操作 3 happens-before 操作 4
所以操作 1 的结果对操作 4 可见——a=1 的赋值一定先于 a 的读取。如果不加 volatile,flag 的可见性没有保证,a=1 的赋值甚至可能被重排到 flag=true 之后,导致 B 看到 flag==true 但 a==0。
happens-before 与内存屏障的关系
happens-before 是规范层面的承诺,内存屏障是底层实现。JVM 在生成字节码时,会根据 happens-before 规则在合适的位置插入内存屏障(Memory Barrier / Memory Fence),来约束 CPU 的重排序。常见的屏障类型:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 屏障前的读操作完成后,屏障后的读操作才能开始 |
| StoreStore | 屏障前的写操作刷入主存后,屏障后的写操作才能开始 |
| LoadStore | 屏障前的读操作完成后,屏障后的写操作才能开始 |
| StoreLoad | 屏障前的写操作刷入主存后,屏障后的读操作才能开始(最重,开销约 10-20 个 CPU 周期) |
JVM 对 volatile 的屏障插入策略(JSR-133 规范):
- volatile 写:前插 StoreStore,后插 StoreLoad
- volatile 读:后插 LoadLoad + LoadStore
但不是所有平台都插一样的屏障。x86 架构本身有较强的一致性保证(TSO 内存模型),volatile 读在 x86 上不需要任何屏障(x86 的 Load-Load 和 Load-Store 不会重排),只有 volatile 写需要 StoreLoad 屏障。而 ARM 和 PowerPC 等弱内存模型架构下,volatile 读也需要 LoadLoad + LoadStore 屏障。
容易被忽略的点:As-If-Serial 语义
这里有个很多人会搞混的事情:单线程内,不管怎么重排,最终结果都必须和顺序执行一致。这是编译器/CPU 重排序的底线,叫 As-If-Serial 语义。
所以以下代码在单线程下是安全的,即使发生了重排:
int x = 1; // 操作 A
int y = 2; // 操作 B
int z = x + y; // 操作 CA 和 B 可以重排(结果一样),但 C 必须在 A 和 B 之后执行(因为依赖 x 和 y)。这个逻辑是编译器自动推导的,happens-before 规则在单线程内自然满足(程序顺序规则)。
总结
happens-before 规则是理解 JMM 的钥匙,也是面试中被问到「可见性」和「有序性」时绕不开的基础。记住这 8 条规则,写多线程代码时脑子里能过一遍,很多 bug 是可以提前发现的。
内存屏障是 JVM 把规则落地的手段,知道 x86 和 ARM 上的差异就行,不用死记每个平台的插入点——实际工作中你不会直接写内存屏障,但理解它对排查「为什么我本地跑得好好的,线上就出问题」这类 bug 非常有用。
最后提一句:volatile boolean 配 synchronized 是并发编程的经典组合,但很多团队现在直接用 AtomicBoolean 或者 ReentrantLock 了。工具选型不重要,理解背后的 happens-before 规则才是关键。