并发诊断:从线程 dump 还原死锁、活锁、饥饿与过载
接口不动了,CPU 却只有 15%;线程 dump 里大部分线程都是 WAITING。这三个现象不足以证明死锁。线程池空闲 worker 本来就在等队列,CompletableFuture 等结果也会 park,真正问题可能是所有入口线程都在等一个已经饥饿的子任务。并发诊断最忌讳把线程状态直接当根因,它需要把等待对象、持有者、队列、CPU、下游和时间线串起来。
并发现场先冻结时间线和影响,再连续抓取轻量证据;按“是否推进、CPU 是否做有效工作、等待能否形成环、容量是否已饱和”分流,最后用最小复现和同负载复验关闭结论。JVM 内存、GC 和类加载证据不能替代线程、任务与资源之间的等待关系。
先保护现场,不要一上来重启和改参数
记录告警开始时间、发布/配置变更、入口流量、错误率、P50/P99、下游耗时、线程池与连接池指标。确认 PID、JDK 版本、容器 CPU/内存限额和当前用户附加权限。然后获取三次线程栈:
jcmd "$PID" Thread.print -l > threads-1.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-2.txt
sleep 10
jcmd "$PID" Thread.print -l > threads-3.txt一次快照容易把瞬时等待误判成卡死。三次都停在同一业务栈、同一锁或同一下游,才有“没有推进”的证据。若服务已经接近雪崩,先限流、摘除异常实例或暂停低优先级消费者,再继续重取证;排障不能以失去整个服务为代价。
JFR 可以补 CPU、锁、线程 park、socket/文件 IO 和虚拟线程事件:
jcmd "$PID" JFR.start name=concurrency settings=profile duration=120s filename=concurrency.jfr先在预发验证采样配置和磁盘,生产文件要控制权限、留存和脱敏。
第一个分叉:系统有没有推进
“有没有推进”比线程状态更接近根因。死锁没有推进;活锁线程很忙却不完成;饥饿是系统整体推进但某些任务长期没有机会;过载则仍在完成,只是到达率长期大于处理率。
死锁:把等待边画成环
Java 级 monitor 死锁可能被线程 dump 直接识别:
grep -n -A100 "Found one Java-level deadlock" threads-1.txt没有自动提示也不能排除更广义的依赖环。对每个卡住线程记录:它持有什么锁/资源,正在等什么,等待对象由谁持有。然后画 wait-for graph:
thread-A holds monitor-X -> waits monitor-Y
thread-B holds monitor-Y -> waits monitor-XBLOCKED (on object monitor) 看 waiting to lock 与 locked <address>;AQS 等待看 ReentrantLock/Semaphore/ConditionObject 上层组件;Future.get 要追产生该 Future 的任务在哪个 executor。如果所有 worker 都等同池子任务,形成的是线程饥饿死锁,未必出现 monitor 环。
真实系统还可能跨 JVM 锁与数据库锁:线程 A 持 Java 锁等 SQL 行锁,线程 B 所在事务持行锁又通过回调等 Java 锁。JVM dump 只能看到 SQL 调用,数据库需同时取锁等待图和事务信息。修复要统一资源获取顺序、缩短持有范围、去掉锁内远程调用,或重新设计所有权。
同池依赖:没有 monitor 环也能完全停住
固定大小为 2 的线程池里,如果两个父任务都提交子任务并同步等待,而子任务仍进入同一个池,两个 worker 会被父任务占满,两个子任务则永远留在队列。这里没有任何两个 monitor 互相持有,findDeadlockedThreads 也未必报告问题,但 wait-for graph 已经闭合:父任务占有 worker → 等子任务完成;子任务要完成 → 等父任务释放 worker。
仓库的 PoolStarvationDemo 用两个门闩稳定保留这个现场:
cd examples/backend-development/concurrency/concurrency-diagnostics
javac --release 17 -Xlint:all -Werror PoolStarvationDemo.java
java PoolStarvationDemo
# queued=2, childrenRemaining=2输出同时证明执行槽已经被等待者占满、真正能推进条件的任务还在队列。此时线程 dump 会看到两个 worker 停在 CountDownLatch.await/AQS park,而不是 BLOCKED;只搜索 Found one Java-level deadlock 会得到错误的“没有死锁”结论。还原关系必须把 executor 队列作为资源节点加入图中。
修复不是简单把 2 改成 20,因为依赖深度或并发请求继续增长后仍会复现。可选策略包括让父任务不阻塞而采用完成组合、让子任务使用所有权独立且有界的执行器、在进入父任务前预留整条调用链所需许可,或直接使用同步调用避免伪异步。复验应保持原来的依赖结构和小容量,证明设计在最小容量下也能推进。
活锁:线程很忙,状态却提交不了
两个任务发现冲突后都礼让、重试,又以相同节奏再次冲突;或多个线程在 CAS 循环里持续失败。CPU 可以很高,线程状态多为 RUNNABLE,但业务完成数几乎不增长。
证据组合:
多次栈在同一重试/CAS/补偿循环附近变化。重试、冲突或 compare-and-set 失败计数快速增长。CPU 高,但成功吞吐和状态版本不增长。
日志出现高频相同“检测冲突—立即重试”。
修复通常是随机退避、重试上限、单一协调者、按 key 分片或改用阻塞等待。无上限“失败就立刻再试”会把局部竞争放大成 CPU 风暴。
饥饿:系统整体正常,某类任务一直轮不到
饥饿可能来自非公平锁持续被新线程抢占、优先级队列低优先任务积压、共享池被长任务占满、读写锁下写者迟迟无法进入,或租户热点挤占全部许可。
判断饥饿需要按任务类别和等待年龄看分布,平均队列长度没有用。记录最老任务等待时间、不同优先级/租户完成率、锁等待 P99 和池中任务类型。若队列一直有旧任务,新任务却持续完成,就是明显的调度偏置。
公平锁可能缓解部分锁饥饿,却牺牲吞吐;优先级队列可加入 aging;多租户资源可做配额与加权。不要用无限增大线程池掩盖资源分配问题。
队列堆积:看增长斜率和任务年龄
线程池 active 接近上限、队列持续增长、等待时间上升,说明到达率超过完成率。队列不增长也可能因为已经开始拒绝,或生产者被 caller-runs 拖慢。
现场至少对齐:
submitted rate
completed rate
active / pool size
queue size / capacity
oldest task age / wait P99
execution P99
rejected / cancelled
downstream latency / connection wait如果执行耗时随下游一起上升,先隔离、缩短超时、暂停低优先级流量;如果 CPU 满且执行热点在业务计算,限流并优化热点;如果队列很大但 active 没到最大,检查无界队列语义。扩线程前先确认连接池和下游是否承受得住。
CPU 飙高:把 OS 线程映射回 Java 栈
平台线程场景可先找热点线程:
top -Hp "$PID"
printf '%x\n' "$TID"
grep -n "nid=0x$HEX_TID" -A60 -B5 threads-1.txt一次映射还不够,连续取样并结合 JFR profile。常见形态:
业务无限循环、正则、序列化或集合扫描。CAS/重试循环高竞争。commonPool 中 CPU 计算或错误放入的阻塞任务。
大量 runnable 线程造成调度和上下文切换。日志异常风暴让格式化与 IO 成为热点。
Java RUNNABLE 也可能在 native 系统调用,需结合栈顶、IO 指标和 JFR。不要看到 runnable 数量多就盲目减线程,也不要看到 CPU 高就先加机器;先确定 CPU 在做有效业务还是无效重试。
上下文丢失与串扰要用成对请求证明
异步日志没有 traceId 可能只是没有传播;出现另一个请求的租户则是更严重的未清理。复现时让同一个单线程 executor 顺序执行两个任务:第一个设置上下文并故意不清理,第二个不设置直接读取。稳定复现后再加入任务包装器,验证复制、恢复和异常路径。
线上对齐入口 trace、异步线程名、任务提交点和日志 MDC。搜索裸 supplyAsync()、runAsync()、自建 executor、ThreadLocal set 没有 finally remove。修复要收口异步入口,不要给每个调用点手工打补丁。
上下文对象可能包含身份与租户,错误传播属于数据隔离事故。除日志校验外,还要补并发测试:两个租户高频交替提交,任何一次读到对方信息都失败。
虚拟线程现场不能只数平台线程
大量虚拟线程可能只映射到少量载体线程,传统 Thread.print 主要看到平台线程与已挂载虚拟线程。使用结构化转储观察大量虚拟线程:
jcmd "$PID" Thread.dump_to_file -format=json virtual-threads.json再结合 JFR 和下游池指标。虚拟线程接口超时时,重点看:
有多少虚拟线程在等数据库、HTTP、锁或许可。连接池等待是否成为新队列。CPU 密集任务是否占满载体。
native/外部调用是否形成版本相关阻塞。ThreadLocal/ScopedValue 使用是否造成内存或传播问题。
JDK 24 之后不要把所有 monitor 阻塞都归因于旧式 pinning;按实际 JDK 和调用栈取证。即便线程调度完全正常,下游容量耗尽仍会让十万虚拟线程一起等待。
最小复现要保留失败结构
事故修复不能只写单元测试验证结果值。并发复现至少包含:同时起跑、可控阻塞点、超时保护、失败断言和多轮运行。常用工具:
CountDownLatch 控制开始与完成。CyclicBarrier 放大相同时刻竞争。有界 executor 防止测试自身泄漏线程。
Future.get(timeout) 保证 CI 不永久挂起。jcstress 验证细粒度内存与原子算法。JFR/线程 dump 证明失败形态与线上一致。
测试结束必须关闭 executor、释放 latch/锁并清理 ThreadLocal。一个偶尔失败但没有记录调度和状态的测试,只会制造 flaky test,不能成为回归门禁。
把三次快照整理成可证伪的时间线
单份 dump 容易把正常瞬间当故障。更可靠的记录以线程或任务为行,以采样时刻为列:每次写线程状态、栈顶业务帧、正在等待的资源、已持有资源和关联队列年龄。若三次都在相同锁地址等待,而持有者三次都停在同一 SQL,便得到“下游等待延长了持锁时间”的候选解释;接着可用数据库会话和超时配置证伪。若持有者不断变化但等待分位数持续升高,则更像高频竞争,而不是单一线程卡死。
诊断结论必须能预测下一份证据。例如判断为同池饥饿,应预测 active 等于池上限、队列里存在被父任务等待的子任务、worker 栈都在 Future/latch 等待;只要下一份池快照显示有空闲 worker,这个解释就不成立。判断为 CAS 活锁,应预测重试计数增长速度远高于成功提交数;若成功吞吐也等比例增长,CPU 高可能只是有效负载。
最终事故记录至少保留:统一时钟下的请求与发布事件、三份原始线程快照、池和下游指标、等待关系图、最小复现命令、修复前后同一负载对照。只留下“重启后恢复”会丢掉所有可验证因果,下次事故仍只能从猜测开始。
止血、修复和复验是三件事
止血可能是入口限流、暂停消费者、隔离慢依赖、摘除异常实例或回滚发布;它不证明根因。长期修复可能是固定锁顺序、增加截止时间、拆线程池、限制重试、收口上下文或重构共享状态。
复验必须用相同负载和故障条件比较:成功吞吐、P99、队列等待、拒绝、锁等待、CPU、下游连接与错误率。只看到“没再报警”不够,流量可能已经下降,故障条件也可能消失。
复盘要留下可自动检查的门禁:禁止无界队列、禁止裸 commonPool、锁顺序规则、线程池指标模板、异步上下文测试、超时和取消测试。否则同类事故会换一个类名再次出现。
一张现场速查表
| 现象 | 关键证据 | 首要方向 |
|---|---|---|
| CPU 低、完全不推进 | wait-for graph、Future/锁持有者 | 死锁、缺通知、同池依赖 |
| CPU 高、完成数不增 | 重试/CAS 热点、冲突计数 | 活锁、重试风暴 |
| 部分任务长期等待 | 最老年龄、分类完成率 | 饥饿、公平与配额 |
| 队列和等待持续涨 | 提交/完成斜率、下游耗时 | 过载与隔离 |
| 日志上下文丢失/串扰 | 提交点、worker 复用、清理路径 | 统一传播与 finally 恢复 |
| 虚拟线程很多仍超时 | 结构化 dump、连接/许可等待 | 下游容量与截止时间 |
两个最小现场验证等待环与资源自依赖
完整源码位于 examples/backend-development/concurrency/concurrency-diagnostics/。ConcurrencyDiagnosticsDemo.java 建立两个 monitor 的等待环并通过 ThreadMXBean 检出;PoolStarvationDemo.java 建立父任务占满 worker、子任务留在队列的资源自依赖。进入目录执行:
mkdir -p out
javac --release 17 -Xlint:all -Werror -d out ConcurrencyDiagnosticsDemo.java PoolStarvationDemo.java
java -cp out ConcurrencyDiagnosticsDemo
java -cp out PoolStarvationDemo稳定输出为:
deadlockedThreads=2 names=deadlock-left,deadlock-right
queued=2, childrenRemaining=2第一行能被 JVM 的 monitor 死锁检测直接证明;第二行没有 monitor 环,却满足“worker 全被父任务占用、队列里的子任务是父任务继续推进的前提”。事故模板至少保存统一时间轴、三份原始快照、wait-for graph、池/下游指标、最小复现和同负载复验。结论还必须写出可证伪预测:例如同池饥饿应持续满足 active=上限、存在排队子任务、worker 均在等待;任一条件不成立就重新分类,不能把一次看似相似的栈永久贴成根因。
并发诊断的核心不是会背 jstack,而是把“谁拥有状态、谁在等、谁能推进、等待何时结束”还原成一张有证据的图。
诊断命令和状态定义可继续查看 JDK Troubleshooting Guide 与 Virtual Threads 调试章节。
