承接的问题
3.1 用停机标志展示了「写已经发生、读线程看不见」。但那只是一个具体程序,换一段代码又得重新猜。
JMM 给出的通用判据是 happens-before(下称 hb)。它不描述指令实际什么时候执行,只描述哪些写对哪些读可见:两个动作之间存在 hb 边,前者的效果对后者可见;没有边,就不能假设可见。
这一节把八条规则逐条落到最小代码,澄清 hb 不是全局时间顺序,再用规则链推导 DCL 为什么需要 volatile。
八条规则总表
常说的八条规则,出自 JLS 17.4.5 的 happens-before 定义:
| # | 规则 | 边从哪来 | 边到哪去 |
|---|---|---|---|
| 1 | 程序顺序 | 线程内前面的动作 | 线程内后面的动作 |
| 2 | 监视器锁 | 同一把锁的 unlock | 后续 lock |
| 3 | volatile | 对同一变量的写 | 后续读 |
| 4 | 线程启动 | Thread.start() | 新线程中的任意动作 |
| 5 | 线程终止 | 线程中的任意动作 | 其他线程检测到终止 |
| 6 | 中断 | interrupt() | 被中断线程检测到中断 |
| 7 | 对象终结 | 构造器结束 | 该对象的 finalize() 开始 |
| 8 | 传递性 | A hb B 且 B hb C | A 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;
}
推导链:
- 构造器里
capacity = 16程序顺序 hbinstance = local(volatile 写); - volatile 写 hb 快路径的 volatile 读
local = instance; - 快路径读 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(...)程序顺序 hblast = 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 字段与安全发布
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。