《Java 并发编程·性能诊断与事故》9.3 从事故到 happens-before 归因

把过期读与半成品对象两起构造事故收进同一张归因表,给出从现象、违规的 happens-before 规则、三级证据到修复验证的完整链路,附可复用的自旋线程检测器与回归测试,并如实记录回归测试 2200 万次读取 0 命中的能力边界与本机复现数据。

承接的问题

9.1 和 9.2 各自给了一个事故:一个停不掉的线程,一个字段全是默认值的对象。现象不同,根因是同一条——两个线程之间缺少 happens-before 边。但在真实的事故复盘里,难点不在于"知不知道 happens-before",而在于:

  • 现场只有一段线程转储、一段日志,怎么排除"业务逻辑写错";
  • 什么时候能说"就是可见性问题",什么时候只能说"没有复现";
  • 修完之后,用什么证明修好了,而不是换了个地方偶发。

这一节不再引入新的故障类型,而是把前两节的做法提炼成一套可重复的流程:归因表 → 证据链 → 修复 → 验证 → 回归。核心原则只有一条:每一条结论都必须能对应到一条 JMM 规则,或者对应到一次可重复的实验。对应不上的,就写"待定",不要写"疑似"后面接一段猜测。

归因表:五列把现象压成结论

复盘的产出不是一段叙述,而是一张表。五列缺一不可:

列要写什么不合格的写法
现象可观测的事实,带时间与次数“系统卡了”
违反的 HB 规则具体到哪条边、哪两个动作“内存可见性问题”
证据现场证据 + 复现证据 + 理论证据“经验判断”
修复补了哪条边,为什么够“加了 volatile”
验证修复前后同一实验的对比数据“上线观察一周”

把 9.1 和 9.2 填进去:

现象违反的 HB 规则证据修复验证
停机标志置位后线程仍 RUNNABLE 自旋,30/30 次不退出普通字段写与读之间无 HB;C2 把读提升出循环两次转储同帧 + CPU 100%;% 4 OSR 编译记录;JFR 458 个样本同帧;C2-only 10/10 卡死标志改 volatile,写读构成 release/acquire30 次独立 JVM 全部退出;-Xint/C1 对照正常
注册表读者拿到引用但字段为默认值,JCStress 命中 0.0013%构造器内 this 逸出,赋值与发布之间无 HB;final 冻结发生在构造器末尾1, 0 样本 4118/3.08 亿;逸出用例出现 1, 0, 500 与 1, 3, 0构造器只赋值,volatile 发布完整对象FinalPubTest/SafePubTest 2.8–2.9 亿样本 0 命中

这张表的价值在于每一格都可反驳。有人说"可能是死锁",第一行证据里"两次转储帧不变且 CPU 100%“就能排除;有人说"volatile 只是心理安慰”,第五列给出对照实验的复现率。

happens-before 边速查

归因时只需要问一句:写方和读方之间,有没有一条已建立的 HB 边? Java 里常用的边只有下面几条:

边建立方式覆盖范围常见误用
程序顺序同一线程内按代码顺序仅本线程可见以为跨线程也成立
监视器同一把锁的 unlock → 后续 lock临界区内的所有写两把不同的锁
volatile写 → 后续读同一字段写之前的所有写读的是另一个字段
线程启动Thread.start() 之前 → 新线程内启动前的所有写线程池复用,没有 start 边界
线程终止线程内所有动作 → join() 返回后该线程的全部写用 isAlive() 轮询代替 join
final 冻结构造器结束(this 未逸出)final 字段及可达对象构造器里发布 this
中断/interrupt不建立任意数据的 HB只传递中断状态指望它同步业务字段

最容易出错的是后两条:

  • final 的边是有条件的。9.2 的 1, 0, 500 就是反证:this 逸出后,final 字段照样读到 0;
  • 线程池没有 start 边。任务提交到 execute() 之前的写,能不能被工作线程看到,取决于队列的同步语义(BlockingQueue 内部有锁或 CAS),不是"提交就算发布"。这也是为什么把配置对象丢进线程池前,仍要保证它已构造完成。

证据链:现场、复现、理论三级

一条可信的归因至少要凑齐两级,最好三级都有:

级别内容本机可用的手段
现场证据事故当时的进程状态线程转储、jcmd、JFR 录制、GC 日志
复现证据主动构造后的命中率独立 JVM 多次运行、JCStress 样本表
理论证据JMM 规则或 JDK 源码JLS 第 17 章、PrintCompilation、JDK 源码

现场证据最容易拿到,也最容易被过度解读。单次线程转储只能说明"这一瞬间在干什么",说明不了"一直这样"。所以自旋类问题至少要两次转储 + CPU 时间对比,或者直接用代码自动判定:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.HashMap;
import java.util.Map;

public class StuckLoopDetector {

    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (!StopFlag.stop) { }      // 非 volatile 标志
        }, "worker-loop");
        worker.setDaemon(true);
        worker.start();
        Thread.sleep(1500);
        StopFlag.stop = true;               // 走到这里也停不下来
        new StuckLoopDetector().report(worker.threadId());
        System.exit(0);
    }

    static final class StopFlag {
        static boolean stop;
    }

    void report(long targetId) throws InterruptedException {
        ThreadMXBean mx = ManagementFactory.getThreadMXBean();
        Map<Long, Long> cpu0 = snapshotCpu(mx);
        ThreadInfo before = find(mx, targetId);
        long wall0 = System.nanoTime();
        Thread.sleep(500);
        long wall1 = System.nanoTime();
        ThreadInfo after = find(mx, targetId);
        Map<Long, Long> cpu1 = snapshotCpu(mx);
        if (before == null || after == null) {
            System.out.println("thread gone");
            return;
        }
        String f0 = topFrame(before);
        String f1 = topFrame(after);
        long cpuDeltaMs = (cpu1.getOrDefault(targetId, 0L)
                - cpu0.getOrDefault(targetId, 0L)) / 1_000_000;
        long wallDeltaMs = (wall1 - wall0) / 1_000_000;
        String verdict = (f0.equals(f1)
                && after.getThreadState() == Thread.State.RUNNABLE
                && cpuDeltaMs * 100 / Math.max(wallDeltaMs, 1) >= 80)
                ? "SPINNING" : "NOT_SPINNING";
        System.out.printf("thread=%s state=%s%n", after.getThreadName(), after.getThreadState());
        System.out.printf("frame_1=%s%nframe_2=%s%n", f0, f1);
        System.out.printf("cpuDeltaMs=%d wallDeltaMs=%d%n", cpuDeltaMs, wallDeltaMs);
        System.out.println("verdict=" + verdict);
    }

    Map<Long, Long> snapshotCpu(ThreadMXBean mx) {
        Map<Long, Long> m = new HashMap<>();
        for (long id : mx.getAllThreadIds()) {
            long t = mx.getThreadCpuTime(id);
            if (t >= 0) {
                m.put(id, t);
            }
        }
        return m;
    }

    ThreadInfo find(ThreadMXBean mx, long id) {
        ThreadInfo[] infos = mx.getThreadInfo(new long[]{id}, Integer.MAX_VALUE);
        return infos.length > 0 ? infos[0] : null;
    }

    String topFrame(ThreadInfo info) {
        StackTraceElement[] st = info.getStackTrace();
        return st.length == 0 ? "<none>" : st[0].toString();
    }
}

本机输出(Temurin 25.0.5+8,Apple M1 Pro):

thread=worker-loop state=RUNNABLE
frame_1=app//StuckLoopDetector.lambda$main$0(StuckLoopDetector.java:11)
frame_2=app//StuckLoopDetector.lambda$main$0(StuckLoopDetector.java:11)
cpuDeltaMs=507 wallDeltaMs=510
verdict=SPINNING

这段代码把"凭经验看转储"变成了三个可判定条件:帧不变、状态 RUNNABLE、采样窗口内 CPU 占比 ≥80%。三条同时成立才报 SPINNING,避免把"正在做长计算"误判成自旋。它不是万能的:一次 500ms 的窗口抓不到间歇性自旋,需要连续采样多次。

复现证据的关键是样本数,不是"跑没跑出来"。9.2 里非 final 发布的命中率是 0.0013%,跑 1 万次大概率一次都碰不上;要说出"这个窗口存在",得跑到几亿个样本。反过来,final 与 volatile 发布在 2.8–2.9 亿样本里 0 命中,这个"0"才有意义——它带了分母。只写"测试通过"的报告,读者无法判断分母是 3 还是 3 亿。

可复用的归因模板

事故编号:
发现时间 / 影响面:
现象(可观测事实,含次数与时间):
时间线:
  T0 触发动作:
  T1 异常表现:
  T2 处置动作:
现场证据(转储 / JFR / 日志 / 指标):
复现实验(代码、命令、样本数、命中率):
候选假设与排除过程:
  H1 ... 排除依据 ...
  H2 ... 保留 ...
违规的 happens-before 规则:
根因(一句话,能对应到规则或源码):
修复(补了哪条边,为什么足够):
验证(修复前后同一实验的对比数据):
回归(测试名、CI 挂载点、失败判据):
遗留风险:

填 9.1 的示例:

事故编号:CONC-2026-091
现象:停机标志置位后进程不退出,线程 100% CPU,30/30 次独立运行复现
现场证据:两次 jcmd Thread.print 同帧同状态;JFR 458 个 ExecutionSample 同帧
复现实验:独立 JVM 30 次 30 次卡死;-Xint 0/10;C1-only 0/10;C2-only 10/10
违规规则:普通字段写读之间无 HB,C2 允许把读提升出循环
根因:while (!stop) 中 stop 非 volatile,OSR 后循环条件成为常量
修复:stop 改 volatile,写读构成 release/acquire
验证:同命令 30 次全部退出;JFR 中不再出现该帧持续采样
回归:VisibilityRegression#sweeperStopsWithinTimeout,超时 2s 判失败
遗留风险:其它轮询标志需全量排查

模板里有两格最常被跳过,也最该保留:候选假设与排除过程、遗留风险。前者防止"第一个想到的解释"直接变成结论,后者防止修复被当成"这一类问题都解决了"。

修复与验证:回归测试抓不到什么

修复的标准是"补上那条缺失的 HB 边",不是"加个关键字"。9.1 补的是 volatile 写读边,9.2 补的是"构造完成后才发布"的边(发布字段 volatile,或对象全部 final 且不逸出)。

验证要拿同一个实验在修复前后各跑一遍。下面这个回归脚本把两节的不变量固定下来:

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

public final class VisibilityRegression {

    static int failures;

    public static void main(String[] args) throws Exception {
        sweeperStopsWithinTimeout();
        publicationHasNoTornState();
        System.out.println(failures == 0 ? "PASS" : "FAIL " + failures);
        System.exit(failures == 0 ? 0 : 1);
    }

    static void sweeperStopsWithinTimeout() throws Exception {
        final class Sweeper implements Runnable {
            volatile boolean running = true;

            @Override
            public void run() {
                while (running) {
                    Thread.onSpinWait();
                }
            }

            void shutdown() {
                running = false;
            }
        }
        Sweeper sweeper = new Sweeper();
        Thread t = new Thread(sweeper, "sweeper");
        t.start();
        Thread.sleep(200);
        sweeper.shutdown();
        t.join(2000);
        if (t.isAlive()) {
            failures++;
            System.out.println("FAIL sweeper did not stop");
            t.interrupt();
        }
    }

    static void publicationHasNoTornState() throws Exception {
        final class Config {
            final int timeoutMs;

            Config(int timeoutMs) {
                this.timeoutMs = timeoutMs;
            }
        }
        AtomicReference<Config> current = new AtomicReference<>();
        AtomicInteger bad = new AtomicInteger();
        AtomicInteger reads = new AtomicInteger();

        Thread reader = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                Config c = current.get();
                if (c != null && c.timeoutMs != 42) {
                    bad.incrementAndGet();
                }
                reads.incrementAndGet();
            }
        }, "reader");
        reader.setDaemon(true);
        reader.start();
        for (int i = 0; i < 1_000_000; i++) {
            current.set(new Config(42));
        }
        Thread.sleep(50);
        reader.interrupt();
        reader.join();
        if (bad.get() != 0) {
            failures++;
            System.out.println("FAIL bad reads = " + bad.get());
        }
        System.out.println("publishRounds=1000000 reads=" + reads.get()
                + " bad=" + bad.get());
    }
}

修复版本机输出:

publishRounds=1000000 reads=9351290 bad=0
PASS

reads 每次运行在 830 万到 940 万之间波动,bad 恒为 0。

但必须说清这个测试抓不到什么。 把 AtomicReference 换回非 volatile 的普通字段(也就是 9.2 的反例),同一个读取循环跑 3 次:

reads=7497925 bad=0
reads=7368947 bad=0
reads=7547791 bad=0

2200 多万次读取,一次都没抓到。原因不难理解:普通字段发布的窗口只有几条指令,靠"读—判断"的循环去撞它,命中率远低于 JCStress 的 actor 对齐采样。所以结论是:

  • 停机那一半能守住结构性退化:把 volatile 去掉后,按 9.1 的 30 次独立运行法 30/30 卡死,join(2000) 必然超时;
  • 发布那一半在本机 2200 多万次读取里 0 命中,抓不到"把 volatile 换回普通字段"这种退化;
  • 所以它不能替代 JCStress。可见性类 bug 的门禁必须是 JCStress(带样本数和命中率),JUnit 只负责"接口没变、停机没退化、编译通过"。

这也解释了一个常见困惑:“修复前跑回归测试也是绿的”。不是测试写错了,是测试的能力边界就在这里。

反模式:四种把归因做废的写法

反模式为什么错替代做法
没复现就写"无此问题"0 命中可能是分母太小给出样本数与多轮结果
加 sleep / 加日志后现象消失就结案副作用改变了优化前提,根因仍在找到缺失的 HB 边
全量加 volatile掩盖问题,且引入写屏障开销只给需要跨线程可见的字段加
把 RUNNABLE 当成"线程正常"自旋也是 RUNNABLE结合 CPU 占比与帧是否变化

还有一个不写在表里但很常见的:把修复当验证。“加了 volatile 之后没再出现"不是验证,只是"这段时间没观测到”。验证必须是同一个实验、可重复、有对照。

在 libraryd-conc 里落地

conc-ops 的事故目录按模板存档,一个事故一个文件,文件名 CONC-YYYY-NNN.md;conc-lab 里放对应的复现器(JCStress 测试或独立 main),CI 分两层:

  1. 每次提交跑 VisibilityRegression,超时与不变量,分钟级;
  2. 每晚跑 JCStress -m default,记录每个用例的样本数与 FORBIDDEN 命中数,命中即失败。

评审时的检查顺序也固定下来:先看归因表的第二列(违规的是哪条 HB 规则)是否具体,再看第三列(证据)有没有分母,最后看第五列(验证)是不是同一实验的前后对比。三列有一列含糊,就退回重做。

常见错误与边界

错误后果正确做法
用单次线程转储下结论把瞬时状态当持续状态两次以上采样 + CPU 差
只贴 JCStress 的红绿读者无法判断采样强度贴结果表(结果 × 样本 × 频率)
复现率写成"偶发"无法比较修复前后写次数与分母
忽略编译级别C1 下正常、C2 下出问题记录 -Xint/C1/C2 对照
把资源问题当内存模型问题方向错,越查越远先排除 OOM、GC、CPU 配额

什么时候不要做 happens-before 归因:当现象能由资源耗尽解释时。队列堆积导致 OOM、线程数超过容器限制、GC 停顿导致超时——这些问题也会表现为"线程不动了"“请求超时”,但它们与内存模型无关。判断方法很简单:如果去掉并发、单线程重放同样的负载也能复现,就不是 HB 问题。第 10 章会专门处理这类事故。

小结

归因的骨架是五列:现象、违规的 HB 规则、证据、修复、验证。证据要分三级(现场、复现、理论),复现要带分母,验证要用同一个实验做前后对比。

本机这组数据里,最有价值的一条不是"复现了",而是"回归测试抓不到":非 volatile 发布在 2200 多万次读取里 0 命中,而 JCStress 在 3 亿样本里稳定抓到 0.0013%。这说明可见性问题的门禁必须靠高采样工具,普通单元测试只能守住结构。把这条写进验收标准,比多背几条 JMM 规则有用。

下一章离开内存模型,进入资源类事故:无界队列如何把一次下游变慢放大成堆内存耗尽。

阅读导航:上一节 9.2 安全发布失败的对象 | 下一节 10.1 队列堆积与 OOM

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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