本节目标:拿到一个症状时,能在一分钟内选出正确的工具,并知道它的开销和盲区。
适用版本:JDK 25(Temurin 25.0.5);JFR 内置;本机未安装 JMC、MAT、async-profiler。
承接的问题
1.2 固定了实验环境,但环境只保证「可比」,不告诉你「看什么」。同一个 libraryd 进程,可用的观测面至少有五层:jcmd 的即时命令、JFR 的采样与事件、GC 日志的时间线、JMX/ManagementFactory 的代码指标,以及 JMC/MAT/VisualVM 这类图形工具。
它们不是替代关系。jcmd GC.heap_info 回答「堆离上限多远」,JFR 回答「时间花在哪」,GC 日志回答「每次停顿多长」,ManagementFactory 回答「服务自己能不能持续报告」。选错工具,要么得到答非所问的数字,要么付出不必要的停顿。
这一节按开销从低到高排一遍,每个工具都给本机真实输出;没装的工具只写操作路径,不编输出。
jcmd:一个入口,按需取数
先看当前 JVM 支持哪些命令:
jcmd 71819 help
本机输出共 55 条命令,按前缀归并如下(命令名逐字来自输出,PID 随进程变化):
| 前缀 | 命令 |
|---|---|
| AOT | end_recording |
| Compiler | CodeHeap_Analytics、codecache、codelist、directives_add、directives_clear、directives_print、directives_remove、memory、queue |
| GC | class_histogram、finalizer_info、heap_dump、heap_info、run、run_finalization |
| JFR | check、configure、dump、start、stop、view |
| JVMTI | agent_load、data_dump |
| ManagementAgent | start、start_local、status、stop |
| System | dump_map、map |
| Thread | dump_to_file、print、vthread_pollers、vthread_scheduler |
| VM | cds、class_hierarchy、classes、classloader_stats、classloaders、command_line、dynlibs、events、flags、info、log、metaspace、native_memory、set_flag、stringtable、symboltable、system_properties、systemdictionary、uptime、version |
| 其他 | help |
这份清单因 JDK 版本和启动参数而异。AOT.end_recording 只在 AOT 相关功能可用时出现,ManagementAgent.* 与 JMX 代理有关。不要背命令,先 help 再选。
help <command> 会给出用途与开销。常用命令的分级:
| 命令 | 开销 | 回答什么问题 |
|---|---|---|
VM.version / VM.uptime | 低 | 版本、运行时长 |
VM.command_line / VM.flags | 低 | 实际参数与来源 |
GC.heap_info | 中 | reserved / committed / used、young 区 |
VM.classloaders | 中 | 加载器层级与已加载类 |
Thread.print | 中 | 平台线程栈、锁等待 |
JFR.start | 中(随配置上升) | 采样、分配、GC、锁事件 |
Compiler.codelist | 中 | 代码缓存里哪些方法被编译 |
GC.class_histogram | 高 | 存活对象直方图,默认触发 Full GC |
GC.heap_dump | 高 | 完整堆转储,写大文件并可能 STW |
VM.native_memory | 中 | 本机内存,需要启动时开 NMT |
GC.heap_info 的输出在 1.1 已经拆过。这里补一个容易被忽略的点:Thread.print 默认只列平台线程。虚拟线程要用 Thread.dump_to_file -format=json,否则你会以为服务只有几十个线程。第 8 章会专门讲。
JFR:事件模型与一次 8 秒记录
JFR 的数据单位是事件。一次记录里常见的几类:
| 事件 | 含义 | 典型用途 |
|---|---|---|
jdk.ExecutionSample | 周期性 Java 栈采样 | CPU 热点 |
jdk.ObjectAllocationSample | 分配采样(带权重) | 谁在制造垃圾 |
jdk.GCPhasePause | GC 停顿阶段 | 停顿归因 |
jdk.ThreadSleep | Thread.sleep | 区分等待与计算 |
jdk.JavaMonitorEnter | 监视器竞争 | 锁等待 |
jdk.Deoptimization | 反优化 | 为什么突然变慢 |
对运行中的进程开启一段有边界的记录:
jcmd 71819 JFR.start name=vol4a-scan \
filename=/tmp/vol4-a/logs/vol4a.jfr duration=8s settings=profile
本机输出:
Started recording 1. The result will be written to:
/tmp/vol4-a/logs/vol4a.jfr
8 秒后记录自动停止,文件约 346 KB。jfr summary 看事件全貌:
jfr summary /tmp/vol4-a/logs/vol4a.jfr
本机输出(节选):
Version: 2.1
Chunks: 1
Start: 2026-10-28 03:24:36 (UTC)
Duration: 8 s
Event Type Count Size (bytes)
=============================================================
jdk.NativeLibrary 846 74302
jdk.SystemProcess 680 73877
jdk.ModuleExport 511 5957
jdk.ObjectAllocationSample 196 2943
jdk.ExecutionSample 84 924
jdk.ThreadSleep 11 198
jdk.Deoptimization 10 239
先看 Duration: 8 s,确认窗口;再看两类采样:84 个 ExecutionSample、196 个 ObjectAllocationSample。8 秒 84 个 CPU 样本,大约每 95 毫秒一个,样本量只能做粗排序,不能做精确归因。NativeLibrary、SystemProcess 数量多,但它们是启动元数据,不是热点。
热点视图:
jfr view hot-methods /tmp/vol4-a/logs/vol4a.jfr
本机输出:
Java Methods that Execute the Most
Method Samples Percent
--------------------------------------------------------------- ------- -------
com.plume.library.app.LabApp.main(String[]) 67 79.76%
java.util.ArrayList.add(Object, Object[], int) 15 17.86%
jdk.internal.event.ThreadSleepEvent.commit() 1 1.19%
jdk.internal.event.ThreadSleepEvent.shouldCommit() 1 1.19%
LabApp.main 占 79.76%,但 Loan.state、FinePolicy.fineCents 没有单独出现。原因不是它们没执行,而是被 JIT 内联进了 main。看到方法「消失」时,先怀疑内联,不要直接判断它没被调用。
分配视图:
jfr view allocation-by-class /tmp/vol4-a/logs/vol4a.jfr
本机输出:
Allocation by Class
Object Type Allocation Pressure
----------------------------------------------------------- -------------------
com.plume.library.domain.LoanState$Overdue 98.34%
com.plume.library.domain.LoanState$Active 0.83%
java.util.concurrent.ConcurrentHashMap$Node[] 0.77%
jdk.internal.event.ThreadSleepEvent 0.05%
LoanState.Overdue 占 98.34% 的分配压力,和 LabApp 每轮为逾期 loan 创建 Overdue 的代码一致。这个百分比是采样加权后的估计,不是精确字节数;它回答「分配压力集中在哪」,不回答「堆里现在有多少」。
settings=profile 的采样率比默认高,适合短时诊断;长期开启应该用默认配置或更低开销的配置。JFR 的记录里可能包含类名、方法名、线程名、SQL 文本,带出生产环境前要按数据分类规则检查。
GC 日志:最低成本的时间线
GC 日志不需要 attach,不需要额外进程,启动时加一个参数就能一直记:
java -Xms64m -Xmx64m \
"-Xlog:gc*:file=/tmp/vol4-a/logs/gc.log:time,uptime,level,tags" \
-cp out com.plume.library.app.LabApp 4
本机输出(节选):
[2026-10-28T11:29:17.596+0800][0.015s][info][gc ] Using G1
[2026-10-28T11:29:17.596+0800][0.016s][info][gc,init] CPUs: 10 total, 10 available
[2026-10-28T11:29:17.596+0800][0.016s][info][gc,init] Heap Region Size: 1M
[2026-10-28T11:29:19.674+0800][2.093s][info][gc,start ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[2026-10-28T11:29:19.676+0800][2.095s][info][gc ] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 35M->2M(64M) 1.986ms
[2026-10-28T11:29:21.473+0800][3.892s][info][gc,start ] GC(1) Pause Young (Normal) (G1 Evacuation Pause)
[2026-10-28T11:29:21.474+0800][3.893s][info][gc ] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 31M->2M(64M) 1.199ms
一条 GC 记录拆成四段:
[2026-10-28T11:29:19.676+0800]是墙钟时间,[2.095s]是进程启动后的秒数。GC(0)是回收序号,Pause Young (Normal)是类型。35M->2M(64M)是回收前 -> 回收后的已用堆,括号里是堆容量。1.986ms是这次停顿的时长。
-Xlog:gc* 的 * 在 zsh 交互式 shell 里会被当成 glob,必须给整个参数加引号,否则报 no matches found。日志要配轮转,filecount=5,filesize=20M 这类参数写在同一个引号里。
GC 日志的盲区是:它只告诉你 GC 发生了什么,不告诉你分配是谁制造的。分配归因要靠 JFR 的 ObjectAllocationSample 或堆转储。
JMX 与 ManagementFactory:把观测变成代码
jcmd 和 JFR 是外部观测;服务自己也需要一套内部快照,用于日志、健康检查和压测报告。JDK 的 java.lang.management 是标准入口,不依赖 com.sun.management 的私有实现:
package com.plume.library.app;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
import java.lang.management.RuntimeMXBean;
import java.lang.management.ThreadMXBean;
public final class RuntimeProbe {
private final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
private final ThreadMXBean threads = ManagementFactory.getThreadMXBean();
private final RuntimeMXBean runtime = ManagementFactory.getRuntimeMXBean();
public RuntimeSnapshot snapshot() {
MemoryUsage heap = memory.getHeapMemoryUsage();
MemoryUsage nonHeap = memory.getNonHeapMemoryUsage();
long metaspace = 0;
for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
if ("Metaspace".equals(pool.getName())) {
metaspace = pool.getUsage().getUsed();
}
}
long gcCount = 0;
long gcTime = 0;
for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
if (gc.getCollectionCount() > 0) {
gcCount += gc.getCollectionCount();
}
if (gc.getCollectionTime() > 0) {
gcTime += gc.getCollectionTime();
}
}
return new RuntimeSnapshot(heap.getUsed(), heap.getCommitted(), heap.getMax(),
nonHeap.getUsed(), metaspace, gcCount, gcTime,
threads.getThreadCount(), threads.getPeakThreadCount(),
runtime.getUptime());
}
public String oneLine() {
RuntimeSnapshot s = snapshot();
return "heap=" + s.heapUsed() / 1048576 + "/" + s.heapCommitted() / 1048576
+ "/" + s.heapMax() / 1048576 + "mB nonheap=" + s.nonHeapUsed() / 1048576
+ "mB metaspace=" + s.metaspaceUsed() / 1048576
+ "mB gc=" + s.gcCount() + "/" + s.gcTimeMillis() + "ms"
+ " threads=" + s.threadCount() + " peak=" + s.peakThreadCount()
+ " uptime=" + s.uptimeMillis() + "ms";
}
}
RuntimeSnapshot 是 1.1 用过的 record。编译运行后的本机输出:
heap=2/256/256mB nonheap=1mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=84ms
heap=11/256/256mB nonheap=2mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=296ms
heap=20/256/256mB nonheap=2mB metaspace=0mB gc=0/0ms threads=6 peak=6 uptime=528ms
metaspace=0mB 不是没加载类,而是 743800 字节做整数除法后不足 1 MB。指标展示要么保留字节,要么用小数;把 0 当成「元空间为空」会误导排障。
三个边界:getCollectionCount() 返回 -1 表示该收集器不支持计数;getHeapMemoryUsage().getMax() 可能返回 -1;getThreadCount() 只统计活着的平台线程,不含虚拟线程。代码必须处理这些返回值,不能直接做除法。
工具边界:JMC、MAT、async-profiler
本机 command -v asprof jmc visualvm mat 全部返回 not found,所以下面只讲适用场景与操作路径,不提供这些工具的实测输出。
JMC(JDK Mission Control) 适合读 .jfr:打开记录,看 Method Profiling、Flame View、Allocation、GC、Lock 视图。它和 JFR 是配套的,记录本身由 JVM 产生,不依赖 JMC。服务器上只装了 JDK 时,用 jfr view 和 jfr print 也能完成大部分定位。
MAT(Eclipse Memory Analyzer) 适合读 .hprof:先用 jcmd <pid> GC.heap_dump /tmp/libraryd.hprof 生成转储,再用 MAT 打开,看 Dominator Tree、Retained Size、Path to GC Roots。堆转储的成本比 JFR 高得多,应该在「已经怀疑某类对象泄漏」之后再做。
async-profiler 适合需要更高采样率、native 栈或分配跟踪的场景。本机没装,不用它下结论;JFR 的 settings=profile 在 Java 方法采样上通常够用。选择工具的顺序是「已有数据能不能回答问题」,不是「哪个工具更高级」。
在 libraryd 里落地
libraryd 的观测面分三层:
- 常驻、低成本:GC 日志 +
RuntimeProbe的周期性oneLine()。GC 日志由启动参数开启,RuntimeProbe每 30 秒写一行到应用日志,用于趋势。 - 按需、分钟级:
collect.sh采集一次基线;jcmd JFR.start开一段 30 到 60 秒的记录;Thread.print抓线程栈。 - 事后、高成本:
GC.heap_dump+ MAT,只在内存问题收窄后使用。
对应的代码落点是给 RuntimeProbe 加一个受保护的诊断端点,而不是把 jcmd 暴露给公网:
package com.plume.library.app;
import io.javalin.Javalin;
public final class DiagnosticsRoutes {
public static void register(Javalin app, RuntimeProbe probe) {
app.get("/internal/runtime", ctx -> {
if (!"127.0.0.1".equals(ctx.ip())) {
ctx.status(403).result("local only");
return;
}
ctx.json(probe.snapshot());
});
}
}
端点只允许本机访问,返回结构化 JSON,方便压测脚本和外部监控抓取。真正重的操作(heap dump、JFR 文件)不通过 HTTP 触发,仍由运维在机器上执行,避免一个误调用把服务拖停。
常见错误与边界
错误一:用 Thread.print 找虚拟线程。 默认输出只有平台线程;虚拟线程要用 Thread.dump_to_file -format=json。看不到虚拟线程不等于没有虚拟线程。
错误二:把 JFR 的分配百分比当成堆占用。 allocation-by-class 是分配压力,不是当前存活大小。要看存活对象,用 GC.class_histogram 或堆转储。
错误三:在 zsh 里不加引号写 -Xlog:gc*。 * 会被展开,命令直接失败:
zsh: no matches found: -Xlog:gc*:file=/tmp/gc.log
把整个参数放进双引号即可。
错误四:把 GC.class_histogram 当轻量命令。 它默认只统计存活对象,会先触发一次 Full GC;GC.heap_dump 更重。生产环境先用 GC.heap_info 和 JFR,直方图留到需要时。
边界一:JFR 看不到 native 栈和内联方法。 native 崩溃看 hs_err_pid.log,系统级 CPU 看 perf;内联方法在采样里会合并到调用者。
边界二:JMX 指标有采集间隔。 MemoryMXBean 是瞬时值,GarbageCollectorMXBean 是累计值;把累计值直接画成曲线会得到单调上升的线,应该做差分或看速率。
小结
jcmd help给出当前 JVM 的命令清单;help <command>标注开销,先选轻的。- JFR 用事件回答「时间花在哪、谁在分配」;8 秒记录 84 个 CPU 样本只够粗排序。
jfr view hot-methods里看不到的方法可能被内联;allocation-by-class是分配压力,不是堆占用。- GC 日志是成本最低的时间线,四段分别是时间、序号与类型、回收量、停顿。
ManagementFactory把同样的数据变成代码指标,注意-1返回值和整数除法。- JMC、MAT、async-profiler 本机未安装,只作为可选路径;没有输出就不写实测。
工具能回答「发生了什么」,下一步要回答「class 文件里到底有什么」。第 2 章从 Book.class 的字节开始,拆常量池、方法表和属性。
阅读导航:上一节:1.2 建立可复现的实验环境:CPU、堆与固定参数 · 下一节:2.1 类文件结构:常量池、方法表与属性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。