一、为什么 JMM 之后还需要 happens-before
上一篇讲到 Java 内存模型(JMM)定义了可见性、有序性、原子性这三个问题,也讲了 volatile、synchronized 是解决这些问题的手段。但这里留了一个更精确的问题没有回答:到底满足什么条件,才能保证一个线程的写操作,对另一个线程的读操作是可见的? 不可能笼统地说"只要用了 volatile 或者 synchronized 就一定安全",因为具体哪两个操作之间存在这种保证,是有明确边界的。
happens-before 就是 JMM 给出的这套精确边界。它是一种两个操作之间的偏序关系:如果操作 A happens-before 操作 B,那么 A 的执行结果对 B 可见,并且 A 在逻辑上排在 B 之前。JMM 定义了一组具体规则,只要两个操作满足其中任意一条规则(或者通过多条规则的传递组合),就可以断定它们之间存在 happens-before 关系,从而保证可见性;如果两个操作之间无法通过任何规则推导出 happens-before 关系,那么它们的执行顺序和可见性就是不确定的,JVM 有权按任意合法方式调度。

二、happens-before 不等于"真的先执行完"
在深入规则之前,必须先纠正一个常见误解:happens-before 描述的是一种可见性和逻辑顺序上的保证,不代表操作 A 在物理时间上真的先于操作 B 执行完毕。事实上,只要不破坏这种可见性语义,编译器和处理器仍然可以对没有依赖关系的指令做重排序优化——这正是上一篇提到的"单线程内重排序不影响最终结果"(as-if-serial 语义)。happens-before 关系保证的是"最终呈现给程序员的语义结果",而不是底层硬件真实的执行时间线。理解这一点,才能明白为什么 JMM 既允许激进的编译器优化,又能对并发程序的正确性给出严格保证。
三、六条具体规则
JMM 定义了若干条规则,覆盖了绝大部分工程实践中会遇到的场景,最核心的有以下几条:
- 程序次序规则(Program Order Rule):在同一个线程内,按照代码书写顺序,前面的操作 happens-before 后面的操作。这是最基础的一条规则,也是单线程程序天然正确的保证来源。
- 锁规则(Monitor Lock Rule):对同一把锁,一个线程的
unlock操作 happens-before 后续另一个线程对这把锁的lock操作。也就是说,只要两个线程通过同一把锁做了互斥访问,前一个线程在锁保护区间内的所有修改,对后一个线程在获取到锁之后都是可见的。 - volatile 变量规则(Volatile Variable Rule):对一个
volatile变量的写操作 happens-before 后续对这个变量的读操作。这也是volatile能保证可见性的底层依据——它本质上就是通过建立这样一条 happens-before 关系来实现的。 - 线程启动规则(Thread Start Rule):一个线程对象的
Thread.start()方法调用 happens-before 这个新启动线程内部执行的每一个操作。换句话说,主线程在调用start()之前对共享变量做的所有修改,子线程一启动就一定能看到。 - 线程终止规则(Thread Termination Rule):一个线程内部的所有操作,都 happens-before 其他线程检测到这个线程已经终止的动作(比如通过
Thread.join()成功返回,或者Thread.isAlive()返回false)。也就是说,只要用join()等待某个线程结束,等待结束之后,就能安全地看到那个线程执行期间对共享变量的所有修改。 - 中断规则(Interruption Rule):一个线程调用另一个线程的
interrupt()方法,happens-before 被中断的线程检测到中断事件的发生(比如捕获到InterruptedException,或者调用isInterrupted()返回true)。

四、程序次序规则与锁规则:最日常的两条
程序次序规则解释了为什么一个方法内部按顺序写下来的代码,效果是可预期的——哪怕底层做了重排序优化,单线程视角下观察到的结果永远和代码书写顺序一致。
锁规则则解释了 synchronized 为什么能保证可见性:线程 A 在同步块里修改了共享变量,退出同步块时执行 unlock;线程 B 进入同一个同步块之前必须先执行 lock。根据锁规则,A 的 unlock happens-before B 的 lock,再结合程序次序规则(A 在同步块内的修改先于 A 的 unlock,B 的 lock 先于 B 在同步块内的读取),就可以推出:A 在同步块内的修改,对 B 在同步块内的读取是可见的。这也是为什么"用同一把锁保护共享变量的读写"能保证线程安全的根本原理,而不是靠某种玄学。

五、线程启动与终止规则:主线程和子线程之间的可见性保证
线程启动规则和终止规则解决的是主线程和子线程之间最常见的两个可见性场景:
- 启动时的可见性:主线程在调用
thread.start()之前对共享数据做的任何修改,子线程内部执行的代码一定能看到,不需要额外加锁或者用volatile。这是因为start()这个方法本身自带 happens-before 语义。 - 结束后的可见性:主线程调用
thread.join()等待子线程执行完毕后返回,此时子线程在执行期间对共享数据做的所有修改,主线程之后都能看到。这也是为什么很多"启动多个线程处理任务,然后汇总结果"的代码模式里,只要用join()等待所有子线程结束,之后读取汇总变量是安全的,不需要额外加锁。

六、传递性:把零散规则拼接成完整推理链的关键
前面几条规则只描述了"两个具体操作"之间的关系,真正让 happens-before 变得强大的是传递性:如果 A happens-before B,且 B happens-before C,那么可以直接推出 A happens-before C,不需要 A 和 C 之间存在某条直接规则。
一个典型的组合场景:线程 1 先写入一个普通变量 data = 42,然后写入一个 volatile 变量 ready = true;线程 2 先读取 ready(读到 true),再读取 data。这里的推理链是:① 线程 1 内部,data = 42 happens-before ready = true(程序次序规则);② 线程 1 对 ready 的写 happens-before 线程 2 对 ready 的读(volatile 变量规则);③ 线程 2 内部,ready 的读 happens-before data 的读(程序次序规则)。把①②③三段通过传递性连起来,就得到:线程 1 的 data = 42 happens-before 线程 2 的 data 读取——即使 data 本身不是 volatile 的,线程 2 依然能安全读到 42,而不是初始值。这正是很多"用一个 volatile 布尔标志位保护一批普通变量"的安全发布模式(Safe Publication)背后的理论依据。

七、面试追问链

延伸阅读
- 《Java 并发编程的艺术》第 3 章,happens-before 规则详解
- JSR-133 官方规范中关于 happens-before 的完整定义
- 下一篇预告:volatile 原理——可见性、有序性、内存屏障及为什么不保证复合操作原子性
问答(不计入阅读时长)
Q1:happens-before 和 JMM 里说的可见性、有序性是什么关系?
A:JMM 提出了可见性、有序性、原子性这几个需要保证的问题,但没有直接说清楚"具体什么时候才算保证"。happens-before 就是把这个模糊的承诺变成一套精确规则——只要两个操作之间能通过某条规则(或规则的传递组合)推出 happens-before 关系,就能确定前一个操作的结果对后一个操作可见,且逻辑顺序上前者在先。可以理解为 happens-before 是可见性和有序性保证的"判定标准"。
Q2:happens-before 是不是说操作 A 真的会在时间上先于操作 B 执行完?
A:不是。happens-before 保证的是语义结果层面的可见性和逻辑顺序,不是物理执行时间线上的先后。只要不破坏这种语义保证,编译器和处理器仍然可以对没有依赖关系的指令做重排序优化。这也是为什么 JMM 既能允许激进的性能优化,又能对并发正确性给出严格承诺。
Q3:为什么两个线程通过同一把锁保护共享变量就能保证可见性?
A:因为锁规则规定,一个线程释放锁(unlock)happens-before 另一个线程后续获取同一把锁(lock)。再结合程序次序规则——线程A在同步块内的修改先于它自己的unlock,线程B自己的lock先于它在同步块内的读取——就能推出线程A在同步块内的修改对线程B的读取是可见的。这是 synchronized 保证可见性的根本原理,而不是什么"锁自带魔法"。
Q4:主线程调用**thread.start()**之前做的修改,子线程一定能看到,这是为什么?
A:这是线程启动规则的直接结论:Thread.start() 这个方法调用 happens-before 新线程内部执行的每一个操作。所以主线程在调用 start() 之前对共享变量的所有写入,子线程一启动就能保证看到,不需要额外加 volatile 或加锁。
Q5:用**join()**等待子线程结束之后读取汇总变量为什么是安全的?
A:这是线程终止规则的结论:子线程内部的所有操作都 happens-before 其他线程检测到它终止的动作(比如 join() 成功返回)。所以只要主线程调用 join() 等到子线程真正结束,之后读取子线程在执行期间写过的任何共享变量都是安全的,不需要额外的同步措施。
Q6:什么是 happens-before 的传递性?能举一个实际会用到的例子吗?
A:传递性是说,如果 A happens-before B,且 B happens-before C,那么可以直接推出 A happens-before C。一个常见应用是"用一个 volatile 布尔标志位保护一批普通变量":线程1先写普通变量再写volatile标志位为true,线程2先读到这个标志位为true再读普通变量,通过程序次序规则+volatile变量规则+传递性,能推出线程2一定能读到线程1写入的普通变量最新值,即使这个普通变量本身没有加 volatile。
Q7:如果两个操作之间完全推不出任何 happens-before 关系,会发生什么?
A:那么这两个操作的执行顺序和可见性就是不确定的,JVM、编译器、处理器有权按任意合法方式调度和优化,读到的值可能是旧值也可能是新值,甚至可能读到一个逻辑上不该存在的中间状态。这种情况通常被称为存在"数据竞争"(Data Race),是并发 bug 最常见的根源之一。
Q8:除了程序次序、锁、volatile、线程启动、线程终止这几条,happens-before 还有其他规则吗?
A:还有中断规则(一个线程调用另一个线程的 interrupt() happens-before 被中断线程检测到中断事件)等补充规则,但工程实践中最常打交道、面试也最常问的就是本文重点介绍的这几条。理解这几条规则背后的推理方式,比死记规则列表更重要——因为很多实际场景需要把多条规则通过传递性组合起来才能得出结论。