本节目标:把「线程 A 写了,线程 B 为什么看不见」变成一个可以复现、可以解释、可以修的问题。
适用版本:JDK 25(Temurin 25.0.5);只使用 JDK 标准库。
承接的问题
libraryd 卷二上线时有一个缓存:AvailabilityCache 每 30 秒由后台线程从数据库全量拉一次「ISBN → 可借副本数」,请求线程只读这份内存快照,避免每次借阅都打一次聚合查询。
上线第二天,一台机器上的 /api/books/{isbn} 一直返回 copies=1,而数据库里同一本书已经是 3。刷新线程的日志显示它每 30 秒都在 publish,请求线程却像停在了旧世界。重启进程后恢复正常,过一段时间又出现。
这类问题最难的地方不是修,而是它不可复现:同一份代码在开发机上跑一万次都对。原因是 Java 内存模型(JMM)只承诺一件事——存在 happens-before 关系的写,对相应的读可见。没有这层关系,读线程没有任何义务看到写线程的修改,JIT 可以把读提升到循环外,CPU 可以让读走寄存器或 store buffer。
这一节先用两个最小实验把「看不见」和「顺序反了」分别固定下来,再回到 AvailabilityCache,看 libraryd 应该怎么写。
实验一:没有 volatile 的停止标志
最朴素的例子是一个布尔标志。写线程 200ms 后把它置为 true,读线程在 while (!stop) 里自旋,期望尽快退出:
public final class StopFlag {
static boolean stop; // 反例:没有 volatile
public static void main(String[] args) throws Exception {
int runs = Integer.parseInt(args[0]);
int hung = 0;
for (int i = 0; i < runs; i++) {
stop = false;
Thread worker = new Thread(() -> {
long spins = 0;
while (!stop) {
spins++;
}
}, "reader-" + i);
worker.setDaemon(true);
worker.start();
Thread.sleep(200);
stop = true;
worker.join(500);
if (worker.isAlive()) {
hung++;
}
}
System.out.println("runs=" + runs + " hung=" + hung);
}
}
编译并运行 10 轮,每轮给读线程 500ms 的退出窗口:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
javac --release 25 -d /tmp/vol3-b/jmm/out /tmp/vol3-b/jmm/StopFlag.java
java -cp /tmp/vol3-b/jmm/out StopFlag 10
本机输出(Apple M1 Pro,macOS 26.3,Temurin 25.0.5):
runs=10 hung=10
10 轮全部没有退出。这不是「概率问题」,而是 C2 把这个循环编译成了「只读一次 stop」的形态:循环体里没有任何可能改变 stop 的操作,也没有同步点,把读提升到循环外是合法优化。读线程在自己的世界里永远看到 false。
要确认原因确实是 JIT 而不是「CPU 太慢」,用解释器模式跑同一份字节码:
java -Xint -cp /tmp/vol3-b/jmm/out StopFlag 3
本机输出:
worker exited spins=39944282
worker exited spins=40954297
worker exited spins=40823223
runs=3 hung=0
-Xint 下 3 轮全部正常退出,每轮自旋约 4000 万次。同一份代码,只换执行引擎,行为就反过来了——这基本可以锁定是编译器的循环提升,而不是调度巧合。
把 stop 改成 volatile,10 轮全部退出:
static volatile boolean stop;
runs=10 hung=0
volatile 在这里做两件事:禁止读被提升到循环外(每次都必须重新读),并且让写线程的写对读线程可见。它不保证「读线程一定立刻看到」,但保证「迟早看到,且不会永久看不到」。
实验二:两个写、两个读的顺序
可见性之外还有第二个问题:编译器和 CPU 可以调整没有依赖关系的指令顺序。经典的 Dekker litmus 测试是这样的:两个线程各自先写自己的变量,再读对方的变量。
public final class Reorder2 {
static int x;
static int y;
static int r1;
static int r2;
static volatile int start;
static volatile int doneA;
static volatile int doneB;
public static void main(String[] args) throws Exception {
int iterations = Integer.parseInt(args[0]);
Thread a = new Thread(() -> {
for (int i = 1; i <= iterations; i++) {
while (start < i) { }
x = 1;
r1 = y;
doneA = i;
}
});
Thread b = new Thread(() -> {
for (int i = 1; i <= iterations; i++) {
while (start < i) { }
y = 1;
r2 = x;
doneB = i;
}
});
a.start();
b.start();
long bothZero = 0;
for (int i = 1; i <= iterations; i++) {
x = 0;
y = 0;
r1 = -1;
r2 = -1;
start = i;
while (doneA < i || doneB < i) { }
if (r1 == 0 && r2 == 0) {
bothZero++;
}
}
System.out.println("iterations=" + iterations + " bothZero=" + bothZero);
}
}
start、doneA、doneB 是 volatile,只负责回合切换与结果发布;被测的是 x、y 的四个读写。如果两个线程严格按程序顺序执行,「A 写 x」和「B 写 y」至少有一个先发生,r1、r2 不可能同时为 0。跑 200 万轮:
javac --release 25 -d /tmp/vol3-b/jmm/out /tmp/vol3-b/jmm/Reorder2.java
java -cp /tmp/vol3-b/jmm/out Reorder2 2000000
本机两次运行的输出:
iterations=2000000 bothZero=1988302 other=11698
iterations=2000000 bothZero=1986894 other=13106
约 99.4% 的回合里,两个读都看到了 0。这是合法结果:x = 1 和 r1 = y 之间没有依赖,编译器可以把读排到写前面;即使编译器不动,两个写进入 store buffer 后,对方的读也可能先完成。JMM 不禁止这种重排,因为它没有违反任何 happens-before。
把 x、y 都改成 volatile,同样 200 万轮:
iterations=2000000 bothZero=0
volatile 写与后续 volatile 读之间建立了 happens-before,两个读不可能都落在对方的写之前。
这里要如实交代一个未复现的版本。第一版实验是「每轮新建两个线程」,跑 2 万轮:
iterations=20000 bothZero=0
本次未复现,原因是每轮要新建两个线程,t1.start() 和 t2.start() 之间隔着几十微秒,而两个线程真正执行 x=1; r1=y 的窗口只有纳秒级,两边几乎不可能同时进入。改成上面的常驻线程加 volatile 回合切换后,窗口被压到最小,才稳定复现。写并发实验时,「没复现」不等于「不存在」,要把线程创建、启动顺序这些开销算进去。
happens-before 的六条规则
JMM 用 happens-before 定义可见性,日常只需记住六条:
- 程序顺序:同一个线程里,前面的操作 happens-before 后面的操作。
- 监视器锁:对同一个 monitor 的 unlock happens-before 后续的 lock。
- volatile:对同一个 volatile 变量的写 happens-before 后续对它的读。
- 线程启动与终止:
Thread.start()happens-before 新线程里的任何操作;线程里的所有操作 happens-before 其他线程从join()返回。 - final 字段:构造器里对 final 字段的写,happens-before 构造完成后引用被其他线程看到(前提是构造期间
this没有逸出)。 - 传递性:A hb B、B hb C,则 A hb C。
回到 AvailabilityCache 的 bug:刷新线程写了一个普通字段,请求线程读它,两者之间只有「碰巧在同一个进程」。没有第 3 条,也没有第 2 条,JMM 不给任何保证。
顺带说清一个常见混淆:对象默认值(0、false、null)是另一回事。JVM 保证字段在分配后先被清零,读到 0 不奇怪;奇怪的是它一直看不到后来的值。
volatile 的边界
volatile 常被当成「轻量锁」,但它只解决两件事:可见性和禁止特定重排。它不解决原子性。
// 反例:volatile 不能保证自增原子
static volatile int borrowedCount;
static void recordBorrow() {
borrowedCount++; // 读、加一、写回,三步之间可以被别的线程插入
}
8 个线程各自增 100 万次,最终的 borrowedCount 会明显小于 800 万,且每次运行都不一样。正确的做法是 AtomicLong(4.2 节)或 LongAdder。
另外两个边界:
- volatile 引用只保护引用本身。
volatile List<Loan> loans保证「换列表」可见,但往列表里add不受保护;volatile int[] counts也只让数组引用可见,counts[0]++依然不是原子的。
volatile 真正适合的场景是「一个线程写、多个线程读」的状态标志,或者「发布一个不可变快照」。libraryd 的两处用法都属于后者。
在 libraryd 里落地
第一处是停机标志。卷二原来的写法是普通 boolean,在 -Xint 下能跑,开了 JIT 就有概率停不下来:
package com.plume.library.app;
public final class LifecycleFlag {
private volatile boolean stopping;
public boolean isStopping() {
return stopping;
}
public void requestStop() {
stopping = true;
}
}
它被三个地方读:Javalin 的 before 过滤器(拒绝新请求)、LoanEventPipeline 的消费循环(决定是否继续取事件)、健康检查(返回 draining 状态)。写只有一处:shutdown hook。这种「单写多读、只关心最终看见」的形状,是 volatile 的标准用法。
第二处是 AvailabilityCache。修法不是给每个字段加 volatile,而是把整份快照做成不可变对象,用一次 volatile 写发布:
package com.plume.library.persistence;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
public final class AvailabilityCache {
public record Snapshot(String isbn, int copies, Instant loadedAt) {
public Snapshot {
if (copies < 0) {
throw new IllegalArgumentException("copies 不能为负: " + copies);
}
}
}
private volatile Map<String, Snapshot> current = Map.of();
public Optional<Snapshot> get(String isbn) {
Map<String, Snapshot> local = current; // 只读一次 volatile
return Optional.ofNullable(local.get(isbn));
}
public void publish(Map<String, Snapshot> fresh) {
current = Map.copyOf(fresh); // 先构造完整快照,再一次性发布
}
}
三个细节决定了它正确:
Map.copyOf生成不可变副本,发布之后没有任何人能改它,读线程不会看到「一半新一半旧」的 map。volatile写建立了 happens-before:刷新线程在publish之前对 map 的所有写,对之后读到这个引用的线程都可见。get先把current读进局部变量。一次请求里如果需要多次读取,用同一个local,避免两次读之间缓存被换掉,出现前后不一致。
刷新任务本身也变简单了,它只负责算出一份新 map:
package com.plume.library.persistence;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
public final class AvailabilityRefresher {
private final AvailabilityQueries queries;
private final AvailabilityCache cache;
public AvailabilityRefresher(AvailabilityQueries queries, AvailabilityCache cache) {
this.queries = queries;
this.cache = cache;
}
public void refreshOnce() {
Map<String, AvailabilityCache.Snapshot> fresh = new HashMap<>();
Instant now = Instant.now();
for (AvailabilityQueries.Row row : queries.loadAll()) {
fresh.put(row.isbn(), new AvailabilityCache.Snapshot(row.isbn(), row.copies(), now));
}
cache.publish(fresh);
}
}
AvailabilityQueries 是 persistence 模块的只读查询端口,返回 Row(isbn, copies)。刷新失败时不要 publish 空 map,那会让所有书瞬间变成不可借;保留上一份快照并把错误记进指标,是更安全的降级。
两个类都在 JDK 25 下用 javac --release 25 -d out -cp out 编译通过,无警告。
常见错误与边界
反例一:用 volatile 当计数器。 前面已经跑过,8 线程 100 万次会丢几十万。volatile 保证每次读是最新值,但 count++ 的「读—加—写」三步仍然可以被交错。看到 volatile int count 就应立即想到「这里是不是该用 AtomicLong」。
反例二:双重检查锁忘了 volatile。
// 反例:instance 不是 volatile
private static Singleton instance;
static Singleton get() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 可能先发布引用,再完成构造
}
}
}
return instance;
}
new Singleton() 不是原子动作:分配、初始化、赋值引用三步可能重排,另一个线程可能在第一个 if 处看到一个「引用非空但字段还是默认值」的对象。JDK 5 之后把 instance 声明为 volatile 就能修好,这也是 JMM 修订的动因之一。
反例三:以为 volatile 能替代锁。 「先检查再动作」需要原子性,volatile 给不了。库存检查 if (copies > 0) copies-- 即使 copies 是 volatile,两个线程也可能都通过检查。libraryd 的做法是把这类判断交给数据库条件更新,而不是在 JVM 里用 volatile 硬撑。
边界:volatile 的读写在 x86 上几乎免费,在 ARM 上不是。 本机是 Apple Silicon(aarch64),volatile 写会生成带内存屏障的指令。把热循环里的普通字段全改成 volatile,可能带来可测量的开销——先测量再改,第 9 章会用 JMH 做这件事。
小结
- 没有 happens-before 关系时,读线程没有义务看到写线程的修改;这不是 bug,是 JMM 允许的。
- 非 volatile 的停止标志在本机 10/10 卡死,
-Xint下 3/3 正常退出,说明主因是 JIT 的循环提升。 - Dekker litmus 在 200 万轮里有约 99.4% 出现「两个读都为 0」,给
x、y加volatile后降为 0。 - happens-before 记住六条:程序顺序、monitor、volatile、线程 start/join、final 字段、传递性。
volatile只给可见性和有序性,不给原子性;计数用AtomicLong/LongAdder,复合判断用锁或数据库条件更新。- libraryd 用
volatile发布不可变快照:先Map.copyOf构造完整对象,再一次写引用。
volatile 解决了「看见」,但没解决「同时改」。下一节从一次丢了几十万次自增的计数器开始,看 CAS 与 LongAdder 各自适合什么。
阅读导航:上一节:3.3 Instrumentation 与 Agent · 下一节:4.2 原子类与 CAS:AtomicLong 与 LongAdder 的取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。