承接的问题
8.3 讲的是怎么把一个偶发竞态缩小成可重复的回归测试。从这一节开始,问题换了一个方向:代码没有竞态、加锁也对,但一个线程就是看不到另一个线程写下的值。
最典型的现场是一段停机逻辑。服务收到 SIGTERM,主线程把 stop 置为 true,等后台线程退出;后台线程却在 while (!stop) { } 里一直转,进程不退出,容器要等到宽限期结束被 SIGKILL,正在处理的请求全部丢失。重启一次好了,下次发布又出现。
这类问题的迷惑性在于三点:
- 代码在单元测试里是对的。单线程、
-Xint、或者刚启动还没预热时,循环都能正常退出。 - 线程转储看起来"没毛病"。线程状态是
RUNNABLE,不是BLOCKED,也没有死锁报告。 - 加日志就"好了"。在循环体里加一行
System.out.println,问题消失——这不是玄学,是日志里的同步调用破坏了 JIT 的优化前提。
这一节先把现象固定下来(停不掉的线程长什么样),再解释根因(非 volatile 字段被 JIT 提升出循环),最后用本机的对照实验给出复现率,并说明 volatile 为什么能修、修到什么程度。
症状:一个 100% CPU 却永不退出的线程
构造场景:libraryd-conc 的借阅服务在关闭钩子里置标志,等待后台的预约过期扫描线程退出。
public final class ReservationSweeper implements Runnable {
private boolean running = true; // 反例:非 volatile
@Override
public void run() {
while (running) {
sweepExpiredReservations();
}
System.out.println("sweeper exited");
}
public void shutdown() {
running = false;
}
private void sweepExpiredReservations() {
// 空实现:真实的扫描逻辑会把循环体撑大
}
}
把 sweepExpiredReservations() 换成空方法,就得到最小复现体:
public class StaleLoop {
static boolean stop; // 非 volatile
static volatile boolean stopV; // 对照组
public static void main(String[] args) throws Exception {
String mode = args.length > 0 ? args[0] : "plain";
Thread worker = new Thread(() -> {
if (mode.equals("volatile")) {
while (!stopV) { }
} else {
while (!stop) { }
}
}, "worker");
worker.setDaemon(true);
worker.start();
Thread.sleep(300);
if (mode.equals("volatile")) {
stopV = true;
} else {
stop = true;
}
worker.join(2000);
System.out.println(worker.isAlive() ? "STALE" : "TERMINATED");
System.exit(0);
}
}
本机实际抓到的转储(用 StaleLoop 的变体把 join 超时放宽到 30 秒,方便抓取;Temurin 25.0.5+8,Apple M1 Pro,进程内已经置过 stop = true):
"worker-loop" #25 [27395] daemon prio=5 os_prio=31 cpu=2320.90ms elapsed=2.34s
java.lang.Thread.State: RUNNABLE
at StaleLoopDump.lambda$main$0(StaleLoopDump.java:6)
at StaleLoopDump$$Lambda/0x0000100001040210.run(Unknown Source)
at java.lang.Thread.runWith(java.base@25.0.5/Thread.java:1487)
三秒后再抓一次,除了 cpu 与 elapsed 增长,帧和行号完全不变:
"worker-loop" #25 [27395] daemon prio=5 os_prio=31 cpu=5457.10ms elapsed=5.48s
java.lang.Thread.State: RUNNABLE
at StaleLoopDump.lambda$main$0(StaleLoopDump.java:6)
ps 同一时刻显示 %CPU 100.0。这组证据把范围缩得很小:线程没有阻塞、没有等锁、没有 park,就是在同一条字节码上烧 CPU。剩下两种可能——要么循环条件永远为真(业务 bug),要么线程读不到主线程写下的值(内存可见性)。前者查源码就能排除,所以问题落在后者。
根因:非 volatile 读被提升到循环外
JMM 对数据竞争只承诺"不出现无中生有的值",不承诺"多久之内能看到"。stop 没有 volatile,主线程的写和后台线程的读之间没有任何 happens-before 边,因此:
- 后台线程可以一直读到旧值
false; - 更糟的是,编译器可以把这次读提升到循环外,只读一次,之后循环条件变成常量
true。
第二件事是这一节真正的杀手。它不是"晚一点看到",而是"永远看不到"。HotSpot 的 C2 在 OSR(栈上替换)编译时就会做这个优化:循环体里没有方法调用、没有 volatile、没有 synchronized,字段读是循环不变量,提升是合法的。
-XX:+PrintCompilation 的输出直接给出了证据(同一份 StaleLoop,plain 模式):
31 27 % 3 StaleLoop::lambda$main$0 @ 18 (28 bytes)
32 28 3 StaleLoop::lambda$main$0 (28 bytes)
32 29 % 4 StaleLoop::lambda$main$0 @ 18 (28 bytes)
32 27 % 3 StaleLoop::lambda$main$0 @ 18 (28 bytes) made not entrant: OSR invalidation of lower level
% 表示 OSR 编译,4 表示 C2。也就是说:这个 lambda 先被 C1 以 level 3 编译,随后在循环入口(bci 18)被 C2 以 level 4 重新编译,C1 版本作废。从这一刻起,执行的循环里已经没有字段读取了。
这解释了两个"玄学":
- 为什么加一行日志就好了。
println是同步方法调用,C2 不能把它跨过循环提升,字段读被迫留在循环里。 - 为什么重启后时好时坏。是否已经触发 C2 OSR,取决于循环跑了多久、编译线程排队情况。窗口期内的循环是解释执行的,能正常退出。
本机对照实验:30/30 与四组反证
只跑一次就下结论没有意义,所以每个配置都起独立 JVM,每个 JVM 只跑一次试验(主线程 300ms 后置标志,join(2000) 判活)。本机结果:
| 配置 | 试验次数 | STALE(未退出) | TERMINATED |
|---|---|---|---|
| 默认(分层编译,C1+C2) | 30 | 30 | 0 |
volatile 标志 | 30 | 0 | 30 |
-Xint(纯解释执行) | 10 | 0 | 10 |
-XX:TieredStopAtLevel=1(只用 C1) | 10 | 0 | 10 |
-XX:-TieredCompilation(只用 C2) | 10 | 10 | 0 |
四组对照把归因钉死了:
-Xint全部正常退出 → 不是业务逻辑写错,解释器每次都重新读字段;- 只用 C1 全部正常退出、只用 C2 全部卡死 → 提升是 C2 做的,与 CPU 架构无关;
volatile全部正常退出 → 加屏障后读不能被提升。
要强调一点:这类提升发生在编译器,不是硬件。x86 的 TSO 本来就不允许读被乱序到写之前,但编译器仍可以把读提到循环外,所以这个 bug 在 x86 上同样存在。本机只有 ARM 环境,x86 未实测,这里不做"本机验证"的表述。
另一个常被忽略的观察:过期读不一定表现为死循环。如果读取发生在有界循环里,C2 会把条件常量折叠,整个循环被"算"出来。
public class StaleRead {
static int limit = 100; // 非 volatile,主线程 200ms 后改成 200
static long sink; // 让累加有副作用,避免整体删除
public static void main(String[] args) throws Exception {
long n = Long.parseLong(args.length > 0 ? args[0] : "2000000000");
Thread worker = new Thread(() -> {
long old = 0, fresh = 0;
for (long i = 0; i < n; i++) {
if (limit == 100) {
old++;
} else {
fresh++;
}
}
sink = old + fresh;
System.out.println("iterations=" + n + " sawOld=" + old + " sawNew=" + fresh);
}, "reader");
worker.setDaemon(true);
long t0 = System.nanoTime();
worker.start();
Thread.sleep(200);
limit = 200;
worker.join();
System.out.println("elapsedMs=" + (System.nanoTime() - t0) / 1_000_000);
}
}
本机跑了 3 次,输出完全一致:
iterations=2000000000 sawOld=2000000000 sawNew=0
elapsedMs=669
把 limit 改成 volatile 后跑 2 次:
iterations=2000000000 sawOld=358822718 sawNew=1641177282
elapsedMs=1170
iterations=2000000000 sawOld=362959243 sawNew=1637040757
elapsedMs=1165
非 volatile 版本 20 亿次"读取"在 469ms(扣掉 200ms 睡眠)内完成,平均每次 0.23ns,低于一次 L1 命中的物理下限;volatile 版本耗时 1.17s,切换点在 3.6 亿次附近,与"主线程 200ms 时改值"吻合。结论不是"20 亿次都读了旧值",而是循环被折叠,读取根本没有在循环里发生——这比"读到旧值"更彻底。
修复:让读取每次都必须发生
第一选择是 volatile:
public final class ReservationSweeper implements Runnable {
private volatile boolean running = true; // 修复:每次循环都重新读
private long sweeps;
@Override
public void run() {
while (running) {
sweepExpiredReservations();
}
}
public void shutdown() {
running = false;
}
private void sweepExpiredReservations() {
sweeps++;
}
}
volatile 的语义不只是"不被缓存":写 volatile 是一次 release,读是一次 acquire,两者构成 happens-before,前面所有普通写都对读到新值的线程可见。它同时禁止编译器把读提出循环。
如果不想用 volatile(比如字段在热路径上被高频读),可以用 VarHandle 的 getOpaque / setOpaque,语义比 volatile 弱一档但仍然禁止提升;需要"读到就一定能看到之前所有写"时用 getAcquire / setRelease。
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
public final class SweeperWithVarHandle implements Runnable {
private static final VarHandle RUNNING;
static {
try {
RUNNING = MethodHandles.lookup().findVarHandle(
SweeperWithVarHandle.class, "running", boolean.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private boolean running = true;
private long sweeps;
@Override
public void run() {
while ((boolean) RUNNING.getOpaque(this)) {
sweepExpiredReservations();
}
}
public void shutdown() {
RUNNING.setOpaque(this, false);
}
private void sweepExpiredReservations() {
sweeps++;
}
}
第三种写法是把"等待退出"换成"等待一个可中断的条件":循环条件用 Thread.currentThread().isInterrupted(),循环体里 Thread.sleep(50)。isInterrupted() 是 native 方法调用,JIT 无法把状态缓存到循环外;sleep 抛 InterruptedException 让线程立刻醒来,顺带把空转降下来。代价是响应延迟从"立即"变成"最多 50ms",对停机完全可接受。
排查手法:三步确认是提升而不是死锁
| 步骤 | 命令 | 判据 |
|---|---|---|
| 1. 看状态 | jcmd <pid> Thread.print | RUNNABLE + 同一帧重复 + CPU 持续增长 → 自旋;BLOCKED → 锁;WAITING → park |
| 2. 看 CPU | ps -p <pid> -o %cpu / top -H | 单线程接近 100%,进程总 CPU 与之匹配 |
| 3. 看编译 | -XX:+PrintCompilation | 出现 % 4 的 OSR 记录,且方法就是循环所在方法 |
JFR 可以作为第 4 步的交叉验证。对上面卡死的进程录 6 秒 profile,jdk.ExecutionSample 共采到 458 个样本,全部落在同一帧:
sampledThread = "worker-loop" (javaThreadId = 30)
StaleLoopDump.lambda$main$0() line: 6
采样法在这里特别有效:死锁或等锁的线程不会被采到(它们不占 CPU),而自旋线程会占满所有样本。反过来说,如果采样里看不到这个线程,就不要往"JIT 提升"上归因。
在 libraryd-conc 里落地
conc-ops 的停机工具类统一用"volatile 标志 + 有界等待 + interrupt 兜底"三段式:
public final class GracefulShutdown {
private volatile boolean stopping;
public void stop(Thread worker, long timeoutMs) throws InterruptedException {
stopping = true; // 第一段:可见的标志
worker.interrupt(); // 第二段:叫醒可能在 sleep/park 的线程
worker.join(timeoutMs); // 第三段:有界等待,不无限期挂住
if (worker.isAlive()) {
throw new IllegalStateException("worker did not stop in " + timeoutMs + "ms");
}
}
public boolean isStopping() {
return stopping;
}
}
规范里加两条硬性要求:
- 任何被其他线程写、被循环条件读的字段,必须是
volatile、Atomic*或VarHandle访问,不允许裸boolean。 - 停机等待必须有超时,超时后记录线程转储再退出,不能
join()不带参数。
常见错误与边界
| 错误 | 现象 | 正确做法 |
|---|---|---|
用 Thread.stop() 强杀 | JDK 25 直接抛 UnsupportedOperationException | 标志 + interrupt + 超时 |
在循环里加 Thread.sleep(1) 当修复 | 问题消失但根因还在,换个循环又出现 | 修可见性,不靠副作用 |
认为 volatile 能保证 count++ 正确 | 计数仍然丢更新 | 复合操作用 AtomicInteger / 锁 |
| 只在启动阶段测试停机 | 未触发 C2 OSR,测不出来 | 用本节的独立 JVM 多次运行法 |
看到 RUNNABLE 就断定"在干活" | 自旋也被标成 RUNNABLE | 结合 CPU 占用与帧是否变化 |
什么时候不要用 volatile:当循环条件依赖多个字段的一致性时(比如 if (stopping && queue.isEmpty()))。volatile 只保证单个读的可见性,两个读之间仍可能插进别的线程,需要锁或把状态收进一个不可变快照对象,用一次 volatile 读拿到整份状态。
还有一个边界要写清楚:本节的 30/30 只说明这台机器、这个 JDK、这个循环形状下稳定复现。循环体里多一次方法调用、多一个 try 块,C2 的决策就可能改变。复现率不是代码的属性,是"代码 + JIT 版本 + 负载"的属性;诊断时先复现,再谈概率。
小结
过期读的根因可以压成一句:没有 happens-before 边的读,编译器有权只读一次。表现为死循环时,线程转储是 RUNNABLE + 同一帧 + 高 CPU;表现为有界循环时,可能直接被常量折叠,连"读到旧值"都看不到。
本机的数字:默认配置 30 次独立运行 30 次卡死,volatile 0/30,-Xint 0/10,只用 C1 0/10,只用 C2 10/10。C1 与 C2 的分裂是这一节最有用的一条证据——它把"玄学"变成了"某个编译器在某个 bci 上做的某个优化"。
下一节把范围从一个字段扩大到一个对象:即使字段本身写对了,只要对象在构造完成之前被别的线程看见,对方拿到的仍可能是半成品。
阅读导航:上一节 8.3 竞态复现、最小化与回归 | 下一节 9.2 安全发布失败的对象
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。