《Java 并发编程·线程与内存模型》3.2 happens-before 八条规则

把 JMM 的八条 happens-before 规则逐条拆成最小代码,说明它们只约束可见性而非实际执行顺序;再用规则链推导双重检查锁定为什么必须加 volatile,给出四道「看到 A 是否一定看到 B」的推导练习与答案,并在借阅服务里用 volatile 发布不可变审计快照。

承接的问题

3.1 用停机标志展示了「写已经发生、读线程看不见」。但那只是一个具体程序,换一段代码又得重新猜。

JMM 给出的通用判据是 happens-before(下称 hb)。它不描述指令实际什么时候执行,只描述哪些写对哪些读可见:两个动作之间存在 hb 边,前者的效果对后者可见;没有边,就不能假设可见。

这一节把八条规则逐条落到最小代码,澄清 hb 不是全局时间顺序,再用规则链推导 DCL 为什么需要 volatile。

八条规则总表

常说的八条规则,出自 JLS 17.4.5 的 happens-before 定义:

#规则边从哪来边到哪去
1程序顺序线程内前面的动作线程内后面的动作
2监视器锁同一把锁的 unlock后续 lock
3volatile对同一变量的写后续读
4线程启动Thread.start()新线程中的任意动作
5线程终止线程中的任意动作其他线程检测到终止
6中断interrupt()被中断线程检测到中断
7对象终结构造器结束该对象的 finalize() 开始
8传递性A hb B 且 B hb CA hb C

规则 1 只在同一线程内建立边;规则 8 负责把前七条串起来。hb 是偏序:有边才有保证,没边不等于「一定看不到」,而是「看到什么都有可能」。

前四条:程序顺序、锁、volatile、启动

规则 1:程序顺序

同一线程内,按源代码顺序,前面的动作 hb 后面的动作。

int a = 1;
int b = a + 1;   // 同一线程内,读 a 一定看到 1

它不阻止重排,只要求重排后线程自己观察到的结果不变。单独看没有跨线程价值,价值在于和规则 2、3、4 组合,把线程内的多个写一起「带过去」。

规则 2:监视器锁

对同一把锁,unlock hb 后续的 lock。

// 线程 A
synchronized (lock) {
    balance = 100;
}

// 线程 B
synchronized (lock) {
    System.out.println(balance);   // 一定看到 100
}

B 能看到的不仅是 balance:A 在 unlock 之前的所有写,都会通过「程序顺序 + unlock hb lock + 程序顺序」传过来。这也是「把一组字段放在同一把锁里」比逐个加 volatile 更省心的原因。前提是同一把锁;两个线程各锁 new Object(),等于没锁。

规则 3:volatile

对同一个 volatile 变量,写 hb 后续的读。

volatile boolean ready;
int data;

// 线程 A
data = 42;
ready = true;

// 线程 B
if (ready) {
    System.out.println(data);   // 一定看到 42
}

推导链:data = 42 程序顺序 hb ready = true;volatile 写 hb B 的读;B 的读程序顺序 hb println(data)。注意方向只有「写 hb 后续读」:如果 B 先读到 false,之后 A 再写 true,这次读不会让 B 看到 A 之后的任何东西。

规则 4:线程启动

Thread.start() hb 被启动线程中的任意动作。

Config config = loadConfig();
Thread worker = new Thread(() -> use(config));
worker.start();   // 子线程一定能看到 config

「先准备数据,再启动线程」因此是安全模式。反过来不成立:子线程的写不会自动对父线程可见,除非父线程 join() 或通过其他同步动作读取。

后四条:终止、中断、终结、传递性

规则 5:线程终止

一个线程中的所有动作 hb 其他线程检测到该线程终止。检测方式包括 join() 正常返回和 isAlive() 返回 false。

int[] result = new int[1];
Thread worker = new Thread(() -> result[0] = compute());
worker.start();
worker.join();
System.out.println(result[0]);   // 一定看到 compute() 的结果

join(1000) 带超时时,返回不代表线程已终止,只代表等待结束;返回后要检查 isAlive(),而 isAlive() == false 本身才是规则 5 的检测点。

规则 6:中断

对一个线程的 interrupt() 调用 hb 被中断线程检测到中断。检测包括抛出 InterruptedException,以及 isInterrupted() / interrupted() 返回 true。

// 线程 A
worker.interrupt();

// 线程 B(worker)
try {
    Thread.sleep(1000);
} catch (InterruptedException e) {
    // 能看到 A 在 interrupt() 之前的所有写
    Thread.currentThread().interrupt();
}

实际意义是:用中断传递「停止」信号时,信号之前的准备动作(比如把状态改成 STOPPING)对被中断线程可见。

规则 7:对象终结

构造器结束 hb 该对象 finalize() 的开始。

public class Resource {
    private final long handle = open();   // 字段初始化,在构造器中执行

    @Override
    protected void finalize() {
        release(handle);                  // 理论上能看到 handle
    }
}

工程上基本不用:finalize() 从 JDK 9 起弃用、JDK 18 起标记为 deprecated for removal,执行时机不确定。列在这里只为规则完整;清理资源用 try-with-resources 或 Cleaner。

规则 8:传递性

如果 A hb B,且 B hb C,那么 A hb C。

int data;
volatile boolean ready;

// 线程 A
data = 42;
ready = true;

// 线程 B
if (ready) {
    System.out.println(data);
}

data = 42 hb ready = true(规则 1),写 hb 读(规则 3),读 hb println(规则 1),传递得到 data = 42 hb println。跨线程推导几乎都要靠这条。

happens-before 不是执行顺序

最常见的误读是:A hb B,所以 A 一定先执行、B 一定后执行。

hb 约束的是可见性:如果 B 读取了 A 写的东西,B 必须看到 A 的结果;它不规定两条指令在时间轴上的先后,也不给所有动作一个全局顺序。

volatile int v;

// 线程 B
System.out.println(v);

// 线程 C
System.out.println(v);

B 和 C 都能看到最新值,但两者之间没有 hb 边,谁先打印没有保证。hb 是偏序,不是全序;不同线程的无关动作可以任意交错。

两个连带误读:

  • hb 阻止重排:不会。只要不破坏 hb 要求的结果,编译器和 CPU 仍可重排。volatile 写之前的普通写不能挪到写之后,读之后的普通读不能挪到读之前,其他方向仍有空间。
  • 有 hb 边就原子:不会。两个线程都执行 volatile int i; i++,每次读都能看到最新值,但「读、加、写」仍可能交错,照样丢更新。

用规则推导双重检查锁定

先看错误版本:

public final class LazySingleton {
    private static LazySingleton instance;
    private final int capacity;

    private LazySingleton() {
        this.capacity = 16;
    }

    public static LazySingleton getInstance() {
        if (instance == null) {                 // 快路径,无锁
            synchronized (LazySingleton.class) {
                if (instance == null) {
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }

    public int capacity() {
        return capacity;
    }
}

new LazySingleton() 大致是三步:分配内存、执行构造器把 capacity 写成 16、把引用赋给 instance。没有同步时,后两步可能被重排,另一个线程可能看到非空引用,但 capacity 仍是 0。

用 hb 分析:写线程在 synchronized 块内完成「构造 + 赋值」,退出时 unlock。读线程的快路径在锁外,既没有 lock 也没有 volatile 读,和写线程的 unlock 之间没有 hb 边。所以快路径看到非空引用时,没有任何规则保证它看到 capacity = 16。慢路径(进入 synchronized 的线程)是安全的,因为 unlock hb 后续 lock;出问题的只有不拿锁的快路径。

修复只加一个 volatile 修饰符,并把读结果存进局部变量:

private static volatile LazySingleton instance;

public static LazySingleton getInstance() {
    LazySingleton local = instance;          // 一次 volatile 读
    if (local == null) {
        synchronized (LazySingleton.class) {
            local = instance;
            if (local == null) {
                local = new LazySingleton();
                instance = local;            // volatile 写
            }
        }
    }
    return local;
}

推导链:

  1. 构造器里 capacity = 16 程序顺序 hb instance = local(volatile 写);
  2. volatile 写 hb 快路径的 volatile 读 local = instance;
  3. 快路径读 hb return local 以及调用方读 capacity()。

传递得到 capacity = 16 hb capacity() 的读,所以快路径看到非空引用时,一定看到 16。

局部变量 local 不是为了性能,而是为了避免「判断用一次读、返回用另一次读」:两次读之间别的线程可能已经完成初始化。判断和返回用同一个值,推理更简单。

为什么 final 不能替代 volatile

capacity 是 final 字段,3.3 会讲 final 字段的初始化安全保证。在「构造器没有逸出 this」的前提下,全 final 字段的对象即使通过数据竞争发布,读线程也可能看到正确的 final 值。

但 DCL 的字段往往不止一个,还可能有非 final 的可变状态;依赖 final 语义属于「用对了是运气、用错了没提示」。标准写法仍然是 volatile。更简单的替代是静态持有类(holder idiom):把实例放进一个私有静态内部类,getInstance() 只返回 Holder.INSTANCE,利用类初始化锁建立 hb,连 DCL 都不需要。

推导练习:看到 A 就一定看到 B 吗

给定:

class Box {
    int a;
    volatile int v;
    int b;
}

// 线程 T1
a = 1;
v = 2;
b = 3;

// 线程 T2
if (v == 2) {
    System.out.println(a);
    System.out.println(b);
}
问题答案规则链
T2 看到 v == 2 时,a 一定是 1 吗是a=1 程序顺序 hb v=2 写;volatile 写 hb 读;读 hb println(a)
b 一定是 3 吗不一定b=3 在 volatile 写之后,没有边回到 T2 的读
T2 先读 v 得到 0,之后 T1 写 v=2,那次读能看到 a=1 吗不能由这条读推出读发生在写之前,hb 方向不成立

锁的版本同理:T1 在 synchronized 块内写 x、块外写 y,T2 在同一个锁内读两者。x 一定可见(写在 unlock 之前,unlock hb T2 的 lock),y 不一定(写在 unlock 之后,没有 hb 边)。练习的通用步骤是:先找同步动作,再看普通读写位于同步动作的哪一侧,最后用程序顺序和传递性串起来。

在 libraryd-conc 里落地

借阅服务需要一个「最近一次预约」的审计探针,供监控线程读取。写线程是业务线程,读线程是监控线程,不能加锁拖慢业务。

package com.plume.conc.core;

import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicLong;

public final class LoanAuditTrail {
    public record Entry(String isbn, String memberId, Instant dueAt) {
    }

    private volatile Entry last;
    private final AtomicLong total = new AtomicLong();

    public void record(Reservation reservation) {
        Entry entry = new Entry(
                reservation.isbn(),
                reservation.memberId(),
                reservation.dueAt());
        last = entry;                  // volatile 写:entry 已构造完整
        total.incrementAndGet();
    }

    public Optional<Entry> last() {
        return Optional.ofNullable(last);   // volatile 读
    }

    public long total() {
        return total.get();
    }
}

正确性来自两条边:

  • record 中 new Entry(...) 程序顺序 hb last = entry 的 volatile 写;Entry 的字段又是 final,构造后不改。
  • volatile 写 hb 监控线程的 volatile 读;读 hb Optional.ofNullable 以及调用方读 isbn() / memberId()。

监控线程拿到非空 Entry 时,三个字段一定已赋值,不会出现 isbn 为 null 的半成品。这里不加锁,因为读多写少且只发布不可变快照;total 用 AtomicLong 是因为它需要复合原子性,volatile 不够,两者解决的是不同问题。

常见错误与边界

错误问题修法
认为 hb 给出全局先后hb 是偏序,B、C 之间可能无序只推导「谁看到谁」
用 sleep 等另一个线程的结果没有 hb 边join() 或 Future.get()
两个线程锁不同对象不构成同一监视器同一个 private final Object
以为 volatile 能保证 i++只保证可见性,不保证原子AtomicInteger 或锁
启动后再补写初始化数据子线程可能看不到初始化放在 start() 之前
用 finalize() 释放资源时机不确定,API 已弃用try-with-resources / Cleaner

几个边界:

hb 不能跨不同变量。 对 v1 的 volatile 写,不会让另一个线程对 v2 的普通读获得任何保证;每条边都绑定具体的变量或锁。

hb 不能弥补原子性。 即使每次读都看到最新值,读-改-写仍可能交错。

非 volatile 的 64 位 long / double 在规范上允许非原子读。 JLS 允许把一次 64 位读写拆成两次 32 位操作(word tearing)。HotSpot 在 64 位平台上通常实现为原子,但写可移植代码时不要依赖;共享的 long 计数要么加 volatile,要么用 AtomicLong。

什么时候不要用 hb 推理: 如果要证明的是吞吐、延迟或「哪个线程先打印」,hb 帮不上忙。它只回答可见性与顺序一致性范围内的正确性问题;性能问题看卷三的压测与诊断。

小结

  • 八条规则里,程序顺序是基础,监视器锁和 volatile 最常用,start/join 管生命周期,中断管取消信号,终结规则实践中不用,传递性负责串链。
  • hb 是可见性约束,不是执行时间顺序;它不阻止重排,也不提供原子性。
  • DCL 的快路径在锁外,和写线程的 unlock 之间没有 hb 边,所以必须用 volatile 建立「构造完成 hb 引用可见」的链。
  • 推导方法是固定的:找同步动作、看普通读写在哪一侧、用程序顺序和传递性接起来。
  • 落地时优先发布不可变快照(record + volatile),需要复合原子性时再换原子类或锁。

阅读导航:上一节 3.1 重排序、可见性与有序性 | 下一节 3.3 final 字段与安全发布

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收