Skip to content

生产诊断工具箱:从 jmap 到 JFR

本文是 JVM & GC 系统学习系列的实战篇(L3)。前置:JVM 调优方法论--那篇讲"什么时候该调、按什么顺序调",这篇解决"手上该拿什么工具"。 学完可以配合面试题食用:内存泄漏排查(jmap+MAT)CPU 100% 排查(top+jstack)JDK 命令行工具JFR+JMC 生产诊断

为什么需要一套工具箱

前面几篇把 JVM 的内存结构、GC 算法、收集器都讲完了,但那些知识落到生产环境,往往是一个具体的求救电话:"服务 RT 突然涨了""内存一直往上涨""CPU 打满"。这时候拼的不是理论,是你能不能在十几分钟内把问题定位到一个具体的类、一段具体的代码。

工具不是越多越好。每个工具都有它的成本:jmap dump 会让服务停顿几秒到几十秒,生产高峰期随手一个 dump 可能直接把服务打挂;JFR 虽然开销低但数据需要离线分析;arthas 功能全但是要往目标 JVM 挂 agent。所以这篇文章除了讲"每个工具怎么用",更重要的是讲"什么场景拿什么工具"——工具地图比工具本身值钱。

先看全景图,后面逐个展开:

mermaid
flowchart TD
    A[生产异常] --> B{问题类型}
    B -->|内存: OOM / 老年代持续上涨| C[jstat -gcutil<br/>看 GC 频率趋势]
    C --> D[jmap histo 锁定对象大户]
    D --> E[dump + MAT 支配树<br/>定位到代码]
    B -->|CPU 打满| F[top -Hp 找线程]
    F --> G[线程号转十六进制<br/>jstack 匹配]
    B -->|持续观测 / 说不清的问题| H[JFR 事件录制<br/>JMC 离线分析]
    B -->|不能重启 / 要看运行时状态| I[arthas 在线诊断]

命令行四件套:jps / jstat / jmap / jstack

JDK 自带命令是第一道防线,优点是不用装任何东西、SSH 上机器就能用。四个命令各管一段:

jps 找进程。jps -l 列出所有 Java 进程和主类名,拿到 pid 后面所有命令都靠它。容器里注意:如果 JVM 和 shell 不在同一个 pid namespace,jps 可能看不到目标进程,直接从 ps aux | grep java 拿 pid。

jstat 看 GC 趋势。这是唯一能"持续盯着看"的命令行工具,不用attach,开销接近零:

bash
# 每 1000ms 输出一次,共 10 次,看各代使用率和 GC 次数/耗时
jstat -gcutil 12345 1000 10

  S0     S1      E      O      M     CCS    YGC   YGCT    FGC  FGCT     GCT
  0.00  98.44  55.35  72.18  94.02  89.71  8234 116.289    5   2.813  119.102
  0.00  98.44  85.11  72.18  94.02  89.71  8235 116.303    5   2.813  119.102

逐列读这份输出:E 是 Eden 使用率,从 55% 涨到 85%,下一轮就会触发 Young GC;O 是老年代,稳定在 72.18% 不动——如果连续几十列 O 只涨不跌、YGC 次数猛增但 O 不降,就是典型的"对象晋升过快或泄漏"形态,该转入 jmap 环节了。FGC=5 说明 Full GC 才 5 次,健康;如果 FGC 每分钟都在加,先把 GC 日志开起来再说。

jmap 拿内存明细。两个用法要分清:jmap -histo 只统计对象直方图,不停顿(准确说停顿很短);jmap -dump 才是导出完整堆快照,STW 最凶的那个。排查顺序永远是先 histo 再 dump,histo 十有八九能省掉一次 dump。

bash
# 按占用字节数排序看前 20 个对象大户
jmap -histo 12345 | head -21

 num     #instances         bytes  class name
   1:       4194304      201326592  [B   // byte[],409 万个实例占 192MB
   2:       2097152      100663296  java.lang.Byte[]
   3:       1048576       33554432  com.demo.Packet

409 万个 byte[],配上业务里"每个请求构造一个 Packet",基本能猜到是某个缓存没设上限或者请求对象被长期持有。histo 给的是"谁占的",但给不了"谁引用的"——这正是要 dump 的理由。

jstack 拿线程栈。除了排查 CPU 问题(后面细讲),它也是死锁检测的捷径:jstack -l 会在末尾直接输出 "Found one Java-level deadlock",加 -l 还会带出锁的持有信息。另外注意 jstack 对僵死进程会失败,加 -F 强制 dump,但那已经是救火姿势了。

命令行的边界也很清楚:都是一次性快照,看不了"过去一小时发生了什么",也没有火焰图。要持续观测,上 JFR。

内存问题三步定位法

生产内存问题五花八门,但收敛下来就是一套固定动作,按顺序做不要跳步。

第一步:jstat 确认形态。 先用上面的 jstat -gcutil 区分是"流量洪峰导致的老年代吃紧"(GC 后 O 能降下来)还是"泄漏"(GC 后 O 的水位线一次比一次高)。这一步决定后面 dump 一次还是两次——泄漏对比需要两个时间点的快照。

第二步:jmap histo 锁定大户,dump 交给 MAT。 histo 排前几的类,通常一半是正常业务对象(大接口、大报文),另一半才是嫌疑犯。确认嫌疑后 dump:

bash
# live 只保留存活对象,format=b 二进制格式给 MAT 用
jmap -dump:live,format=b,file=heap.hprof 12345

dump 前评估两件事:堆有多大(dump 文件约等于堆大小,磁盘够不够)、流量有多高(STW 期间堆积多少请求)。大堆服务尽量摘流量再 dump。

第三步:MAT 支配树定位引用链。 打开 hprof 后别看直方图,直接看 ** dominator tree(支配树)**。MAT 界面左下角有个 "Dominator Tree",点开后是一棵按 Retained Heap 排序的树——截图位置就在这里:最顶上一行一般是整个堆,往下第一个占大头的分支就是泄漏对象集合。看两个数:Shallow Heap(对象自身大小)和 Retained Heap(对象被回收后能释放的总大小),泄漏排查看的是后者。比如某行 Retained Heap 300MB、对象是 ThreadLocalMap,右键 Path to GC Roots(选 exclude weak/soft references),一路点过去看到业务代码里的 static ThreadLocal<SessionContext> 没有 remove——实锤。经典案例就是线程池场景:工作线程不死,ThreadLocal 里的 SessionContext 越攒越多,histo 里表现为 ThreadLocalMap$Entry 和业务对象同步增长。

支配树比引用链好用的原因:引用链可能成百上千条(一个 Map 被 N 处引用),支配树直接告诉你"杀死这个节点能释放多少内存",把问题从"谁引用了它"简化成"留着它最亏的是谁"。

CPU 问题三步定位法

CPU 打满的排查比内存更机械,三步走,全程不超过五分钟。

bash
# 1. 找到吃 CPU 的线程(-H 显示线程)
top -Hp 12345

# 假设最忙的线程 tid 是 12711
# 2. 线程号转十六进制(jstack 里线程 nid 是十六进制)
printf "%x\n" 12711     # -> 31a7

# 3. jstack 输出里匹配
jstack 12345 | grep -A 20 "nid=0x31a7"

第三步的输出里,重点看栈顶是不是你的业务代码。两个高频命中场景:栈顶是 java.util.regex.Pattern$Node.match,正则回溯灾难——去检查业务里那个能被用户输入撑爆的正则;栈顶是 HashMap.get 且连续多层 TreeNode 调用、同一 jstack 里几十个线程都卡在同一处,那是 hashCode 都返回同一个值的对象塞进了 HashMap,链表退化成树还在反复扩容。

比手工三步更快的是 arthas 一条命令:thread -n 3 直接列出最吃 CPU 的 3 个线程和它们的栈,省去转十六进制和 grep。代价是先要往 JVM 挂 agent,有些公司的安全规范不允许,命令行三件套因此还是必练基本功。

jstack 还有个易被忽略的坑:JVM 在 CPU 打满时 jstack 本身可能超时,多试两次或者加 -F;另外一次 jstack 只是采样,严谨的做法是隔 5 秒抓三次,三次都指向同一段代码才算数。

JFR:生产可开的飞行记录仪

前面所有工具都是"出事了去查",JFR(Java Flight Recorder)反过来:一直低开销地录着,出事了回放。从 JDK 11 开源、17 正式可用,它的核心卖点是生产环境可以常开——整机上开启后 CPU 开销通常在 1% 以内(个别重负载场景配置不当会到几个点,所以要用参数约束录制预算,别裸开)。

JFR 的能力来自事件模型:垃圾回收、锁竞争、方法采样、分配采样都是一个个带时间戳和上下文的事件,录到 .jfr 文件里用 JMC(JDK Mission Control)打开分析。这比 jstack 采样强在两点:一是时间维度,能看到"过去 30 分钟哪些方法一直热";二是相关性,能对着 GC 事件和分配事件找因果,比如每次接口 RT 毛刺都紧挨着一次 Young GC 停顿,一眼关联。

分析 .jfr 时最常用的视图是 Method Profiling 样本:JFR 按固定间隔(默认约 10ms,可调)对线程采样,记录"这一刻线程正在执行哪个方法"。样本数足够多后,一个方法被采到的次数占比就近似它的 CPU 占比。JMC 里打开自动分析报告,热点方法、长暂停、分配大户会直接列出来。

启动常开录制只要两个参数:

bash
java -XX:StartFlightRecording=filename=/var/log/app.jfr,maxsize=250m,maxage=6h \
     -XX:FlightRecorderOptions=dumponexit=true -jar app.jar

maxsize/maxage 让文件循环覆盖,磁盘不会被打爆;dumponexit 保证进程退出时把缓冲里的事件落盘。拿不到 JMC 的环境(比如公司只给你命令行),jfr print --events jdk.CPULoad app.jfr 也能把关键事件吐到终端做初步筛查。

工具选型:一张决策表

把前面所有内容压成四条决策规则,出问题对号入座:

  • 一次性快照够用 -> jstat/jmap/jstack。 突发异常、能复现的问题、或者安全规范不让挂 agent 的环境。零依赖,SSH 上去就干。
  • 要持续观测、回看历史 -> JFR。 "昨晚三点到底发生了什么"这类问题只有 JFR 能答,常开录制 + maxage 滚动覆盖,事件模型还能把 GC、锁、热点方法放同一时间轴看。
  • 不能重启、要看运行时状态、要热修 -> arthas。 线上看某个方法的入参出参(watch)、反编译确认线上跑的代码版本(jad)、临时改日志级别(logger)、火焰图(profiler),这些是命令行工具做不到的。
  • dump 文件分析 -> MAT。 内存问题的终点站,支配树 + Path to GC Roots 两板斧。

arthas 的 profiler 命令值得单独记:profiler start 开始采样,profiler stop --format html 生成火焰图,比手工 jstack 采样直观一个量级,CPU 热点和锁竞争在火焰图上是一眼可见的宽窄条。

最后排一个常见的优先级误区:很多人拿到内存问题第一反应是 dump。正确顺序是 jstat 看形态、histo 找大户、确认需要引用链了才 dump。dump 是最贵的一步(STW + 大文件 + 离线分析),把它留给 histo 解决不了的场合。

常见误区与小结

  • 生产高峰期直接 jmap -dump:大堆 dump 停顿能到几十秒,流量直接打挂。先 histo,确认要引用链再摘流量 dump。
  • jstat 只看一眼就下结论:单次输出毫无意义,至少连看 10 列,看的是趋势(O 的水位线、YGC/FGC 的增速)而不是瞬时值。
  • CPU 排查拿 pid 而不是线程号去 greptop -Hp 出来的是线程 tid,要转十六进制后去 jstack 里匹配 nid,直接拿进程 pid 匹配什么都匹配不到。
  • 只抓一次 jstack 就定案:采样有偶然性,隔几秒抓三次,三次栈顶一致才可信。
  • 把 JFR 当成出事后才开的工具:它的价值在常开,maxage 滚动录制几乎零成本,事后回放恰恰是最常用姿势。

小结:这篇把 JVM 系列的落地工具串成了一条线——jstat/jmap/jstack 负责一次性快照,MAT 负责 dump 深挖,JFR 负责持续记录,arthas 负责在线交互。JVM & GC 模块到这里收尾:从内存结构、GC 算法、收集器选型、调优方法论,到今天的诊断工具箱,这一条链子已经完整。下一篇进入 MQ 模块,从消息中间件为什么存在讲起。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1