什么是 happens-before 原则?它约束了什么?
结论先行:happens-before(先行发生)是 Java 内存模型给出的偏序关系:如果 A happens-before B,那么 A 的执行结果对 B 一定可见,并且 A 不会被重排序到 B 之后。它是判断"多线程下我能不能看到别人写的值"的唯一标准,不是时间上的先后。
1. 八条规则
| 规则 | 表述 | 例子 |
|---|---|---|
| 程序顺序 | 同一线程内,前面的操作 hb 后面的操作 | a = 1; hb b = 2; |
| 监视器锁 | 解锁 hb 后续对同一锁的加锁 | synchronized 块结束 → 下一个进入的线程 |
| volatile | volatile 写 hb 后续对该变量的读 | flag = true hb while (!flag) |
| 线程启动 | start() hb 新线程中的所有操作 | 启动前赋的值,新线程一定看得到 |
| 线程终止 | 线程内所有操作 hb 其他线程的 join() 返回 | join() 之后能读到子线程的结果 |
| 线程中断 | interrupt() hb 被中断线程检测到中断 | isInterrupted() 返回 true 时能看到中断前的写入 |
| 对象终结 | 构造函数完成 hb finalize() 开始 | 对象初始化对终结器可见 |
| 传递性 | A hb B 且 B hb C ⇒ A hb C | 由前七条组合推导 |
2. 传递性怎么用
主线程 worker 线程
─────── ────────────
x = 1; ┐
flag = true; ┘ volatile 写 ──hb──▶ 读到 flag == true ──▶ 读 x 必然为 1
↑
程序顺序规则:x=1 hb flag=true
volatile 规则:flag=true hb 读 flag
传递性:x=1 hb 读 x
这就是 volatile 能"顺带发布"普通变量的原因:写 volatile 时,之前的所有写都已经刷出去了。
public class HbDemo {
private static int x = 0;
private static volatile boolean flag = false;
public static void main(String[] args) {
new Thread(() -> {
while (!flag) { } // volatile 读:与写建立 hb
System.out.println(x); // 必然输出 1
}).start();
x = 1; // 普通写
flag = true; // volatile 写:把 x=1 一起"发布"
}
}
反过来,如果去掉 volatile,while (!flag) 可能永远读到 false,x 也可能读到 0——因为两条线程之间没有任何 hb 关系。
3. 三个常见误解
误解一:"happens-before 就是先执行"。 不是。它约束的是可见性与合法重排,不是真实时钟。两个操作即使物理上先发生了,只要没有 hb 关系,结果就不可信。
误解二:"同一个线程内的代码不会被重排"。 会被重排,只是不会影响单线程的执行结果(as-if-serial 语义)。指令重排、编译器优化、CPU 乱序执行都真实存在,happens-before 只保证"有 hb 关系的操作"顺序不乱。
误解三:"只有加锁才有 hb"。
start()、join()、interrupt()、final 字段、CountDownLatch.await()、Future.get()、BlockingQueue.put/take 都能建立 hb。锁只是其中最常见的一种。
4. 常见追问
追问一:final 字段有什么特殊 hb?
构造函数中对 final 字段的写,hb 于"构造完成后把引用赋给其他线程"的读。所以只要对象构造期间 this 没有逃逸,即使没有同步,其他线程看到 final 字段也一定是构造后的值——这是不可变对象能安全发布的基础。
追问二:Thread.start() 之前的写,新线程一定看得到吗?
一定。线程启动规则保证 start() 之前的所有写在子线程中可见,所以"启动前准备好参数"是安全模式。
追问三:join() 为什么能读到子线程的结果?
线程终止规则:子线程中的所有操作 hb 于其他线程从 join() 成功返回。所以 join() 之后读共享变量不需要额外同步。
追问四:happens-before 能推出"不需要 volatile"吗?
有时能。如果两条线程之间已经有锁或 join 建立 hb,那么共享变量的可见性已被保证。但注意:happens-before 保证的是可见性,不是原子性,count++ 依然要加锁。
追问五:两条线程之间没有任何 hb 关系,就一定出错吗? 不一定"必然"出错,但结果是不可预期的:可能在开发机上跑一万次都对,在生产环境换一颗 CPU、换一版 JDK 就错了。并发 bug 的可怕之处正在于此——它不报错,只是偶尔给出错误结果。
小结
happens-before 是 JMM 给出的"可见性合同":有 hb 关系,就敢读;没有 hb 关系,读到的值就是不可信的。排查诡异的并发 bug,第一步永远是画出两条线程之间的 hb 链,看它是否存在。