承接的问题
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/acquire | 30 次独立 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 分两层:
- 每次提交跑
VisibilityRegression,超时与不变量,分钟级; - 每晚跑 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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。