《Java 并发编程·性能诊断与事故》9.1 过期读与无限循环

从停不掉的停机标志出发,构造非 volatile 标志导致的无限循环,用线程转储、JFR 采样与 PrintCompilation 定位到 C2 把字段读提升到循环外,并在本机用 30 次独立 JVM 运行逐档对照 Xint、C1、C2 与 volatile 修复的复现率。

承接的问题

8.3 讲的是怎么把一个偶发竞态缩小成可重复的回归测试。从这一节开始,问题换了一个方向:代码没有竞态、加锁也对,但一个线程就是看不到另一个线程写下的值。

最典型的现场是一段停机逻辑。服务收到 SIGTERM,主线程把 stop 置为 true,等后台线程退出;后台线程却在 while (!stop) { } 里一直转,进程不退出,容器要等到宽限期结束被 SIGKILL,正在处理的请求全部丢失。重启一次好了,下次发布又出现。

这类问题的迷惑性在于三点:

  1. 代码在单元测试里是对的。单线程、-Xint、或者刚启动还没预热时,循环都能正常退出。
  2. 线程转储看起来"没毛病"。线程状态是 RUNNABLE,不是 BLOCKED,也没有死锁报告。
  3. 加日志就"好了"。在循环体里加一行 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)30300
volatile 标志30030
-Xint(纯解释执行)10010
-XX:TieredStopAtLevel=1(只用 C1)10010
-XX:-TieredCompilation(只用 C2)10100

四组对照把归因钉死了:

  • -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.printRUNNABLE + 同一帧重复 + CPU 持续增长 → 自旋;BLOCKED → 锁;WAITING → park
2. 看 CPUps -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;
    }
}

规范里加两条硬性要求:

  1. 任何被其他线程写、被循环条件读的字段,必须是 volatile、Atomic* 或 VarHandle 访问,不允许裸 boolean。
  2. 停机等待必须有超时,超时后记录线程转储再退出,不能 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 安全发布失败的对象

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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