并发诊断:线程、任务与等待关系
线程状态描述采样瞬间的位置,任务完成量描述工作是否推进,队列与下游指标描述资源是否跟得上。诊断需要把这三类观察对应起来:WAITING 可以是空闲,也可以是无从完成的依赖;RUNNABLE 可以持续计算,也可能消耗 CPU 却没有提交业务结果。
| 观察方向 | 要区分的现象 |
|---|---|
| 等待关系 | 锁环、条件缺失、父任务等待同池子任务 |
| 进度变化 | 持续完成、反复重试、少数任务长期饥饿 |
| 容量变化 | 排队增长、拒绝、下游连接等待、CPU 饱和 |
| 数据与上下文 | 正确值、旧请求遗留、结果结束后后台仍执行 |
建立可以采集的活跃目标
环境、身份与输出目录
使用 Linux、完整 JDK 17 或 25、Bash 和 coreutils;CPU 线程观察还需 procps 的 ps/top。运行和诊断使用相同普通用户、相同 PID 命名空间。Docker 内目标优先在同一容器中采集,避免宿主 PID、容器 PID 与 /tmp Attach 文件不一致。
安装入口见Java 版本基线。下载并发诊断实验包,解压进入 concurrency-diagnostics 目录,设置实际 JAVA_HOME:
export JAVA_HOME=/opt/jdk-25
unset JAVA_TOOL_OPTIONS JDK_JAVA_OPTIONS _JAVA_OPTIONS
umask 077
LAB_OUT=$(mktemp -d /tmp/concurrency-diagnostic.XXXXXX)
"$JAVA_HOME/bin/java" -version
"$JAVA_HOME/bin/javac" --release 17 -Xlint:all -Werror \
-d "$LAB_OUT/classes" DiagnosticLab.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/classes" DiagnosticLab 90 \
> "$LAB_OUT/target.log" 2>&1 &
PID=$!
for attempt in $(seq 1 100); do
grep -q 'deadlockedThreads=2' "$LAB_OUT/target.log" && break
sleep 0.1
done
cat "$LAB_OUT/target.log"
kill -0 "$PID"DiagnosticLab 在独立进程中建立两个 monitor 的死锁、两个 worker 的资源饥饿,同时运行 CPU 计算和周期 park 线程。预期首行形态:
pid=<本次PID> deadlockedThreads=2 queued=2 childrenRemaining=2随后每秒输出 tick 与 progress。90 表示目标保留约 90 秒,允许 1—180 秒;只用于实验,不加载进业务服务。故意死锁的两个线程是 daemon,无法通过 interrupt 解开 monitor 环,最终随实验进程结束回收。其他工作线程有清理路径。
如果首行没有出现,先查看 target.log 与进程是否存活,检查编译、内存及资源限制;不要对旧 PID 继续执行后续命令。诊断文件可能包含类名、环境和业务上下文,应限制读取权限和留存,生产采集前还要检查磁盘空间及影响。
连续快照与进度对照
在目标仍存活的同一终端执行:
for sample in 1 2 3; do
kill -0 "$PID" || break
"$JAVA_HOME/bin/jcmd" "$PID" Thread.print -l > "$LAB_OUT/threads-$sample.txt"
sleep 1
done
grep -n -E 'deadlock-left|deadlock-right|starved-parent|cpu-progress|park-pulse' \
"$LAB_OUT/threads-1.txt"
tail -n 5 "$LAB_OUT/target.log"应得到三份非空线程转储。实验用 1 秒间隔保留固定状态;生产间隔应匹配故障持续时间与采集成本,不能照搬为通用阈值。jcmd 官方文档列出命令和 Attach 要求。使用 -XX:+DisableAttachMechanism 的目标无法按该方式附加;先按环境允许的诊断入口处理,不擅自修改生产启动参数。
连续相同栈只能提示相同位置经常被采到,不能单独证明任务没有推进。实验中的 cpu-progress 会多次出现相同计算帧,但 progress 持续增加;需要同时检查任务标识、计数或状态版本。原始文件保留完整上下文,不只保存 grep 命中的几行。
还原锁环与资源自依赖
monitor 死锁中的对象匹配
打开 threads-1.txt 的死锁报告:
grep -n -A80 'Found one Java-level deadlock' "$LAB_OUT/threads-1.txt"在普通线程段和报告中,deadlock-left 持有一个对象,等待另一个;deadlock-right 则反向持有和等待。用同一份转储中的 locked 与 waiting to lock 对象标识连接关系:
deadlock-left ──持有──→ left
│ ↑
等待 right 等待
↓ │
right ←──持有── deadlock-right对象标识只在该份快照内匹配;跨快照可能因对象移动而变化。AQS 独占锁则结合 parking to wait for 与拥有者的 Locked ownable synchronizers,操作示例见显式锁与 Condition。
实验还使用 ThreadMXBean.findDeadlockedThreads 检出两个平台线程。ThreadMXBean API明确其监控对象为平台线程,包含虚拟线程的环不在该方法的检测范围。自动检测也无法识别任意数据库锁、外部 RPC 或业务条件依赖。
修复 monitor 环要统一多资源获取顺序、减少嵌套持有,或在锁外完成远程工作并重新验证状态。内置锁入口不可中断,重启仅能解除当前进程中的僵局,不能修复导致下一次成环的顺序。
两个 worker 等待两个排队任务
实验池固定两个 worker、两个队列槽。两个父任务先占满 worker 并等待 children 闩锁,随后负责 countDown 的子任务进入同一池队列,因此输出 queued=2 childrenRemaining=2。
父任务占有 worker ──→ 等子任务 countDown
↑ │
└── 子任务等空闲 worker ─┘threads 文件中 starved-parent 停在 CountDownLatch.await / AQS park,池队列里的 Runnable 没有自己的运行线程栈。诊断必须把队列和执行槽加入等待图,只找 monitor 环会漏掉这种关系。
这个实验把“产生子任务”和“等待子任务”分开控制,以稳定保留相同资源依赖。实际代码常是父任务提交子任务后 Future.get,结果相同。修复可以改完成组合、直接执行适当的小工作,或在明确资源预算下分离执行器。增大池只提高再次饥饿的门槛,保留小池复验更容易确认依赖是否真正打断。
外部系统也可形成类似环:线程持 Java 锁等待数据库,另一事务持行锁又等待回调。JVM 只显示 SQL 调用位置,还需数据库等待与事务信息;没有 Java 死锁提示不能排除跨系统环。
CPU 高时区分有效计算与无效重试
将 Linux TID 对应到 nid
平台线程可以由 ps/top 找到 OS 线程。目标仍存活时执行:
ps -L -p "$PID" -o tid=,pcpu=,comm= | sort -k2,2nr
top -H -b -n 2 -d 1 -p "$PID" > "$LAB_OUT/top-threads.txt"
TID=$(ps -L -p "$PID" -o tid=,pcpu= | sort -k2,2nr | awk 'NR==1 {print $1}')
case "$TID" in
''|*[!0-9]*) printf '%s\n' '未取得有效线程号' ;;
*)
HEX_TID=$(printf '%x' "$TID")
"$JAVA_HOME/bin/jcmd" "$PID" Thread.print -l > "$LAB_OUT/cpu-threads.txt"
grep -n -A35 -E "nid=(0x0*$HEX_TID|$TID)([[:space:]]|$)" "$LAB_OUT/cpu-threads.txt"
;;
esac这里先列出线程再由 sort 排序,避免某些 procps 版本将 ps 的线程显示与内部排序组合时只输出进程级行。ps 的 %CPU 通常是进程生命周期口径,不等同于刚才一秒的瞬时占用;top 连续样本辅助判断当前热点。自动选出的第一项是候选,实验中通常对应 cpu-progress,但应根据实际输出核对。
Java 17 常见 nid=0x...,Java 25 的 Linux 输出可能使用十进制 nid。表达式同时匹配两种完整 token,并包含空白或行尾约束,避免线程号 12 误中 123。Java 线程 ID、tid 内部指针与 OS TID 也不同,不能互相替代。
目标或线程已退出、不同命名空间、刚好换了热点都会使匹配失效。先重新采样,不凭空拼一个线程号。CPU 多时刻采样还能区分一条持续计算线程与大量短任务轮流执行。
活锁、竞争与饥饿的差别
CAS 失败重试很多时,仍可能有其他线程持续提交。成功量也增长属于竞争成本,不应直接叫活锁。活锁强调参与者不断采取动作但没有完成目标;观察冲突次数、状态版本和业务完成数,才能区分。计数与失败量的实验见CAS 与原子类。
饥饿则是系统整体推进,某类任务长期没有机会。按租户、优先级和任务类别查看最老年龄及完成率,比全池平均值更有用。公平锁、优先级 aging 或分租户配额各解决不同偏置,需要保持相同负载复验,而非统一增加线程。
热点在序列化、正则或集合扫描时,应先确认有效输入与计算量。热点在重试、异常构建和日志格式化时,要检查错误放大。RUNNABLE 还可能处于 native 调用,不能只凭 Java 状态认定正在消耗 CPU。
JFR 要采完再解释事件
记录与解析使用不同工具
在目标仍有至少 20 秒剩余运行时间时,启动 10 秒 profile 记录:
kill -0 "$PID"
"$JAVA_HOME/bin/jcmd" "$PID" JFR.start name=concurrency \
settings=profile duration=10s filename="$LAB_OUT/concurrency.jfr"JFR.start 返回表示记录已安排,并非文件已完整写好。目标仍运行时,检查记录状态:
"$JAVA_HOME/bin/jcmd" "$PID" JFR.check name=concurrency等待该记录结束、文件存在且非空,再用离线工具解析:
test -s "$LAB_OUT/concurrency.jfr"
"$JAVA_HOME/bin/jfr" summary "$LAB_OUT/concurrency.jfr"
"$JAVA_HOME/bin/jfr" print --events jdk.ExecutionSample,jdk.ThreadPark \
"$LAB_OUT/concurrency.jfr" > "$LAB_OUT/events.txt"如果这里文件尚未生成,继续查记录状态,不使用空文件得出“没有事件”。若目标已退出,检查是否在写出前被终止、输出路径是否对目标 UID 可写。jcmd 中 filename 是目标进程视角的路径,跨容器采集尤其要确认挂载对应关系。工具定义见 jfr 命令文档。
记录完成后解析不依赖目标仍存活,但记录过程必须覆盖所需现场。完整实验 capture.sh 留出 35 秒目标寿命,采三份线程栈后再记录 10 秒,并在确认记录结束后运行 summary/print,最后等待目标自然退出。
事件与正在发生的等待
实验应在 summary 中看到非零 ExecutionSample 与 ThreadPark,events.txt 分别包含 cpu-progress 的采样和 park-pulse 的周期等待。事件数量受调度、采样和 JDK 影响,不使用固定数量作为跨机器标准。
| 事件 | 主要观察 |
|---|---|
| jdk.ExecutionSample | 被采到的 Java 执行栈分布 |
| jdk.ThreadPark | park 等待及调用栈 |
| jdk.JavaMonitorEnter | 进入 monitor 的竞争耗时 |
| jdk.JavaMonitorWait | Object.wait 等待 |
| jdk.SocketRead / SocketWrite | 网络读写耗时与调用位置 |
| jdk.CPULoad / ThreadCPULoad | CPU 使用变化 |
时长事件通常需要动作结束才形成完整记录,阈值也会过滤短等待。实验死锁始终未取得目标 monitor,因此 JavaMonitorEnter 可以为 0;这不会推翻线程转储中的死锁关系。事件没出现还可能因为设置未启用、现场不在记录窗口或等待尚未结束。
profile 比默认设置更积极,采集前需要了解开销与敏感字段。性能分析的事件选择见 Oracle JFR 排障指南。不应为了追求更多事件无条件开启所有高频记录。
虚拟线程使用结构化转储
传统平台线程计数无法表达大量虚拟任务,OS TID 也只对应当时载体,不能永久绑定某个虚拟线程。Java 25 实验提供真实 VirtualProbe;先等前一个手动目标结束或保留其独立 PID,再编译启动:
"$JAVA_HOME/bin/javac" --release 25 -Xlint:all -Werror \
-d "$LAB_OUT/classes" java25/VirtualProbe.java
"$JAVA_HOME/bin/java" -cp "$LAB_OUT/classes" VirtualProbe 30 \
> "$LAB_OUT/virtual.log" 2>&1 &
VIRTUAL_PID=$!
for attempt in $(seq 1 50); do
grep -q 'virtual-waiters=12' "$LAB_OUT/virtual.log" && break
sleep 0.1
done
cat "$LAB_OUT/virtual.log"
kill -0 "$VIRTUAL_PID"
"$JAVA_HOME/bin/jcmd" "$VIRTUAL_PID" Thread.dump_to_file \
-format=json "$LAB_OUT/virtual-threads.json"
grep -n 'diag-vt-' "$LAB_OUT/virtual-threads.json"
wait "$VIRTUAL_PID"
unset VIRTUAL_PID输出文件包含 diag-vt-0 至 diag-vt-11 的 12 个真实虚拟线程,它们等待同一闩锁,目标结束前统一释放并 join。不要把 JSON 名称匹配扩展成所有调度细节的证明,完整结构还要查看容器关系和栈。
诊断与调度说明见 Java 25 虚拟线程指南。Thread.dump_to_file 与 Thread.print 信息结构不同,不能要求 JSON 一定具有传统 monitor 拥有者段落。虚拟线程相关 JFR 事件是否启用、是否达到阈值也应按实际设置确认。
JDK 24 后 monitor 相关 pinning 行为已改变,不直接套用 Java 21 的旧判断。大量任务等待连接或许可时,优先观察下游并发上限和请求截止时间;载体正常调度也无法让已耗尽的数据库容量继续增长。
从积压与错误结果定位原因
任务推进却越来越慢时,把到达、接受、开始与完成分开统计。队列满后可能拒绝,CallerRuns 可能拖慢生产者;因此队列不增长也可能意味着压力已传到入口。
| 现象组合 | 候选原因 | 下一项核对与处理 |
|---|---|---|
| worker 满、队列年龄增长、下游变慢 | 外部等待占用执行资源 | 连接等待与超时;限入口、隔离慢依赖 |
| queue 长期增长、worker 留在 core | 无界队列阻止扩线程分支 | 队列配置;按预算设限并完成拒绝回执 |
| 新任务完成、旧任务长留 | 优先级或租户偏置 | 分类完成率;aging 或配额 |
| Future 已失败、调用仍占连接 | 结果超时未停止底层任务 | 客户端中止与预算传递 |
| 同 worker 出现其他租户 | ThreadLocal 未恢复 | 成对任务复现,正常/异常路径都恢复 |
| worker 在 getTask 等待、无积压 | 正常空闲 | 不把 park 数量当事故指标 |
上下文正反实验见异步上下文,队列和拒绝的可执行对照见线程池。涉及身份串扰时应先限制影响范围,保留必要诊断材料,避免把真实租户和令牌公开到日志或文章。
止损可以是限流、暂停后台生产、隔离异常实例或回退有明确关联的变更。长期修复应对应关系:锁环改顺序,同池依赖改任务结构,重试风暴加预算,容量过载限制接收。恢复后在相同负载与故障条件下比较完成率、尾延迟、最老年龄、拒绝和下游占用,不能只看告警是否消失。
复现程序用闩锁固定交错、为正常工作设置退出机制,为等待设置失败上限。Future.get(timeout) 只终止测试等待,不自动清理工作任务;需要 finally 关闭执行器、释放闩锁并确认线程退出。细粒度内存可见性问题还可使用 OpenJDK jcstress,但压力测试未观测到错误也不是规范层面的正确性证明。
完整采集与收尾
自动完成三份栈与一份记录
在已解压目录中创建空输出目录,再执行:
CAPTURE_DIR=$(mktemp -d /tmp/diagnostic-capture.XXXXXX)
export CAPTURE_DIR
bash capture.sh脚本按最终 ZIP 中的源码编译,启动 35 秒目标,保存三份 Thread.print、完整 10 秒 JFR、summary 和事件文本,断言存在 CPU 与 park 事件,最后输出:
PASS capture: three dumps, completed JFR, CPU/park events, target exited采集材料保留在 CAPTURE_DIR。若目录非空,脚本拒绝执行以避免覆盖。遇到 Attach、空间、事件缺失或目标提前退出会非零退出,读取 target.log、jfr-start.txt 与 jfr-check.txt 判断失败位置。自动脚本的等待不会替代人工理解事件。
bash run.sh 只执行短时目标结构检查;Java 25 可用 bash run.sh java25 加测虚拟目标。短检查不生成完整 JFR,不能当作采集验证。
Linux 主机有 Docker 权限时,短检查可以这样执行:
LAB_SOURCE="$(pwd -P)"
JDK_IMAGE=eclipse-temurin:25.0.4_7-jdk
docker pull "$JDK_IMAGE"
docker run --pull never --rm --network none --read-only \
--user 10001:10001 --cap-drop ALL --security-opt no-new-privileges \
--mount "type=bind,src=$LAB_SOURCE,dst=/lab,readonly" \
--tmpfs /tmp:rw,nosuid,nodev,size=192m,mode=1777 \
"$JDK_IMAGE" bash /lab/run.sh java25普通 UID 读取源码并在 tmpfs 编译,运行不联网。离线环境提前校验并导入可信镜像;Java 17 镜像 eclipse-temurin:17.0.20_8-jdk 去掉 java25 参数。若在容器内做完整采集,应另挂该 UID 可写的专用输出目录;仅写 tmpfs 且 --rm 退出会丢失诊断文件。
只回收本次实验
手动目标自然结束后 wait;不要拿其他会话中的旧 PID 发送信号:
wait "$PID"
unset PID确认已不需要采集文件后,只回收本次创建目录:
case "$LAB_OUT" in
/tmp/concurrency-diagnostic.*) rm -r -- "$LAB_OUT" ;;
*) printf '%s\n' '保留未知目录' ;;
esac
case "${CAPTURE_DIR:-}" in
/tmp/diagnostic-capture.*) rm -r -- "$CAPTURE_DIR" ;;
*) printf '%s\n' '保留其他采集目录' ;;
esac
unset LAB_OUT CAPTURE_DIR生产材料应按组织留存要求处理,不照搬实验删除。保留下来的结论需要指向原始快照、任务进度与等待资源,并明确哪些观察能推翻当前解释:例如队列里的子任务已经开始执行,就要重新检查“全部 worker 被父任务占住”是否仍成立。
权威资料与规范地址
按方法语义查 API,按实现字段查固定版本源码。
| 资料 | 完整地址 |
|---|---|
| jcmd 官方文档 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/jcmd.html |
| ThreadMXBean API | https://docs.oracle.com/en/java/javase/25/docs/api/java.management/java/lang/management/ThreadMXBean.html |
| jfr 命令文档 | https://docs.oracle.com/en/java/javase/25/docs/specs/man/jfr.html |
| Oracle JFR 排障指南 | https://docs.oracle.com/en/java/javase/25/troubleshoot/troubleshoot-performance-issues-using-jfr.html |
| Java 25 虚拟线程指南 | https://docs.oracle.com/en/java/javase/25/core/virtual-threads.html |
| OpenJDK jcstress | https://github.com/openjdk/jcstress |
