Java 内存模型(JMM)的 happens-before 规则与这些屏障有什么关系?
下面先解释 happens-before 的抽象概念,再说明它如何通过内存屏障落地实现。
一、happens-before 是什么?
happens-before 是 JMM 定义的一种偏序关系。如果两个操作满足 happens-before 关系,那么第一个操作的结果对第二个操作可见 ,并且第一个操作的执行顺序必须在第二个操作之前(不会被重排序)。
- hb(A, B) 表示:A happens-before B。
- 我们编程时借助
volatile、synchronized、Thread.start()等来建立 happens-before 关系,从而保证线程安全。
关于 happens-before 的精确定义,可以参考 Java 语言规范(JLS §17.4.5) 中的表述:
两个操作可以用 happens-before 关系来排序。如果一个操作 happens-before 另一个操作,那么第一个操作的结果对第二个操作可见,并且第一个操作的执行顺序在第二个操作之前。
更严格的形式化定义:
-
偏序关系 :happens-before 是程序执行中操作之间的一种偏序关系(自反、传递、反对称)。
-
满足的条件 :对于任意两个操作 A 和 B,如果满足以下任一条件,则称 A happens-before B:
- 程序次序:A 和 B 在同一个线程内,且 A 在代码顺序上出现在 B 之前。
- Volatile 规则 :A 是对
volatile变量的写,B 是对同一个volatile变量的读,且 B 在时间上后续看到该写。 - 锁规则:A 是对监视器锁的解锁,B 是对同一个锁的加锁。
- 线程启动 :A 是
Thread.start(),B 是新线程中的任意操作。 - 线程终止 :A 是线程中的任意操作,B 是其他线程上对该线程的
Thread.join()成功返回。 - 中断 :A 是
Thread.interrupt(),B 是被中断线程检测到中断。 - 传递性:若 A happens-before B 且 B happens-before C,则 A happens-before C。
- 终结规则 :A 是对象的构造器的结束,B 是该对象的
finalize()方法的开始。
-
重要特性:如果两个操作没有 happens-before 关系,JVM 允许对它们任意重排序,并且不保证可见性(即可能出现数据竞争)。
一句话定义 :
happens-before 是 Java 内存模型中用于保证内存可见性 和操作顺序的偏序关系;当 A happens-before B 时,A 对所有后续操作(包括 B)的效果都是确定的、可见的,且不会被重排序到 B 之后。
二、与 volatile 相关的 happens-before 规则
JMM 对 volatile 定义了如下规则:
- 写规则 :对
volatile变量 v 的写,happens-before 于后续(按时间顺序)对同一变量 v 的读。 - 传递性:如果 A happens-before B,B happens-before C,则 A happens-before C。
这个规则正是通过内存屏障来实现的。我们看一个典型例子:
java
// 线程 A
x = 42; // 普通写
volatile v = 1; // volatile 写
// 线程 B
if (v == 1) { // volatile 读
y = x; // 普通读
}
根据规则:x = 42 在时间上先于 v = 1(假设线程 A 先执行),而且 v = 1 和 v == 1 满足 volatile 变量规则,因此有 happens-before 链条:
x = 42 → (程序顺序) → v = 1 → (volatile 规则) → v == 1 → (程序顺序) → y = x
所以最终 x = 42 happens-before y = x,线程 B 读取 x 时会看到 42(而不是旧值或未初始化值)。
三、内存屏障如何实现 volatile happens-before
1. 实现写规则(volatile 写 happens-before volatile 读)
- volatile 写前插入 StoreStore :确保
x = 42不会被重排到v = 1之后。如果发生重排,线程 B 可能在看到v == 1时x还未更新,破坏 happens-before。 - volatile 写后插入 StoreLoad :强制将写缓冲刷入主存,并使其他 CPU 的缓存行失效。这样后续线程 B 读
v时一定从主存拿到最新值。 - volatile 读后插入 LoadLoad + LoadStore :确保
v == 1的读不会与后续普通读/写重排,保证看到 volatile 读的结果后,后续操作能看到之前所有写入。
2. 实现传递性
传递性靠编译器和 CPU 遵守这些屏障规则,禁止可能导致违反 happens-before 的重排序。
四、happens-before 全规则概览
除了 volatile,还有以下常见规则(每条规则背后也依赖屏障或锁实现):
| 规则 | 示例 | 底层实现手段 |
|---|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 happens-before 后面的操作 | 编译器不重排(依赖依赖关系) |
| volatile 规则 | 对 volatile 写 happens-before 后续对同一变量的读 | 内存屏障(StoreStore/StoreLoad/LoadLoad/LoadStore) |
| 锁规则 | 解锁 happens-before 后续的加锁 | 释放锁时 StoreLoad(类似 volatile 写),获取锁时类似 volatile 读 |
| 线程启动规则 | Thread.start() happens-before 该线程内的任何操作 |
依赖于内存同步(start() 内部有同步) |
| 线程终止规则 | 线程内的任何操作 happens-before Thread.join() 返回 |
同样依赖同步 |
| 中断规则 | 调用 interrupt() happens-before 被中断线程检测到中断 |
通过 volatile 变量实现中断标志 |
| finalizer 规则 | 构造函数结束 happens-before finalizer 开始 | 特殊的内存屏障(防止构造函数内对象逃逸) |
五、为什么 volatile 的 happens-before 规则不保证原子性?
happens-before 只定义了顺序和可见性 ,但并不限制一个操作(如读-改-写)的内部步骤不被其他线程交错。例如 count++ 可以分为三步,线程 A 读 count 和线程 B 读 count 可以同时发生(虽然两者都要先于后续写,但两者之间没有 happens-before 关系),导致丢失更新。因此原子性需要额外的锁或 CAS。
六、下一层:Java 代码到汇编的真实屏障指令
我们在 Java 里写的 volatile 最终变成了什么 CPU 指令?以 x86_64 为例:
java
volatile int v = 42;
对应的汇编(通过 JVM 的 JIT 编译后)类似:
asm
mov dword ptr [rsp+...], 42 ; 普通写
lock add dword ptr [rsp], 0 ; lock 前缀指令(既是 StoreLoad 屏障,也充当 StoreStore 屏障,因为 x86 不重排写-写)
lock addl $0x0, (%rsp) 就是 x86 上的全功能屏障,实现了 StoreStore + StoreLoad。
而 volatile 读在 x86 上通常就是普通 mov 指令,因为 x86 的读操作不会被重排,但仍会配合缓存一致性协议从主存读取最新值(因为其他 CPU 的写可能通过 RFO 使缓存行无效)。
七、总结
| 概念 | 抽象层面 | 实现层面 |
|---|---|---|
| happens-before | JMM 定义的可见性与顺序规则 | - |
| volatile 写规则 | 对 volatile 写 happens-before 后续读 | StoreStore + StoreLoad 屏障 |
| volatile 读规则 | - | LoadLoad + LoadStore 屏障 |
| 最终保证 | 跨线程的可见性和禁止特定重排序 | 具体 CPU 指令(x86: lock 前缀) + 缓存一致性协议(MESI) |