jcmd、jstack 与 jmap JVM 现场诊断手册
先保住现场,再追根因
凌晨告警里最危险的组合不是“接口超时、CPU 不高”,而是值班同学看见堆用了 80%,立刻对唯一实例执行 heap dump。此时 JVM 可能为了生成 dump 请求一次 Full GC,并在遍历对象图、写文件期间继续承受请求;容器临时盘被写满后,原来的慢请求会升级成驱逐或重启,证据也只剩半个 HPROF 文件。
更稳的调查从三个问题开始:命令打向哪个 JVM,当前现象更像 CPU、锁、下游等待还是内存增长,下一份证据会让目标停顿多久。jcmd 是首选控制入口,jstack 和 jmap主要用于兼容旧脚本与旧输出格式。它们不是三个可以随意互换的“查看命令”:线程栈保存一个时刻的执行位置,类直方图按类聚合对象浅大小,heap dump才保存完整对象图。
先把工具和目标对上
这些命令来自完整 JDK 的 bin 目录。生产镜像可能只有 jlink 运行时,因此“应用能运行”不等于“容器里有 jcmd”。首次值守前应完成以下检查:
command -v java jcmd jstack jmap
readlink -f "$(command -v java)"
readlink -f "$(command -v jcmd)"
java -version
jcmd -hWindows 使用 where java、where jcmd 和 java -version。新流程应让客户端工具与目标 JVM 使用同一 JDK 版本,最好同一发行版;跨版本诊断不是受支持组合。JDK 8 或厂商定制构建的命令名、参数和 JFR 能力可能不同,先以目标 JVM 返回的帮助为准:
jcmd "$PID" help
jcmd "$PID" help Thread.print
jcmd "$PID" help GC.class_histogram
jcmd "$PID" help GC.heap_dumpJDK 21 及更新版本还可能提供适合大量虚拟线程的 Thread.dump_to_file。不要按文章记忆猜能力;命令没有出现在目标 help 中,就不能由脚本强行调用。
若运行镜像没有工具,有三种工程化入口:在同构诊断镜像中预置经校验的 JDK;让临时调试容器加入目标 PID 命名空间并使用匹配 UID;或在维护窗口使用平台提供的节点诊断能力。临时下载未知二进制、复制另一大版本的 jcmd、永久开启 hostPID 或 privileged,都会把一次排障变成新的供应链或越权问题。
attach 不是网络连接
jcmd 通过 HotSpot Attach 机制与同机 JVM 通信。成功至少需要:目标 PID 在当前 PID 命名空间可见,工具进程与目标具有相同有效 UID/GID,目标没有用 -XX:+DisableAttachMechanism 关闭 attach,临时目录和 attach socket 可访问,安全策略也没有阻断。root 身份不能自动消除用户命名空间、SELinux、AppArmor 或只读文件系统差异。
先用没有业务副作用的探针确认链路:
test "${PID:-}" -gt 0 2>/dev/null || { echo "拒绝空 PID、非数字 PID 或 PID 0" >&2; exit 2; }
jcmd "$PID" help
jcmd "$PID" VM.command_line绝不能把 jcmd 0 ... 放进采集脚本。PID 0 会把诊断命令发给所有可见 JVM;用主类名作为目标也可能命中多个同名实例。
容器里先分清三种 PID
同一个 Java 进程可能同时拥有 Kubernetes 业务视角的 Pod、容器内 PID 和宿主机 PID。传给 jcmd 的必须是 执行命令所在 PID 命名空间看到的 PID。jcmd -l不会跨独立 Docker 进程列出目标 JVM,因此在容器外看不到并不等于 JVM 不存在。
在目标容器内先检查:
id
ps -eo pid,user,args
cat /proc/1/status | sed -n '1,12p'
cat /proc/1/cgroup若 Java 是容器主进程,容器内 PID 通常是 1,但 sidecar、多进程镜像和共享进程命名空间都可能改变这一点。kubectl exec 时要明确 -c <container>;临时调试容器还必须真正共享目标进程命名空间,仅共享网络没有用。宿主机 docker inspect 得到的 PID 只能交给宿主机命名空间里的兼容工具,不能原样复制到容器内。
诊断前同时记录 cgroup 约束。cgroup v2 常用入口如下,文件不存在时再按平台基线查询 v1 路径:
cat /sys/fs/cgroup/memory.current 2>/dev/null || true
cat /sys/fs/cgroup/memory.max 2>/dev/null || true
cat /sys/fs/cgroup/memory.events 2>/dev/null || true
cat /sys/fs/cgroup/cpu.max 2>/dev/null || true
cat /sys/fs/cgroup/cpu.stat 2>/dev/null || truememory.events 中 oom_kill 增长、cpu.stat 中 throttling 增长,分别提示容器内存和 CPU 配额问题。它们不能被 Java heap 百分比或线程状态替代。
每条命令都在购买一份证据
先取低影响上下文,再决定是否读取堆概况
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flagsVM.command_line 和 VM.flags用于确认真正生效的启动入口与 JVM 参数。不要默认执行 VM.system_properties:系统属性经常含数据库地址、代理配置、临时凭证或内部路径。Native Memory Tracking 也只有在 JVM 启动时已启用时才有可用基线,且它统计的是 HotSpot 跟踪范围,不是“所有 native 内存”。
GC.heap_info给出当前堆概况,但目标 JDK 的命令帮助将它标为 Medium impact。只有线程栈、GC 指标或容器内存证据确实指向堆问题,并且当前实例有扰动余量时,才单独执行并观察延迟、GC pause 与 CPU:
jcmd "$PID" help GC.heap_info
HEAP_INFO_DIR="$(mktemp -d "${TMPDIR:-/tmp}/jvm-heap-info-${PID}-XXXXXXXX")"
chmod 700 "$HEAP_INFO_DIR"
jcmd "$PID" GC.heap_info > "$HEAP_INFO_DIR/heap-info-$(date -u +%Y%m%dT%H%M%SZ).txt"
printf 'evidence_dir=%s\n' "$HEAP_INFO_DIR"该目录可能暴露堆布局和容量信息,完成关联分析后要按事件保留策略清理,不能留在共享临时目录等待系统自行回收。
线程栈要连续采,不要只截一帧
Thread.print需要 VM 协调线程并遍历栈,目标线程越多,输出和停顿风险越高。它通常比 heap 遍历温和,但绝不是零开销。生产上先采 3 份,只有状态持续变化且扰动可接受时再增加:
OUT="$(mktemp -d "${TMPDIR:-/tmp}/jvm-thread-${PID}-XXXXXXXX")"
chmod 700 "$OUT"
for i in 1 2 3; do
ts="$(date -u +%Y%m%dT%H%M%SZ)"
jcmd "$PID" Thread.print -l > "$OUT/thread-$i-$ts.txt" || break
sleep 5
done
printf 'evidence_dir=%s\n' "$OUT"-l增加 java.util.concurrent 锁信息,也会放大输出。RUNNABLE只表示线程可运行或位于 native 调用,不能直接等同于烧 CPU;WAITING可能只是空闲线程池;持续 BLOCKED 在同一 monitor、持锁线程始终不动,才构成稳定锁证据。CPU 高时还要把操作系统 TID 转成十六进制,与栈里的 nid=0x... 对齐。
JDK 提供 jstack -l "$PID" 作为历史兼容入口。新自动化优先 jcmd Thread.print,因为目标 help会同时给出命令能力与影响等级。大量虚拟线程场景下,普通 Thread.print在新 JDK 中只显示平台线程和已挂载虚拟线程,应优先检查 Thread.dump_to_file 是否可用,并把结构化文件写入唯一的新路径。
Safepoint 为什么会放大事故
HotSpot 的许多诊断操作需要在线程处于可安全检查运行时状态时读取一致数据。线程到达 Safepoint 的时间、VM Operation 执行时间和恢复调度时间共同构成应用可见暂停;JNI 临界区、线程数量、对象数量和当前 GC 压力都可能放大它。于是“CPU 不高”并不能证明命令安全,CPU 受 cgroup 节流时,诊断线程反而更难及时完成。
打开统一日志的 JVM可以用 -Xlog:safepoint或更细的安全点日志回看停顿,但不要为了事故临时重启唯一实例。没有 Safepoint 日志时,把命令开始/结束时间与 P99、GC pause、容器 throttling 对齐;只要采集窗口出现同步延迟尖峰,就应停止升级。
histogram 是高影响聚合,不是免费计数器
jcmd "$PID" GC.class_histogram > "$OUT/histogram-$(date -u +%Y%m%dT%H%M%SZ).txt"目标 JDK 把 GC.class_histogram标为高影响,成本取决于堆大小和内容。默认统计可达对象;新 JDK 的 -all会把不可达对象也纳入,-parallel改变并行遍历线程数。更多线程可能缩短墙钟时间,也可能与业务争夺受限 CPU,不能把默认值机械改成核数。
直方图中的字节数是该类实例的浅大小总和,不含它们保留的整个对象图。类名排名高不等于泄漏;真正有价值的是同负载、相近 GC 阶段下的趋势:某类实例数和字节持续增长,GC 后也不回落,并且与缓存、批次或会话生命周期不符。
heap dump 是最后一级动作
当前 jcmd语法把文件名作为位置参数,而不是 filename=...:
ts="$(date -u +%Y%m%dT%H%M%SZ)"
DUMP_HELP="$(jcmd "$PID" help GC.heap_dump)"
if grep -q -- '-gz' <<<"$DUMP_HELP"; then
DUMP="$OUT/heap-$PID-$ts.hprof.gz"
DUMP_ARGS=(-gz=1)
else
DUMP="$OUT/heap-$PID-$ts.hprof"
DUMP_ARGS=()
fi
test ! -e "$DUMP" || { echo "目标文件已存在" >&2; exit 3; }
jcmd "$PID" GC.heap_dump "${DUMP_ARGS[@]}" "$DUMP"默认 heap dump只保留可达对象,并请求 Full GC;-all可避免这次 Full GC,但会把不可达对象也写入,文件更大、分析噪声更多,遍历与写盘成本仍然存在。JDK 17 的 GC.heap_dump没有 -gz,因此兼容路径直接生成 .hprof;目标 JVM 的 help明确列出 -gz时才启用压缩。压缩减少落盘体积,却会增加 CPU 消耗;较新版本提供的 -parallel可能缩短遍历时间,也会争用 CPU。每个字段都在停顿、CPU、磁盘和证据质量之间交换成本。
执行前必须确认:实例已摘流或有足够冗余;可用空间能容纳“最大堆 + 临时增长 + 文件系统安全余量”;目录不是容器易失层或应用日志分区;目标文件不存在;操作人能观察延迟、GC、磁盘与 cgroup 事件;安全负责人已批准数据副本与保留期。
jmap -dump:format=b,file=<path> "$PID"和 jmap -histo "$PID"只保留给既有兼容流程。jmap在当前 JDK 中仍是实验性、非受支持工具。:live不是“在线安全模式”,它为了只保留存活对象可能触发 Full GC。
heap dump命令发出后没有可靠的客户端撤销语义。中断本地 jcmd不代表目标 JVM停止 VM Operation;删除正在写的文件还可能制造更多 I/O 和调查混乱。所谓回滚必须前置为摘流、唯一文件名、独立容量、观察人和停止升级条件,事后只能恢复流量、验证服务和销毁产物。
在小堆 JVM 上把证据链跑一遍
下面的实验只占用随机临时目录,并把测试 JVM限制为 128 MiB。它制造一个持续持锁线程、一个阻塞线程和一组有上限的存活对象,既能看到正向证据,也能看到“单份快照容易误判”的反例。
LAB="$(mktemp -d "${TMPDIR:-/tmp}/jcmd-lab-XXXXXXXX")"
chmod 700 "$LAB"
cat > "$LAB/DiagnosticLab.java" <<'JAVA'
import java.util.ArrayList;
import java.util.List;
public class DiagnosticLab {
private static final Object LOCK = new Object();
private static final List<byte[]> RETAINED = new ArrayList<>();
public static void main(String[] args) throws Exception {
Thread holder = new Thread(() -> {
synchronized (LOCK) {
try { Thread.sleep(60_000); } catch (InterruptedException ignored) { }
}
}, "lab-lock-holder");
Thread waiter = new Thread(() -> {
synchronized (LOCK) { System.out.println("lock acquired"); }
}, "lab-lock-waiter");
holder.start();
Thread.sleep(500);
waiter.start();
for (int i = 0; i < 40; i++) {
RETAINED.add(new byte[512 * 1024]);
Thread.sleep(250);
}
holder.join();
}
}
JAVA
javac -d "$LAB" "$LAB/DiagnosticLab.java"
java -Xms128m -Xmx128m -cp "$LAB" DiagnosticLab > "$LAB/app.log" 2>&1 &
PID=$!
printf 'lab=%s pid=%s\n' "$LAB" "$PID"先在前 10 秒连续取栈和直方图:
jcmd "$PID" help Thread.print
jcmd "$PID" Thread.print -l > "$LAB/thread-1.txt"
sleep 3
jcmd "$PID" Thread.print -l > "$LAB/thread-2.txt"
jcmd "$PID" GC.heap_info > "$LAB/heap-info.txt"
jcmd "$PID" GC.class_histogram > "$LAB/histogram.txt"
grep -nE 'lab-lock-(holder|waiter)|BLOCKED' "$LAB"/thread-*.txt
grep -E '\[B|DiagnosticLab' "$LAB/histogram.txt" | head预期能在两份栈中都找到 lab-lock-holder,并看到 lab-lock-waiter等待同一 monitor;直方图中 byte array 的实例和浅大小靠前。反例也在这里:只看一份栈,可能把 main的 TIMED_WAITING当根因;只看 [B排名,可能把有界实验对象误报为泄漏。重复栈、对象增长趋势和业务生命周期必须一起成立。
只有在这个受控小堆实验里,才练习 dump:
DUMP_HELP="$(jcmd "$PID" help GC.heap_dump)"
if grep -q -- '-gz' <<<"$DUMP_HELP"; then
DUMP="$LAB/lab-$PID-$(date -u +%Y%m%dT%H%M%SZ).hprof.gz"
DUMP_ARGS=(-gz=1)
else
DUMP="$LAB/lab-$PID-$(date -u +%Y%m%dT%H%M%SZ).hprof"
DUMP_ARGS=()
fi
test ! -e "$DUMP" || exit 3
time jcmd "$PID" GC.heap_dump "${DUMP_ARGS[@]}" "$DUMP"
test -s "$DUMP" && ls -lh "$DUMP"成功证据是命令退出码为 0、文件非空且大小稳定,不是“终端没有报错”。同时观察 time结果和应用日志,理解小堆实验的耗时绝不能外推到几十 GiB 的生产堆。
再验证一个安全失败:
if jcmd 999999 help > "$LAB/missing-pid.txt" 2>&1; then
echo "异常:不存在的 PID 不应成功" >&2
exit 4
fi
grep -Ei 'not found|no such|attach|process' "$LAB/missing-pid.txt" || true最后按持有关系清理,而不是模糊匹配进程名:
kill "$PID" 2>/dev/null || true
wait "$PID" 2>/dev/null || true
rm -rf -- "$LAB"这里的 LAB由 mktemp -d直接返回,并且没有重新拼接或通配扩展。生产证据不能照此立即删除,应由事件保留策略到期后按清单销毁。
把安全默认值接进项目
仓库里应保存采集逻辑和证据合同,不保存真实线程栈、JFR 或 dump。一个可审查的目录可以是:
performance/diagnostics/
collect-jvm-evidence.sh
evidence-manifest.example
runbook.md
retention-policy.md采集脚本默认只执行 VM.command_line和三次线程栈;标为中等影响的 GC.heap_info、histogram 与 heap dump都必须使用单独的显式开关,并在执行时观察服务扰动。下面的骨架解决最常见的误操作:
#!/usr/bin/env bash
set -euo pipefail
pid="${1:-}"
root="${2:-}"
[[ "$pid" =~ ^[1-9][0-9]*$ ]] || { echo "PID 必须是正整数" >&2; exit 2; }
[[ -n "$root" && -d "$root" ]] || { echo "第二参数必须是已存在的证据根目录" >&2; exit 2; }
out="$(mktemp -d "$root/jvm-${pid}-XXXXXXXX")"
chmod 700 "$out"
jcmd "$pid" help > "$out/help.txt"
jcmd "$pid" VM.command_line > "$out/command-line.txt"
for i in 1 2 3; do
jcmd "$pid" Thread.print -l > "$out/thread-$i.txt"
(( i == 3 )) || sleep 5
done
sha256sum "$out"/* > "$out/SHA256SUMS"
printf '%s\n' "$out"证据清单还要记录 UTC 开始/结束时间、集群与实例、应用版本、JDK 版本、容器 ID、cgroup 限额、命令、操作者、审批号、文件校验值、读取者和删除期限。脚本输出目录由调用者传入并在其下随机创建,避免固定目录覆盖上一场事故。
从输出反推机制
锁与下游等待
多份栈里,大量线程持续 BLOCKED在同一 monitor,且能找到长期不动的 owner,才支持锁竞争。大量线程停在连接池借用、Future.get、Socket 读取或队列获取,更像下游慢、池容量不足或背压缺失。此时 heap dump通常没有新增价值,应该把线程时间线与连接池、请求积压和 Trace对齐。
CPU 热点
先用 top -Hp "$PID"或 pidstat -t -p "$PID" 1 5找高 CPU 的操作系统线程,再用 printf '%x\n' "$TID"转换后匹配 nid。重复栈稳定落在同一业务调用链,且 CPU 样本同步升高,才支持 CPU 热点。若高 CPU 来自 GC、JIT 或 native 代码,应转向 GC 日志、JFR 或 async-profiler。
内存增长
两份 histogram 只有在负载和 GC 阶段可比时才有意义。对象数增长但随后按业务周期回落,可能只是批处理;浅大小不大但 retained heap 巨大的容器对象,只有 heap dump 的 dominator tree和 GC root路径能揭示。分析 dump时要区分 shallow heap、retained heap和引用所有者,不能按类名排名直接下泄漏结论。
attach 失败也是边界证据
AttachNotSupportedException、权限拒绝或超时应按顺序检查:命名空间中的 PID、有效 UID/GID、工具与目标 JDK、DisableAttachMechanism、临时目录、只读文件系统和安全策略。若 JVM已经严重卡顿,attach listener也可能无法及时响应。连续重试不会修复它,只会增加压力;此时回到预先存在的 JFR、GC 日志、指标和容器事件。
权限、敏感数据与长期成本
线程栈会暴露类名、请求路径、锁对象和线程本地上下文;命令行与系统属性可能带凭证;heap dump近似生产内存副本,可能包含 Token、Cookie、SQL、用户数据、密钥材料和内部拓扑。诊断权限不应混在普通日志只读角色中,dump访问要采用最小授权、加密存储、下载审计、隔离分析机和到期销毁。
远程传输使用受控对象存储或工单证据库,凭证由短期身份注入,不能写进命令历史、脚本或文件名。清理要覆盖容器卷、节点临时目录、对象存储、分析机、工单附件和自动备份;“删除原文件”不等于所有副本已销毁。
容量预算至少计算四项:应用剩余内存与 CPU、Safepoint 停顿预算、证据文件最大体积、证据保留与传输成本。一次 16 GiB 堆的 dump不是“16 GiB磁盘问题”这么简单,它还会占用临时空间、网络、分析机内存和安全审计成本。团队应把 dump次数、失败率、平均体积、保留天数和销毁完成率纳入治理指标。
生产操作卡
执行人先确认告警窗口、应用版本、目标实例、JDK、容器 PID和 cgroup,再用 help验证 attach。连续线程栈足以解释问题时立即停止升级;只有对象趋势证据成立,才在摘流实例上做 histogram;只有需要回答“谁保留了对象”且审批与容量都满足,才生成 heap dump。
操作过程中任一条件出现就停止升级:P99 或错误率继续恶化、GC pause显著增加、CPU throttling上升、证据分区逼近告警线、attach超时、命令能力与预期不符。操作结束后验证流量恢复、延迟、错误率、GC、磁盘、cgroup事件和实例重启数,并登记所有证据副本的删除期限。
长期值守至少每季度演练一次小堆 dump与销毁链路,每次 JDK或基础镜像升级都重跑 help、权限和容器 PID验证。架构评审应提前决定哪些服务允许 attach、哪些服务只保留持续 JFR、哪些高敏系统禁止导出 dump。事故中临时放宽全局权限,通常比少一份证据更危险。
继续核对目标版本时,可查阅 JDK jcmd 命令参考、JDK 诊断工具指南、jstack 命令参考和jmap 命令参考。
