1. 项目背景
业务场景:某物流调度系统的"运单匹配"服务每隔几分钟就会出现 CPU 打到 100%、服务完全无响应的情况,但 10-20 秒后又自动恢复。开发组加了 Prometheus 监控,却因为采集间隔大(15 秒)而完美的"跳过"了每次故障窗口。运维通过 top 看到 CPU 爆表后只能重启,故障原因永远定位不到。
痛点:
- 监控工具的粒度盲区 :Prometheus/Grafana 等外部监控有采集间隔限制(15s~60s),瞬时的 GC 停顿、短暂的 CPU 飙升、偶发性的线程阻塞------这些"脉冲型故障"根本捕捉不到。JDK 自带的诊断工具(如
jstat -gc每秒采集)才是捕捉它们的利器。 - 诊断工具的"副作用恐惧" :很多人听过
jmap -dump会触发 Full GC,所以不敢在生产环境用诊断工具------干脆"等它自己恢复"。但jcmd、jstat、jstack的开销极小,生产环境完全可以大胆使用。 jcmd的能力被严重低估 :jcmd是 JDK 7 引入的统一诊断入口,201 个子命令覆盖了从堆 dump、类直方图到 JFR 录制------但绝大多数运维只用来查VM.version和GC.heap_dump,90% 的能力被浪费。
本章系统学习 JDK 自带的诊断六件套------jps、jstat、jmap、jstack、jcmd、jhsdb------并通过一个"故意泄漏内存的 Demo"完成「发现→采样→转储→分析→定位根因」的完整闭环。
2. 项目设计
(监控大盘上一片绿,但用户群反馈"时不时卡几秒",运维一脸茫然。)
小胖 :大师,Prometheus 完全捕捉不到这个偶发性卡顿啊!top 倒是能看到 Java 进程偶尔 100% CPU,但来不及看是哪个线程干的。有没有什么"显微镜"级工具能看到一瞬间的 CPU 分布?
大师(打开终端):当然有,JDK 自带六大诊断工具。先教你看"菜单":
perl
jps → 列出所有 Java 进程(像 ps aux | grep java,但不受列宽限制)
jstat → 实时监控 GC、类加载、JIT 编译等统计指标
jstack → 导出所有线程的快照(线程在做什么、锁在谁手里)
jmap → 堆内存映射(直方图/clstats/dump)
jcmd → 万能入口(VM 信息、堆分析、线程 dump、JFR 录制)
jhsdb → 基于 Serviceability Agent 的事后分析(离线 dump 分析)
拿你的"间歇性 CPU 100%"问题来说,三步搞定:
bash
# 第一步:找到进程
jps -l
# 输出: 12345 com.logistics.DispatchService
# 第二步:看哪个线程在烧 CPU
top -H -p 12345 # 找到 CPU 最高的线程 PID,如 12399
printf "%x\n" 12399 # 转十六进制 → 306f
# 第三步:定位该线程在干什么
jstack 12345 | grep -A 20 "0x306f"
# 输出线程名、状态、调用栈
技术映射:jps ↔ 花名册(所有员工列表),jstat ↔ 体检仪(心率、血糖实时监测),jstack ↔ 全员大会点名(每个人汇报自己正在干什么),jmap ↔ 仓库盘点(所有物品登记在册)。
小白 :等一下!我听说 jmap -dump 会触发 Full GC,导致服务停顿甚至雪崩------是真的吗?
大师:版本混用的误解,需要精确澄清:
jmap -dump:live(加:live后缀):会触发一次 Full GC 来统计"存活对象",然后 dump。这个确实会停顿,对生产服务有影响。jmap -dump:all(或不加:live):不做 GC,直接 dump 当前堆状态。几乎无停顿,只是把内存内容写到磁盘,开销仅 IO。jcmd <pid> GC.heap_dump:等价于jmap -dump:all------不触发 GC,生产安全。jcmd <pid> GC.heap_dump -all:包含不可达对象,文件更大但信息更全。
铁律:生产环境用 jcmd GC.heap_dump,永远不用 jmap -dump:live。
技术映射 :dump:live ↔ 大扫除后盘点(先整理再记录,要停工);dump:all / jcmd heap_dump ↔ 不打扰盘点(趁员工正常工作时快速拍个照)。
小胖 :那 jstat 怎么用?它输出一堆缩写,什么 S0C S1C EC OC MC,看不懂啊!
大师 (白板画表):jstat -gc <pid> 1000 每 1 秒输出一行 GC 统计。
ini
S0C S1C S0U S1U EC EU OC OU MC MU
(kb) (kb) (kb) (kb) (kb) (kb) (kb) (kb) (kb) (kb)
5120 5120 0 1024 40960 20480 81920 30720 65536 51200
S0C/S1C = Survivor 0/1 容量 S0U/S1U = Survivor 0/1 使用量
EC = Eden 容量 EU = Eden 使用量
OC = Old 容量 OU = Old 使用量
MC = Metaspace 容量 MU = Metaspace 使用量
YGC = Young GC 次数 YGCT = Young GC 总耗时
FGC = Full GC 次数 FGCT = Full GC 总耗时
GCT = GC 总耗时
关键洞察:如果 EU 持续增长不降 → 对象分配太快,Minor GC 跟不上;如果 OU 持续增长 → 有内存泄漏;如果 FGC 快速增长 → 老年代频繁满。
技术映射:jstat 每条记录 ↔ 食堂每 5 分钟的客流量统计------EU 上升=来吃饭的人多了,OU 上升=饭菜被长期占着不走,FGC=需要保安清场了。
小白 :那 jcmd 呢?我只会用它来 heap_dump,还有什么好用的命令?
大师 :jcmd 有 201 个子命令,我给你一份"必知必会 TOP 10":
| 命令 | 作用 | 场景 |
|---|---|---|
VM.version |
查看 JVM 版本与参数 | 确认版本号、构建信息 |
VM.flags |
查看所有 JVM 参数的当前值 | 确认参数是否生效 |
VM.uptime |
查看 JVM 启动时间 | 判断进程是否重启过 |
VM.system_properties |
查看所有系统属性 | 排查-D参数设置问题 |
Thread.print |
打印线程 dump(等价于 jstack) | 排查线程死锁/阻塞 |
GC.heap_dump |
导出堆 dump(不触发 Full GC) | 内存泄漏分析 |
GC.heap_info |
查看各代堆使用情况 | 快速判断 GC 是否健康 |
GC.class_histogram |
按类统计存活对象数量 | 定位哪个对象最多 |
VM.class_hierarchy |
查看某个类的继承链 | 排查 ClassLoader 冲突 |
VM.native_memory |
Native Memory Tracking 汇总 | 排查堆外内存泄漏 |
技术映射 :jcmd ↔ 公司大楼的前台统一服务台------无论你需要保安(jstack)、清洁(jmap dump)、会计(jstat)、人事(VM.info),都从这个前台开始分发。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 内置工具 |
| MAT (Eclipse Memory Analyzer) | 1.14+ | dump 可视化分析 |
| async-profiler | 可选 | CPU/分配火焰图 |
| gcviewer | 可选 | GC 日志可视化 |
3.2 分步实现
步骤一:用 jps/jstat 实时监控一个泄漏进程
目标:启动一个有内存泄漏的 Demo,用 jstat 观察内存上升。
java
// LeakyApp.java ------ 故意制造内存泄漏
import java.util.*;
import java.util.concurrent.*;
public class LeakyApp {
// 泄漏根源:静态引用,永不被 GC 回收
static List<byte[]> leakList = new ArrayList<>();
static void startLeakThread() {
new Thread(() -> {
int count = 0;
while (true) {
leakList.add(new byte[1024 * 512]); // 512KB per object
count++;
if (count % 20 == 0) {
System.out.println("泄漏对象数: " + count
+ " (" + (count * 512 / 1024) + " MB)");
}
try { Thread.sleep(500); } catch (InterruptedException e) { break; }
}
}, "Leak-Thread").start();
}
static void startBusyThread() {
// 制造 CPU 负载,帮助练习 jstack 定位
new Thread(() -> {
while (true) {
Math.sin(Math.random() * 10000);
}
}, "Busy-Thread").start();
}
public static void main(String[] args) throws Exception {
System.out.println("PID: " + ProcessHandle.current().pid());
System.out.println("泄漏线程和忙等线程已启动...");
startLeakThread();
startBusyThread();
Thread.sleep(600_000); // 运行 10 分钟
}
}
bash
javac LeakyApp.java
# 后台启动泄漏程序
java -Xms128m -Xmx256m -XX:+UseSerialGC LeakyApp &
APP_PID=$!
echo "应用 PID: $APP_PID"
# 实时监控 GC 统计(每秒一行)
jstat -gc $APP_PID 1000 | head -20
# 观察 OU (Old Usage) 上涨、FGC 次数增加
步骤二:用 jstack 定位 CPU 飙升线程
bash
# jstack 输出全部线程状态
jstack $APP_PID > thread_dump_$(date +%Y%m%d_%H%M%S).txt
# 快速定位问题线程:
# 1. 找出 CPU 最高的线程
top -H -p $APP_PID -n 1 | head -5
# 2. 转换为十六进制线程 ID (nid)
printf "%x\n" <线程ID>
# 3. 在 jstack 输出中搜索
grep -A 20 "0x<十六进制ID>" thread_dump.txt
步骤三:用 jcmd 导出堆 dump 并用 MAT 分析
bash
# 导出堆 dump(不触发 Full GC,生产安全)
jcmd $APP_PID GC.heap_dump leak_dump.hprof
echo "堆 dump 已生成: leak_dump.hprof ($(du -h leak_dump.hprof | cut -f1))"
# 查看对象直方图(不导出完整 dump,只统计)
jcmd $APP_PID GC.class_histogram | head -20
# 输出示例:
# num #instances #bytes class name
# 1: 15234 78901234 [B ← byte[] 占比最高=泄漏
# 2: 1234 1234567 java.lang.String
# 3: 567 456789 java.util.ArrayList
用 MAT (Eclipse Memory Analyzer) 分析 dump:
bash
# 下载 MAT (独立版)
# 或直接在 IDE 中打开 leak_dump.hprof
# 1. 查看 "Leak Suspects" 报告 → MAT 自动检测泄漏嫌疑
# 2. 打开 "Histogram" → 按 retain size 排序 → byte[] 第一
# 3. 右键 byte[] → "List objects" → "with outgoing references"
# 4. 跟踪引用链: byte[] ← ArrayList.elementData ← ArrayList ← LeakyApp.leakList
# 5. 定位根因: LeakyApp.leakList (静态字段) 持有所有 byte[] 的引用
步骤四:使用 jstat 自动收集 GC 数据形成趋势
bash
# 每 5 秒收集一次,连续 100 次,写入 CSV
jstat -gc -t $APP_PID 5000 100 > gc_stats_$(date +%H%M%S).csv
python
# 快速可视化脚本 (Python)
import pandas as pd
import matplotlib.pyplot as plt
df = pd.read_csv('gc_stats.csv', skiprows=0, header=0)
df.columns = ['Timestamp','S0C','S1C','S0U','S1U','EC','EU',
'OC','OU','MC','MU','CCSC','CCSU','YGC','YGCT',
'FGC','FGCT','GCT']
plt.figure(figsize=(12,6))
plt.plot(df['Timestamp'] - df['Timestamp'][0], df['OU']/1024, label='Old Gen (MB)')
plt.plot(df['Timestamp'] - df['Timestamp'][0], df['MU']/1024, label='Metaspace (MB)')
plt.xlabel('Seconds')
plt.ylabel('Usage (MB)')
plt.title('GC Heap Usage Over Time')
plt.legend()
plt.grid(True)
plt.savefig('gc_trend.png')
步骤五:jhsdb 事后分析基础
目标:不用启动 JVM 也能分析 dump 文件。
bash
# 用 jhsdb (HotSpot Serviceability Agent) 分析 dump
# jhsdb jmap 可以在不启动 JVM 的情况下查看 dump
# 从 core dump 提取堆信息(生产环境常用)
jhsdb jmap --binaryheap --dumpfile=offline_dump.hprof --exe=/path/to/java
# 查看 dump 中的类直方图
jhsdb jmap --histo --heap --exe=/path/to/java /path/to/core.dump
可能遇到的坑:
jstack在容器中权限问题 :容器的SYS_PTRACEcapability 可能被禁用------导致jstackattach 失败。K8s 中需在 Pod securityContext 中显式开启。jmap -dump文件大小过大:线上 16GB 堆可能生成 8-10GB 的 dump 文件------确保磁盘空间充足且文件系统支持大文件(ext4/xfs)。jstat精度 :jstat的数字来自 JVM 内部计数器,精确但有时效性------两次采样间的瞬时尖峰可能漏掉。配合-Xlog:gc*获取完整 GC 事件时间线。- Docker 中
jcmd需要 root :如果目标 Java 进程由 root 启动,而jcmd以非 root 执行会失败。以同一用户(推荐USER设置为应用用户)运行 Java 进程和诊断工具。
3.3 完整诊断流水线脚本
bash
#!/bin/bash
# diagnostic_pipeline.sh ------ 一键诊断流水线
PID=$(jps -l | grep LeakyApp | awk '{print $1}')
if [ -z "$PID" ]; then
echo "错误: LeakyApp 未运行"
exit 1
fi
echo "=== 诊断报告 PID=$PID $(date) ==="
echo ""
echo "--- 1. JVM 基本信息 ---"
jcmd $PID VM.version
jcmd $PID VM.uptime
jcmd $PID VM.flags | grep -E "Xm|MaxHeap|Metaspace"
echo ""
echo "--- 2. 堆使用情况 ---"
jcmd $PID GC.heap_info
echo ""
echo "--- 3. 类直方图 TOP 5 ---"
jcmd $PID GC.class_histogram | head -7
echo ""
echo "--- 4. 线程状态分布 ---"
jstack $PID | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn
echo ""
echo "--- 5. GC 统计 ---"
jstat -gc $PID 1 3
echo ""
echo "--- 6. 导出 thread dump ---"
jstack $PID > thread_dump_$(date +%Y%m%d_%H%M%S).txt
echo "已保存 thread dump"
echo ""
echo "--- 7. 导出 heap dump ---"
jcmd $PID GC.heap_dump heap_dump_$(date +%Y%m%d_%H%M%S).hprof
echo "已保存 heap dump"
echo ""
echo "=== 诊断完成 ==="
4. 项目总结
4.1 优点与缺点
| 工具 | 优点 | 缺点 |
|---|---|---|
| jps | 极简,输出清爽,含 main 类名和参数 | 不能查看非当前用户的 Java 进程 |
| jstat | 毫秒级采集、零额外 GC 开销 | 数字是瞬时值,不反映整个 GC 事件时间线 |
| jstack | 快速诊断死锁、线程阻塞 | 只反映瞬时快照,瞬态锁竞争可能漏掉 |
| jmap | 可查看对象直方图、类加载统计 | jmap -dump:live 触发 Full GC;jmap -histo:live 也会触发 GC |
| jcmd | 统一入口、201 个子命令、不触发 GC 的 heap dump | 部分命令在不同 JDK 版本间不一致(VM.native_memory 需先启用 NMT) |
| jhsdb | 离线分析 core dump 和 heap dump,无需启动 JVM | 依赖 debug 符号或 fastdebug 构建,学习曲线陡峭 |
| 对比维度 | jcmd heap_dump | jmap -dump:all | jmap -dump:live |
|---|---|---|---|
| 触发 Full GC | 否 | 否 | 是 |
| 生产可用 | 是(推荐) | 是(OK) | 慎用 |
| 文件大小 | 中等(仅堆) | 中等 | 较小(仅存活对象) |
| 所需权限 | attach API | attach API | attach API |
4.2 适用场景
- 内存泄漏排查 :
jstat -gc观察 OU 增长 →jcmd GC.heap_dump导出 → MAT 分析 dominator tree。 - CPU 飙升定位 :
top -H找到高 CPU 线程 →jstack定位具体代码行。 - 死锁检测 :
jstack -l <pid>自动检测并报告死锁环。 - GC 异常监控 :
jstat -gc 1000实时监控 YGC/FGC 频率和停顿。 - 生产排障 SOP :
jcmd作为统一入口,先Thread.print看线程状态,再GC.heap_dump抓现场。
不适用场景:
- 需要长期趋势分析的场景------
jstat适合短时诊断,长期趋势应用 Prometheus + JMX exporter 采集到 Grafana。 - 需要纳秒级精度的分析------
jstat的数据来自 JVM 计数器,非实时采样。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| jmap 与 jcmd 的版本偏好 | JDK 8 时代推荐 jmap -dump,JDK 9+ 官方推荐 jcmd GC.heap_dump------命令更简洁且未来更易兼容 |
| jstat 计数器溢出 | jstat 输出的 YGCT/FGCT 是累计秒数,可能溢出------每约 68 年溢出一次(实际不用关心) |
| jhsdb 需要 SA-Backend | jhsdb 使用的 Serviceability Agent 在不同 OS 上有各自的 native 后端------Windows 上的 SA 支持相对较弱 |
| 容器中 jcmd 权限 | Docker 的安全限制(seccomp, AppArmor)可能阻止 jcmd 使用 ptrace attach------需要 --cap-add SYS_PTRACE |
4.4 常见踩坑经验
案例 1:jmap -histo:live 导致业务雪崩
某支付系统运维在排查内存问题时,执行了 jmap -histo:live 12345。histo:live 需要先做一次 Full GC 来"找出存活的",结果 4GB 堆的 Full GC 耗时 8 秒------所有业务请求在这 8 秒内超时,触发上游熔断。根因 :jmap -histo:live 隐式触发 Full GC。修复 :改用 jcmd <pid> GC.class_histogram(不触发 GC,但包含垃圾对象)。
案例 2:jstack dump 太大导致 GC 日志分析系统 OOM
某公司把所有微服务的 jstack 定时抓取结果写入 ElasticSearch。100 个服务 × 每分钟 1 次 = 每天 14.4 万条线程 dump,ES 索引膨胀到 500GB。根因 :jstack 是诊断工具,不是监控工具------不应定时采集。修复 :仅告警触发时采集,配合 -XX:+PrintConcurrentLocks 输出锁信息就够。
案例 3:jstat 误导------EU 下降但堆真的在泄漏
运维用 jstat -gc 监控 Eden 使用率,看到 EU 一直很低,以为堆使用很健康。但实际上老年代 OU 已在持续上涨------对象提前晋升到 Old,所以 Young Gen 的 EU 看起来"很正常"。根因 :只看一行 jstat 数据,不看整体趋势。修复:同时监控 YGC 频率 + OU 趋势 + FGC 次数,三者结合判断。
4.5 思考题
-
进阶题 :
jcmd <pid> VM.native_memory summary需要 JVM 启动时加-XX:NativeMemoryTracking=summary。如果不加这个参数直接执行该命令,会收到什么错误?请实际验证,并解释 NMT 的summary和detail两个 level 的区别(提示:查阅src/hotspot/share/services/memTracker.hpp)。 -
实战题 :你的服务部署在 K8s 中,Pod 的 securityContext 已配置
readOnlyRootFilesystem: true且无SYS_PTRACEcapability。请问能用jcmd在该 Pod 内执行GC.heap_dump吗?如果不能,请设计一个替代方案(至少一种)。
答案提示:思考题 1 答案见第 35 章 Metaspace 与 NMT;思考题 2 答案见第 28 章容器感知。
下一章预告:第 13 章将系统学习 Unified Logging(-Xlog),从标签体系到日志轮转,为微服务集群制定统一的 JVM 日志参数模板。