JVM 诊断工具与证据链:jstack、jcmd、JFR、NMT 与 Arthas
本页命令固定 JDK 17(源码
jdk-17.0.12+7),历史实验运行 Oracle JDK 17.0.12(macOS x86_64),Arthas 固定arthas-all-3.7.2。工具输出的语义、HotSpot 实现路径与本机实测分三层表述;每个工具都标注它能证明什么、不能证明什么。
先确认目标,再采集可关联证据
先记录 PID、启动时间、JDK 构建、启动参数、容器/主机身份和时区,防止将重启前后的相同 PID 或不同实例的输出拼在一起。jcmd 应在同一机器、合适用户权限与 PID namespace 下运行,不能把 attach 失败当作 JVM 已经死锁。精简镜像可能没有 JDK 工具,DisableAttachMechanism、权限、临时目录或 namespace 也会影响连接(JDK 17 jcmd 手册)。
不同故障需要不同的证据类型:CPU 高要区分用户态 Java、GC 工作、JIT 编译、内核态和虚拟机外进程;堆占用高要区分分配速率、存活集、晋升、缓存保留与泄漏;RSS 高要覆盖堆外、栈、代码缓存、映射文件和本地库;请求线程少但延迟高要检查锁、池队列、I/O 与容器配额。
以下 shell 变量应替换成实验进程实际 PID 和当前 JDK 路径。输出目录新建,避免覆盖历史证据:
lab_jdk=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
lab_pid=12345 # 替换成你刚启动的实验 JVM PID
lab_out=$(mktemp -d)
"$lab_jdk/bin/jcmd" -l
"$lab_jdk/bin/jcmd" "$lab_pid" VM.version
"$lab_jdk/bin/jcmd" "$lab_pid" VM.command_line
"$lab_jdk/bin/jcmd" "$lab_pid" VM.flags
"$lab_jdk/bin/jcmd" "$lab_pid" help
"$lab_jdk/bin/jcmd" "$lab_pid" help JFR.start目标 JVM 返回的 help 是这一进程的命令与选项依据,不能从别的 JDK 复制参数。记录 VM.command_line 时注意它可能包含业务配置;学习实验没有真实凭据。先做低成本的版本、flags、heap_info、线程和受限 JFR,是否采完整堆应由故障假设与采集预算决定。工具不存在单一“真相”,要用相互独立的证据交叉验证。
Thread.print:从状态读到等待图
jstack -l PID 与 jcmd PID Thread.print -l 可提供 Java 线程栈和锁相关信息。什么都不能只看单次快照——线程转储是瞬时快照,多个连续快照才能揭示线程是否持续同一栈、等待对象和状态变化。锁对象地址在一次转储中用于关联等待与持有,经过 GC 移动后不能把跨快照地址当永久业务 ID。
原码:Thread.print 并非只读取一份静态文本
原码连续摘录(仅统一公共缩进,截取方法整体):diagnosticCommand.cpp,jdk-17.0.12+7,L542–L554。
void ThreadDumpDCmd::execute(DCmdSource source, TRAPS) {
// thread stacks
VM_PrintThreads op1(output(), _locks.value(), _extended.value());
VMThread::execute(&op1);
// JNI global handles
VM_PrintJNI op2(output());
VMThread::execute(&op2);
// Deadlock detection
VM_FindDeadlocks op3(output());
VMThread::execute(&op3);
}这里顺序提交三个 VM 操作:打印线程栈、打印 JNI 全局句柄、执行死锁检测,全部经 VMThread 执行。这说明 Thread.print 需要 JVM 参与、不是读取外部文件,也说明 -l 参数进入了线程打印和死锁检查。死锁检查只覆盖它识别的等待关系(Java monitor / ownable synchronizer 环),不能保证发现线程池父任务等待子任务等业务资源环;一次转储也不是全时段执行录像。理解采集路径后再选择频率与现场保护。
读法要沿“等待谁 → 谁持有 → 持有者在做什么”。BLOCKED 通常表示竞争对象 monitor;WAITING 可能是 park、条件队列或 join;TIMED_WAITING 可能是 sleep、带超时等待。RUNNABLE 是 JVM 状态,不代表 OS 正在调度该线程,native socket read 也可能显示 RUNNABLE。正常空闲线程池停在 getTask/park,不是线程泄漏的充分证据。
连续快照的实测对照(blocked 模式,20 s 实验进程,两份快照间隔约 3 s):
# threads-2.txt(threads-1.txt 内容一致)
"lock-waiter" #15 ... nid=0x6403 waiting for monitor entry [0x00007000066ab000]
java.lang.Thread.State: BLOCKED (on object monitor)
at JvmSeniorLab.lambda$blocked$2(JvmSeniorLab.java:147)
- waiting to lock <0x00000007fde12778> (a java.lang.Object)
"lock-owner" #14 ... nid=0x6f03 waiting on condition [0x00007000065a8000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(java.base@17.0.12/Native Method)
at JvmSeniorLab.lambda$blocked$1(JvmSeniorLab.java:142)
- locked <0x00000007fde12778> (a java.lang.Object)两份快照中 waiter 持续 BLOCKED、owner 持续 TIMED_WAITING(sleeping),且 waiting to lock 与 locked 指向同一地址,才支持“owner 持锁 sleep、waiter 持续等待”的结论。单份快照只证明该瞬时的关系;若 A 等 B、B 等 A 才构成环,一条长等待链不自动等于死锁。
Linux 上 top -H -p PID 的线程号可转十六进制匹配 HotSpot nid=0x...。Java Thread.getId / Arthas Id、nid 和 OS TID 是不同标识,不能直接互换。容器还可能看到不同 namespace 的 TID,须先核对 /proc/.../status 的 NSpid 等信息,具体命令见 Linux/cgroup 单元。
jcmd 与 jmap:堆证据的分层采集
先获取整体,再决定是否取对象级证据:
"$lab_jdk/bin/jcmd" "$lab_pid" GC.heap_info
"$lab_jdk/bin/jcmd" "$lab_pid" VM.classloader_stats
"$lab_jdk/bin/jcmd" "$lab_pid" Compiler.codecache
# 下列是高影响采集;只在本地实验或已预留暂停/磁盘预算时执行。
"$lab_jdk/bin/jcmd" "$lab_pid" GC.class_histogram
"$lab_jdk/bin/jcmd" "$lab_pid" GC.heap_dump "$lab_out/heap.hprof"
# jmap 是替代入口,不必对同一目标重复执行两套全量采集。
"$lab_jdk/bin/jmap" -histo "$lab_pid"原码:直方图与 dump 默认都会请求一次 Full GC
原码连续摘录(仅统一公共缩进,截取方法整体):diagnosticCommand.cpp,jdk-17.0.12+7,L517–L530。
void ClassHistogramDCmd::execute(DCmdSource source, TRAPS) {
jlong num = _parallel_thread_num.value();
if (num < 0) {
output()->print_cr("Parallel thread number out of range (>=0): " JLONG_FORMAT, num);
return;
}
uint parallel_thread_num = num == 0
? MAX2<uint>(1, (uint)os::initial_active_processor_count() * 3 / 8)
: num;
VM_GC_HeapInspection heapop(output(),
!_all.value(), /* request full gc if false */
parallel_thread_num);
VMThread::execute(&heapop);
}原码连续摘录(仅统一公共缩进,截取方法整体):diagnosticCommand.cpp,jdk-17.0.12+7,L483–L500。
void HeapDumpDCmd::execute(DCmdSource source, TRAPS) {
jlong level = -1; // -1 means no compression.
if (_gzip.is_set()) {
level = _gzip.value();
if (level < 1 || level > 9) {
output()->print_cr("Compression level out of range (1-9): " JLONG_FORMAT, level);
return;
}
}
// Request a full GC before heap dump if _all is false
// This helps reduces the amount of unreachable objects in the dump
// and makes it easier to browse.
HeapDumper dumper(!_all.value() /* request GC if _all is false*/);
dumper.dump(_filename.value(), output(), (int) level, _overwrite.value());
}两处都把 !_all.value() 直接交给下一个环节:ClassHistogramDCmd 用它作为 VM_GC_HeapInspection 的“是否请求 full GC”参数,HeapDumpDCmd 用它构造 HeapDumper,注释写明了原因——先回收可减少 dump 中的不可达对象、便于浏览。也就是说默认命令可能先请求 GC;-all 包含不可达对象并避免该显式预先 GC 请求,但不等于不暂停、不扫描或没有开销,不能把 -all 称为“无损在线 dump”。直方图列通常为序号、实例数、总浅字节、类名;[B 是 byte 数组。一个大数组可能比几十万个业务对象占用更多,但直方图不能给出 retained size、对象之间的边或哪个 Map 在保留这些数组。两次比较还须统一是否包含不可达对象、是否触发回收、采样间隔和负载,避免把不同口径当增长。
heap 模式的实测(应用保留 32 MiB 数组,GC.heap_info 与 NMT 交叉):
应用输出 :retainedMiB=32 root=JvmSeniorLab.RETAINED
GC.heap_info:garbage-first heap total 98304K, used 71437K
region size 1024K, 6 young (6144K), 3 survivors (3072K)
NMT diff :Java Heap (reserved=98304KB, committed=98304KB) ← 无增量应用自报的是保留数组的 payload,GC.heap_info 的 used 是当时整个堆的占用,NMT Java Heap 则是堆地址空间的 reserved/committed;三者不是相等的统计量。NMT 包含 Java Heap 类别,这里 committed 无增量仅表示相对 baseline 没有增加堆提交量;已提交堆内继续分配对象仍可提高 used。拿到 dump 后先看类加载器与 dominator tree,再对最大 retained 子图找 path to GC roots,区分强路径与弱/软引用。支配树表示所有到该对象的路径都经过某节点;“最大数组”不一定是根因,真正应修的是让数组长期存活的 owner。dump 保存的是 Java 堆,不包含所有 native 内存内容。进程若被 cgroup 直接杀死,未必有 Java OOME,更不能保证自动 heap dump。
JFR:有边界的时间线,而非全量执行录像
JFR 记录启用事件的时间、线程、堆栈与特定字段,执行采样回答“采样点在执行哪里”,分配采样回答“哪些分配源值得关注”,锁/park/GC 事件回答对应启用条件下的等待或回收。事件有采样、阈值和缓冲设置,没有事件不能证明从未发生;短方法和稀有路径可能没被命中(JDK 17 jfr 手册)。
"$lab_jdk/bin/jcmd" "$lab_pid" JFR.start name=incident settings=profile \
duration=60s maxsize=128m "filename=$lab_out/incident.jfr"
"$lab_jdk/bin/jcmd" "$lab_pid" JFR.check
"$lab_jdk/bin/jcmd" "$lab_pid" JFR.dump name=incident "filename=$lab_out/snapshot.jfr"
# 需要提前结束时执行;若 duration 已结束,先 JFR.check 确认状态。
"$lab_jdk/bin/jcmd" "$lab_pid" JFR.stop name=incident
"$lab_jdk/bin/jfr" summary "$lab_out/snapshot.jfr"
"$lab_jdk/bin/jfr" print \
--events jdk.ExecutionSample,jdk.ObjectAllocationSample,jdk.JavaMonitorEnter,jdk.ThreadPark,jdk.GarbageCollection \
"$lab_out/snapshot.jfr"JFR.dump 生成当前录制的副本,不等于停止录制;JFR.stop 才结束指定录制。summary 的事件 Count 是记录数,不是请求数、分配对象总数或热点百分比。ExecutionSample 要按线程与栈聚合;ObjectAllocationSample 的 weight 等字段需按该事件定义解释,不可把事件数当总分配字节。JavaMonitorEnter 反映进入 monitor 的竞争等待,不等于该锁所有持有时间;ThreadPark 也可能是正常空闲。阈值越低、事件越多,观测成本越高,需在代表负载下验证 profile 设置。
JFR 的观测成本本身也有证据:cpu 模式实验的 NMT 差量里出现 Tracing (reserved=15540KB +15540KB),这是 JFR 录制缓冲带来的 native 记账;同轮 safepoint 日志中 Safepoint "JFROldObject" ... Reaching safepoint: 63345 ns, At safepoint: 10240375 ns, Total: 10303720 ns,说明 JFR 相关的停顿是标准 safepoint 流程的一部分,可以在 -Xlog:safepoint 中直接读到。从现象到推断的例子:应用 CPU 高,采样多在 cpu-burner 的计算栈,GC CPU/停顿低,连续线程快照也在同一热循环,才支持业务计算是主要消耗。
cpu 模式的实测(线程 CPU 差量 + 栈 + NMT):
threads-1.txt: "cpu-burner" #14 ... cpu=2576.13ms elapsed=4.12s ... runnable
threads-2.txt: "cpu-burner" #14 ... cpu=4594.65ms elapsed=6.91s ... runnable
栈(两份一致):at JvmSeniorLab.lambda$main$0(JvmSeniorLab.java:71)
NMT diff: Thread (reserved=23610KB +3080KB, committed=23610KB +3080KB)
Code (reserved=247920KB +180KB)
GC.heap_info: garbage-first heap total 67584K, used 21720Kcpu-burner 的 cpu 字段从 2576.13 ms 涨到 4594.65 ms(窗口约 2.8 s),同时栈位置不变、堆使用稳定,才支持“计算线程持续占 CPU”的结论;只看某一份快照显示 RUNNABLE 不足以支撑它。NMT 中 Thread 的 +3080KB 是新增线程的栈预留(stack 子项 +3072KB),Code +180KB 对应编译活动;Tracing +15540KB 是 JFR 记账。无法 attach 时,可在实验启动用 -XX:StartFlightRecording=... 预设录制;生产采集失败应保留 OS 层证据并检查权限,不能对任意 PID 重试强制操作。
NMT:先启动记账,再比较差量
启动参数 -XX:NativeMemoryTracking=summary 或 detail 不是事后凭命令就能补回的历史数据。已经以 tracking=off 启动的进程,不能用 baseline 魔法恢复过去的分配;需要安排下一次启动或采用 OS/native 证据(NMT 文档)。
"$lab_jdk/bin/jcmd" "$lab_pid" VM.native_memory summary scale=MB
"$lab_jdk/bin/jcmd" "$lab_pid" VM.native_memory baseline
# 制造或等待一个明确负载阶段后采集。
"$lab_jdk/bin/jcmd" "$lab_pid" VM.native_memory summary.diff scale=KBdiff 是相对 baseline 的变化,不是采集命令运行期间的总分配。Thread 类别上涨应联合 live thread 数和栈;Class 上涨联合加载器/卸载;Java Heap committed 上涨可能只是正常提交页面。直接缓冲可能在某些 HotSpot 分配类别中体现,但 NMT 不保证给出一个名为 Direct 的完整账目,更不覆盖所有第三方 allocator。旧 collect.sh 在启动应用后等待 1 s 才建立 baseline,但固定 sleep 不构成“分配完成”的跨进程保证。下面 Oracle 17.0.12 历史记录按初始分配已进入基线解释;复现时必须记录分配完成与 baseline 的先后,不能只从 diff 接近零反推该时序。
direct 模式的实测正是这个边界:
应用输出:directMiB=12 touched=true
NMT diff:Other (reserved=12322KB +34KB, committed=12322KB +34KB)应用自报分配并触碰了 12 MiB 直接缓冲,但 NMT 差量只有 +34KB——因为 12 MiB 的大部分在 baseline 之前已经预留(reserved=12322KB 无增量),NMT 也不保证把直接缓冲记为独立类别。结论:NMT 只能描述 HotSpot 跟踪到的部分本地分配,不能覆盖所有第三方 native 分配,committed 也不等于 resident,更不能用它“证明没有堆外增长”。
原码:基线是快照,diff 使用前后两份记账
固定 jdk-17.0.12+7 的 NMTDCmd::execute 关闭检查先判断 tracking level,NMT=off 时返回诊断文本;后面的命令不会开启追踪。baseline 与 summary.diff 分支分别更新已保存快照、检查此前是否已有基线,没有基线则拒绝比较;当前构建的实际成功/失败提示以原始实验记录为准,不能要求所有JDK提示语相同。
report_diff先取此前保存的 early_baseline,再捕获当前 baseline。以下为其中178–182行连续原码,仅统一缩进:
MemBaseline baseline;
if (baseline.baseline(summaryOnly)) {
if (summaryOnly) {
MemSummaryDiffReporter rpt(early_baseline, baseline, output(), scale_unit);
rpt.report_diff();MemSummaryDiffReporter 的两个输入就是前后两份记账,它不从启动起累加所有分配。初始分配若先于 early_baseline,已经是两次快照共同的存量;不能把 Total 差量当作整个进程RSS变化,也不能跨两个运行实例比较。
解释差量前,固定分配与基线的先后
| 时序 | baseline 中的直接缓冲 | 后采 diff 回答的问题 |
|---|---|---|
| A:分配并保留 → baseline → diff | 初始分配是存量 | 基线之后又改变了多少,而非从启动至今分配了多少 |
| B:baseline → 分配并保留 → diff | 初始分配尚未发生 | 新负载窗口增加了多少已跟踪记账 |
两种时序末尾都可以保留 12 MiB,diff 却不同。应在同一窗口读取 BufferPoolMXBean 的 direct count、totalCapacity 与 memoryUsed,前者提供缓冲数和容量交叉证据,后两者是估计值、可能因对齐与分配器而不同(Java SE 17 接口契约)。它们均不等于 RSS;若 NMT 与缓冲池不能解释 RSS,继续查 native 库、映射、线程栈和 OS 证据,不能宣布全部内存正常。
固定 jdk-17.0.12+7 的 Direct-X-Buffer 模板 中,直接缓冲构造先经 Bits.reserveMemory(size, cap) 校验/记账容量,再调用 Unsafe.allocateMemory 取得本地内存,并注册 Cleaner 负责释放;分配失败须撤销预留。Unsafe_AllocateMemory0 将分配交给 os::malloc(sz, mtOther),解释了这一 HotSpot 路径的 Other 归类。这里描述的是固定 tag 的控制流;不能推广为每个 JDK、每种直接内存 API 都有完整同名分类,更不能将此 tag 当作当前运行构建。
改变条件追问:A 的 diff 很小能否推出“进程没保留直接缓冲”?不能,先查 baseline 存量与 BufferPool;B 的 Total 恰好增加 12 MiB 能否推出全部是缓冲?也不能,attach、类加载、JIT 等同时会改变其他类别,应看相关类别和独立容量证据。若启动时 NMT=off,后执行 baseline 也不能开启或补记历史;应记录失败并安排下次受控启动。
有界实验:两时序与关闭追踪
2026-10-24 新运行使用 BellSoft Liberica JDK 17.0.19+11-LTS、Darwin 27.0.0 arm64、Python 3.9.6,没有改写上面的 Oracle 17.0.12 x86_64 历史结果,也不据 LTS 推断免费维护状态。这里是旧机制在另一明确构建上的复验,不是 U03 的新增工程兼容组合。
从仓库根运行,--output 必须是尚不存在的目录,脚本不会覆盖既有证据:
lab_jdk=$(/usr/libexec/java_home -v 17) # macOS;Linux 指向已安装JDK17的JAVA_HOME
python3 labs/jvm-senior/nmt_compare.py --java-home "$lab_jdk" \
--output /tmp/nmt-compare-new-run
# 预期失败入口:退出1,说明baseline不能打开已关闭的NMT。
python3 labs/jvm-senior/nmt_compare.py --java-home "$lab_jdk" --tracking off \
--output /tmp/nmt-off-new-run公开入口在临时目录编译探针,固定 -Xms64m -Xmx96m -XX:MaxDirectMemorySize=32m;只对自己启动的子进程执行 jcmd,最多保留 12 个 1 MiB 缓冲,逐 4 KiB 触碰。子 JVM 有 30 s 总看门狗,每次阶段等待与工具调用另有超时;失败仍关闭自己的进程,不访问其他 PID 或服务。A 收到 allocated 后才 baseline,B 在 baseline 成功后才发送 allocate,避免用 sleep 猜先后。该对照不启动 JFR,避免把录制缓冲混入目标负载。
原始正常对照记录和关闭追踪记录包含命令、stdout/stderr、环境、UTC 阶段时间与退出状态;北京时间分别从 2026-10-24 15:44:55、15:44:57 开始。实际结果如下:
| 本次时序 | baseline direct capacity | 末尾 direct count/capacity | baseline Other committed | diff Other committed | 退出 |
|---|---|---|---|---|---|
| A:分配 → baseline → diff | 12,582,912 B | 12 / 12,582,912 B | 12,288 KiB | 0 KiB | runner 0、子 JVM 0 |
| B:baseline → 分配 → diff | 0 B | 12 / 12,582,912 B | 0 KiB(零项未列出) | +12,288 KiB | runner 0、子 JVM 0 |
| NMT=off | 不成立 | 不用于比较 | 无可用分类 | baseline 回显 Native memory tracking is not enabled | runner 1(预期)、子 JVM 0 |
A/B 末尾 count、capacity 与 memoryUsed 相同,差量随基线位置改变,支持“存量不被 diff 重报”的解释;off 的 jcmd 在这次构建仍返回 0,因此入口同时检查诊断内容,拒绝把工具退出 0 等同于基线成功。实现只对本组 HotSpot17 的 Other 归类做 1 MiB 量级容差验证,不断言 Total 精确增加 12 MiB。其他构建若分类或噪声不符,入口失败并留证,不能改成空输出通过。这些数字不支持生产 RSS、实际泄漏、长期容量或所有 allocator 已覆盖的结论。
Arthas:聚焦一条业务调用,控制增强范围
在已有本地 Arthas 3.7.2 分发包时启动 java -jar arthas-boot.jar PID。本轮只核对官方源码和命令,未在共享工作区自动下载或向任何生产 JVM 注入 agent。进入 shell 后依次可用:
dashboard -n 1
thread -n 3 -i 1000
thread -b
sc -d com.example.OrderService
trace com.example.OrderService place '#cost>20' -n 5
watch com.example.OrderService place '{params.length,returnObj,throwExp}' -x 1 -n 5 -f
reset com.example.OrderService
stop这里类名是需要替换的业务示例,不能拿不存在的类运行后声称验证成功。thread -n 用两次 CPU 时间差除采样间隔,之后才取得栈;栈不一定就是整个窗口的唯一热点。thread -b 在该版本主要定位 synchronized 阻塞的 owner,不保证覆盖 java.util.concurrent.Lock(固定版 thread 文档)。
trace 主要展示匹配方法内部的调用及耗时,默认跳过部分 JDK 方法,不能作为完整分布式追踪;#cost>20 的单位为 ms,是筛选输出条件,不表示被筛掉的调用完全没有增强成本。watch -b 看到调用前入参,结束点的 params 可能已变;异常时 returnObj 不应当作正常结果。限制 -n 和展开深度,避免高频输出大对象(trace、watch)。
源码入口 TraceCommand extends EnhancerCommand,后者按类/方法 matcher 找目标,注册 listener 并通过 instrumentation 增强;命令终止和 reset/stop 要按生命周期清理。增强可能引发重转换、编译变化及额外开销;没有匹配类、类来自不同加载器、方法不可增强都可能导致没有预期结果。先用 sc -d 确认实际加载器,不能不断扩大 * 匹配整个应用(TraceCommand.java、EnhancerCommand.java)。
可运行实验
实验目录:labs/jvm-senior。从仓库根执行(本机 macOS x86_64、Oracle JDK 17.0.12):
lab_jdk=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
lab_build=$(JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/build.sh)
bash labs/jvm-senior/collect.sh "$lab_build" blocked
bash labs/jvm-senior/collect.sh "$lab_build" cpucollect.sh 启动独立的有界子进程(固定 20 s),依次采集 version/flags、两份 Thread.print、heap_info、NMT baseline 与 diff、JFR.dump,输出到证据目录后打印路径。预期:blocked 模式 waiter 两份快照都 BLOCKED、owner 在 sleep 中持有同一 monitor;cpu 模式 cpu-burner 的 cpu 字段持续增长且栈不变;heap 模式保留量与 heap_info 一致;direct 模式的 NMT 差量与直方图各自回答不同问题。以上预期均已按上文实测片段逐条核对。可切换 cpu、heap、direct、blocked、deadlock、churn;deadlock 模式的 threads-*.txt(由 jcmd Thread.print 生成)会输出 Found one Java-level deadlock: 与两个守护线程互相等待的描述,适合练习读环;实验限于合成进程,不能对任意用户服务注入故障。
一份合格记录应写 PID/JDK、负载输入、采集时点、命令、原始文件路径、观察和不能证明的内容。例如“两个快照显示等待同一 monitor,JFR 存在对应竞争事件”比“线程卡死”具体;但 20 s 实验没有真实请求,因此不能声称已经证明订单接口 P99。没跑 Arthas、Linux 或完整 heap dump 的部分必须标未执行。
高级答案与三轮追问
主问:“线上慢,你先用哪个工具?”答:先看影响范围与资源时间线,确认 PID/版本;连续线程快照和受限 JFR 形成初始假设,堆/native 假设分别走直方图/dump 和 NMT/OS,动态方法问题再聚焦 Arthas。不存在万能第一条 dump 命令。
第一轮:“一次栈就在某方法,是否热点?”答:单点只说明瞬时位置,结合线程 CPU 差量及多次采样;低 CPU 的 I/O 等待也可能在那里。评分点:时间口径和独立证据。
第二轮:“-all 是否零影响?”答:只改变对象包含范围及显式预先 GC 请求,扫描/dump 仍可能高成本;两处源码的 !_all.value() 参数就是证据。评分点:能指出 HotSpot 的取反参数分支。
第三轮:“JFR 没有锁事件就没有锁竞争?”答:检查事件启用、阈值、时间窗口和缓冲,联合线程等待图;不满足采集条件就不能作不存在证明。评分点:观测覆盖范围。
架构师变化条件:诊断预算也是可用性预算
假设 8 GiB 堆,实例剩余磁盘 3 GiB、在线只剩一个健康副本,不应直接按默认方案取可能超过磁盘的完整 dump。候选 A 保留 GC/JFR/直方图并限流,候选 B 先恢复副本与磁盘再取一次 dump,候选 C 在同等负载副本复现。取舍是对象图证据的完整性、故障持续时间和采集风险,不能把“需要证据”理解为随时全量采集。
变成难复现的低频事件时,预先设置有限大小的滚动 JFR、统一时间与实例标签比临时盯一次快照更有效;变成明显 native RSS 增长时,完整 Java heap dump 的优先级下降。验收是采集能关联到故障窗口、费用可控、结论可被反证,诊断结束后增强和录制按约定清理。
闭卷验收
本页 7 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JDK 17 jcmd 手册 | JDK 17 | attach、命令列表与 JVM 返回 help 的依据 |
| JDK 17 jfr 手册 | JDK 17 | 录制生命周期、事件与 print/summary 语义 |
| HotSpot NMT 文档 | JDK 17 | 分类范围、baseline/diff 语义与覆盖边界 |
| OpenJDK 17u diagnosticCommand.cpp | jdk-17.0.12+7 | HeapDumpDCmd(L483–L500)、ClassHistogramDCmd(L517–L530)、ThreadDumpDCmd(L542–L554) |
| Arthas 3.7.2 thread 文档 | arthas-all-3.7.2 | thread -n / thread -b 的行为与覆盖边界 |
| Arthas 3.7.2 trace 文档 / watch 文档 | arthas-all-3.7.2 | trace/watch 的输出语义与费用说明 |
| Arthas TraceCommand.java / EnhancerCommand.java | arthas-all-3.7.2 | 增强命令的 matcher 与 listener 注册路径 |
本地 labs/jvm-senior/collect.sh cpu/blocked/heap/direct 采集 | Oracle JDK 17.0.12 | 线程差量、等待图、heap_info/NMT/JFR 的实测证据 |
NMT 官方契约、BufferPool 与上述固定源码补充核查日期:2026-10-24。
访问日期:2026-10-23。源码行号以 jdk-17.0.12+7 标签为准;实测数字来自本机 Oracle JDK 17.0.12(macOS x86_64)。工具输出格式、命令集合与 Arthas 行为随版本变化,升级后需重新核对。