第12章:JDK 诊断工具箱——jps / jstat / jmap / jcmd / jhsdb

1. 项目背景

业务场景:某物流调度系统的"运单匹配"服务每隔几分钟就会出现 CPU 打到 100%、服务完全无响应的情况,但 10-20 秒后又自动恢复。开发组加了 Prometheus 监控,却因为采集间隔大(15 秒)而完美的"跳过"了每次故障窗口。运维通过 top 看到 CPU 爆表后只能重启,故障原因永远定位不到。

痛点:

  1. 监控工具的粒度盲区 :Prometheus/Grafana 等外部监控有采集间隔限制(15s~60s),瞬时的 GC 停顿、短暂的 CPU 飙升、偶发性的线程阻塞------这些"脉冲型故障"根本捕捉不到。JDK 自带的诊断工具(如 jstat -gc 每秒采集)才是捕捉它们的利器。
  2. 诊断工具的"副作用恐惧" :很多人听过 jmap -dump 会触发 Full GC,所以不敢在生产环境用诊断工具------干脆"等它自己恢复"。但 jcmdjstatjstack 的开销极小,生产环境完全可以大胆使用。
  3. jcmd 的能力被严重低估jcmd 是 JDK 7 引入的统一诊断入口,201 个子命令覆盖了从堆 dump、类直方图到 JFR 录制------但绝大多数运维只用来查 VM.versionGC.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

可能遇到的坑

  1. jstack 在容器中权限问题 :容器的 SYS_PTRACE capability 可能被禁用------导致 jstack attach 失败。K8s 中需在 Pod securityContext 中显式开启。
  2. jmap -dump 文件大小过大:线上 16GB 堆可能生成 8-10GB 的 dump 文件------确保磁盘空间充足且文件系统支持大文件(ext4/xfs)。
  3. jstat 精度jstat 的数字来自 JVM 内部计数器,精确但有时效性------两次采样间的瞬时尖峰可能漏掉。配合 -Xlog:gc* 获取完整 GC 事件时间线。
  4. 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 适用场景

  1. 内存泄漏排查jstat -gc 观察 OU 增长 → jcmd GC.heap_dump 导出 → MAT 分析 dominator tree。
  2. CPU 飙升定位top -H 找到高 CPU 线程 → jstack 定位具体代码行。
  3. 死锁检测jstack -l <pid> 自动检测并报告死锁环。
  4. GC 异常监控jstat -gc 1000 实时监控 YGC/FGC 频率和停顿。
  5. 生产排障 SOPjcmd 作为统一入口,先 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 12345histo: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 思考题

  1. 进阶题jcmd <pid> VM.native_memory summary 需要 JVM 启动时加 -XX:NativeMemoryTracking=summary。如果不加这个参数直接执行该命令,会收到什么错误?请实际验证,并解释 NMT 的 summarydetail 两个 level 的区别(提示:查阅 src/hotspot/share/services/memTracker.hpp)。

  2. 实战题 :你的服务部署在 K8s 中,Pod 的 securityContext 已配置 readOnlyRootFilesystem: true 且无 SYS_PTRACE capability。请问能用 jcmd 在该 Pod 内执行 GC.heap_dump 吗?如果不能,请设计一个替代方案(至少一种)。

答案提示:思考题 1 答案见第 35 章 Metaspace 与 NMT;思考题 2 答案见第 28 章容器感知。
下一章预告:第 13 章将系统学习 Unified Logging(-Xlog),从标签体系到日志轮转,为微服务集群制定统一的 JVM 日志参数模板。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

相关推荐
吠品8 小时前
Nginx负载均衡配置与线上故障排查的一些经验
java·开发语言·数据库
边境悍匪8 小时前
蜗牛学苑 Java 智能体学习 Day44|Vue 前端项目搭建 思维导图复盘
java·开发语言·spring boot·学习·阿里云
万年咸鱼8 小时前
public class 和 class 的区别详解
java
焦虑的说说8 小时前
redis知识汇总
java·redis
Wang's Blog8 小时前
Java 接入Redis: 密码认证与远程连接配置
java·服务器·redis
SunnyDays10118 小时前
Java 拆分 Word 文档教程:按页、分页符和分节符拆分
java·word·分页·拆分
彧azz8 小时前
Java学习记录:判断语句
java·笔记·学习·算法
企业数字化笔记8 小时前
一批相同的电脑怎么建账?数量管理、单件编码和拆分规则
java·后端·电脑
君顾18 小时前
本地城市社区家政物业联动系统源码:架构设计与派单联动实战
java·开发语言·健身房