Happens-Before 原则

候选人小周在面试京东 P6 时,面试官问了一个延伸问题:

"Happens-Before 是什么意思?它和'先后'有什么区别?"

小周说:"就是保证前面的操作先执行,后面的后执行。"面试官追问:"那单线程中后面的代码一定能'看到'前面的结果吗?"

小周说:"那当然..."面试官说:"不一定。编译器优化可能重排序。那 Happens-Before 和重排序是什么关系?"

小周彻底答不上来了...

一、核心问题:什么是 Happens-Before 🔴

1.1 问题拆解

第一层:概念定义(是什么?)
  "什么是 Happens-Before?它和普通的时间先后有什么区别?"
  考察点:是否理解 JMM 是语言层面的抽象,不是实际执行顺序

第二层:八大规则(有哪些?)
  "JMM 定义了哪些 Happens-Before 规则?"
  考察点:能否完整列举并在实际场景中运用

第三层:重排序与内存屏障(为什么需要?)
  "Happens-Before 和编译器重排序是什么关系?"
  考察点:是否理解编译器优化、CPU 乱序执行与 JMM 的关系

第四层:实际应用(怎么用?)
  "在并发编程中,如何利用 Happens-Before 编写正确的代码?"
  考察点:是否能在生产代码中正确使用 volatile、synchronized、final

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 提供约束

这句话有点绕,举个例子:

int a = 1;      // 操作 A
int b = a + 1;  // 操作 B

按程序顺序,A Happens-Before B。但 JVM 在单线程中可以将 A 和 B 重排序(因为它们没有依赖关系),只要重排序后程序结果不变(A 先算还是 B 先算,最终 b = 2)。但重排序不能违反 Happens-Before——如果 B 能看到 A 的结果,那么重排序后的效果必须和顺序执行一样。

在多线程场景下:

volatile boolean flag = false;
int data = 0;

// 线程 A
data = 42;      // 操作 A
flag = true;    // 操作 B

// 线程 B
if (flag) {     // 操作 C
    System.out.println(data);  // 操作 D
}

根据 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 规则

  1. 程序顺序规则(Program Order Rule) 同一线程中,按代码书写顺序,前面的操作 Happens-Before 后面的操作。

    // 线程 A 中
    a = 1;      // ①
    b = 2;      // ②
    // ① Happens-Before ②

    注意:JVM 仍可能重排序 ① 和 ②,只要重排序不影响该线程的结果。

  2. 监视器锁规则(Monitor Lock Rule) 对一个锁的 unlock 操作 Happens-Before 对同一个锁的后续 lock 操作。

    synchronized (lock) {
        a = 1;  // 写
    }          // unlock
    
    synchronized (lock) {
        // lock
        System.out.println(a);  // 读:一定看到上面的写入
    }
  3. volatile 变量规则(Volatile Variable Rule) 对 volatile 变量的写 Happens-Before 对该变量的读。

    volatile boolean ready = false;
    
    // 线程 A
    data = 42;
    ready = true;  // 写:隐含 Store Barrier
    
    // 线程 B
    if (ready) {    // 读:隐含 Load Barrier
        System.out.println(data);  // 一定看到写入
    }
  4. 线程启动规则(Thread Start Rule) Thread.start() 调用 Happens-Before 被启动线程中的任何操作。

    int x = 10;
    thread.start();        // start() Happens-Before 线程内任何操作
    thread.join();         // join() 返回后,线程内所有操作 Happens-Before join() 返回
  5. 线程终止规则(Thread Termination Rule) 线程中的所有操作 Happens-Before 其他线程检测到该线程终止。

  6. 中断规则(Interruption Rule) interrupt() 调用 Happens-Before 被中断线程检测到中断(通过 isInterrupted() 或抛出 InterruptedException)。

  7. 终结规则(Finalizer Rule) 对象的构造函数结束 Happens-Before 该对象的 finalize() 方法开始。

  8. 传递性规则(Transitivity) 如果 A Happens-Before B,B Happens-Before C,则 A Happens-Before C。

P7 级别:编译器重排序与内存屏障

为什么需要 Happens-Before?

因为现代编译器和 CPU 会进行指令重排序,目的是提升性能。但重排序不能违反 Happens-Before——这是 JMM 规定的"安全线"。

编译器的重排序类型

  1. 编译器重排序:JVM 在编译阶段重新安排指令顺序
  2. CPU 重排序:CPU 流水线执行、指令并行可能打乱指令顺序
  3. 内存重排序:Store Buffer 和 Invalidate Queue 导致"存储-加载"乱序

内存屏障的作用

volatile boolean ready = false;
int data = 0;

// 写线程中的 volatile 写:
// Store Barrier(storestore + storeload)
data = 42;         // 普通写
// storestore barrier:禁止上面的普通写重排序到下面的 volatile 写之后
ready = true;      // volatile 写,隐含 lock 前缀
// storeload barrier:禁止下面的读取重排序到上面的 volatile 写之前

// 读线程中的 volatile 读:
// Load Barrier(loadload + loadstore)
if (ready) {       // volatile 读,隐含 lock 前缀
    // loadload barrier:禁止下面的读重排序到上面的 volatile 读之前
    System.out.println(data);
    // loadstore barrier:禁止写重排序到上面的 volatile 读之后
}

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 字段有特殊规则:

  1. 构造函数中写入 final 字段 Happens-Before 该对象的引用被发布(在构造函数返回前,final 字段必须写入完成)
  2. 禁止在构造函数中逸出 this 引用(否则 final 字段的保证失效)
class Safe {
    final int x;
    Safe(int value) {
        x = value;  // final 写入
    }
}

class Unsafe {
    final int x;
    Unsafe() {
        x = 1;
        // 错误:逸出 this
        // new Unsafe() 可能被其他线程在构造函数完成前看到
    }
}

这个规则使得不可变对象(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 但对象未初始化完成。

// 错误版本
private static Singleton instance;
public static Singleton getInstance() {
    if (instance == null) {          // 第一次检查
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton(); // 可能重排序
            }
        }
    }
    return instance;
}

根因new Singleton() 的指令重排序——先分配内存并赋值引用(变成非 null),后执行构造函数。在高并发下,其他线程可能在构造函数执行前看到非 null 的 instance。

正确做法:加 volatile

3.2 错误的消息通知模式

// 错误:先改数据再改 flag
new Thread(() -> {
    data = compute();
    ready = true;  // 如果 ready 不是 volatile,可能看不到 data 的更新
});

// 正确:使用 Happens-Before 正确的通知模式
new Thread(() -> {
    data = compute();
    synchronized (lock) {
        ready = true;
        lock.notifyAll();
    }
});
💡

面试加分点:能说出"JMM 的 happens-before 和因果一致性(causal consistency)的关系"——JMM 满足 happens-before 一致性,但不满足因果一致性。因果一致性允许"先因后果"不成立的情况,只要求因果相关的操作有序。JDK 9 的 VarHandle 提供了更强的内存排序操作(如 setReleasegetAcquire),可以达到获取-释放语义(Acquire-Release Semantics)。

⚠️

面试陷阱:被问到"线程 A 中 a=1,线程 B 中 b=2,a 和 b 之间有 Happens-Before 关系吗"时,答"有"是大错特错。跨线程的 Happens-Before 必须通过同步机制(锁/volatile/线程启动/join 等)建立。没有任何同步的两个线程间的操作完全可能重排序或不可见。