《Spring Boot 高级》10.3 生产问题诊断手段

从 CPU、延迟、内存三个问题域出发,用本机 JDK 21 实测的 jcmd 与 JFR 输出演示 VM.flags、GC.heap_info、Thread.print、GC.class_histogram 的读法,讲清 JFR 记录了什么、线程转储怎么找死锁、堆转储怎么拿与怎么分析,并给出 Arthas 类在线诊断工具的适用边界与风险。

本节目标:把「服务卡住 / 内存涨 / 线程堆死」这类线上问题的定位手段系统化——JFR 记录什么、jcmd 有哪些子命令、线程转储与堆转储怎么拿与怎么读、在线诊断工具的边界在哪。
适用版本:Spring Boot 4.1.x(Java 21)

10.3 生产问题诊断手段

前两节讲的是「正常运行时可观测性」,本节讲「不正常时怎么取证」。所有 jcmd / jfr / jstack 输出都来自本机 JDK Temurin 21.0.12.1+1(路径 /tmp/springboot_book/jdk-21.0.12.1+1/Contents/Home)对同一个 Spring Boot 4.1.1 借阅服务进程的实际采集;async-profiler 本机未安装,涉及它的内容一律标注「示例输出」。

10.3.1 三个问题域与工具映射

线上问题先分域,再选工具。分错了域,用错工具,只会拿到一堆看不懂的输出:

问题域典型现象首选工具
CPU / 热点CPU 打满、单请求慢JFR(jdk.ExecutionSample)、async-profiler(火焰图)
线程 / 并发请求堆积、响应超时、死锁线程转储(jcmd Thread.print / jstack)
内存 / GCOOM、Full GC 频繁、堆持续涨堆转储(GC.heap_dump)、GC.class_histogram、JFR GC 事件

一个反直觉的点:「CPU 打满」和「响应超时」经常不是同一件事。CPU 打满要看热点在哪(JFR 采样),而响应超时往往是线程在等锁或等 IO(线程转储)。先看线程转储确定「线程都在干什么」,再用 JFR 定位 CPU 花在哪,比一上来就抓火焰图更省事。

10.3.2 JFR:低开销的飞行记录仪

JFR(Java Flight Recorder)是 JDK 自带的运行时事件记录器,模块是 jdk.jfr,命令行工具是 jfr。本机实测:

$ jfr --version
21.0.12.1

它的价值在低开销:默认配置下对吞吐的影响通常在个位数百分比量级,可以常开。JFR 记录的是结构化事件,不是文本日志。本机对一个运行中的进程采了 5 秒 profile 配置的记录,再用 jfr summary 读事件计数(本机实测输出,已截断):

 Version: 2.1
 Chunks: 1
 Start: 2026-10-09 10:42:16 (UTC)
 Duration: 5 s

 Event Type                              Count  Size (bytes)
=============================================================
 jdk.NativeLibrary                         852         73171
 jdk.NativeMethodSample                    230          2300
 jdk.ThreadPark                             10           367
 jdk.GCHeapMemoryPoolUsage                   6           228
 jdk.SafepointBegin                          8           109
 jdk.ClassLoaderStatistics                  10           268

事件类型本身说明了 JFR 能回答什么。用 jfr metadata 能列出全部事件定义(本机实测,节选):

@Name("jdk.ExecutionSample")
@Name("jdk.GCPhasePause")
@Name("jdk.ObjectAllocationSample")
@Name("jdk.ObjectAllocationInNewTLAB")
@Name("jdk.ThreadStart")

对应到问题:

事件回答什么
jdk.ExecutionSample方法级 CPU 采样(火焰图的数据源)
jdk.ObjectAllocationSample谁在分配对象(内存涨的元凶)
jdk.GCPhasePauseGC 各阶段暂停时长
jdk.ThreadPark / jdk.ThreadStart线程阻塞与创建
jdk.NativeMethodSample本地方法(JNI)耗时

启动方式有两条:进程内 jcmd <pid> JFR.start(见下节),或启动参数 -XX:StartFlightRecording=duration=60s,filename=app.jfr,settings=profile。settings 有 default(低开销)与 profile(采样更密、开销更高)两档。读文件用 jfr summary(概览)、jfr print --events jdk.ExecutionSample app.jfr(明细)、jfr view hot-methods app.jfr(汇总视图)。

10.3.3 jcmd:一个入口打所有诊断命令

jcmd 是 JDK 诊断的统一入口。先 jcmd -l 列出进程,本机实测:

$ jcmd -l
99193 target/probe-0.0.1-SNAPSHOT.jar

拿到 PID 后 jcmd <pid> help 会列出该 JVM 支持的全部命令(本机实测,节选):

GC.class_histogram      GC.finalizer_info      GC.heap_dump
GC.heap_info            GC.run                 JFR.check
JFR.dump                JFR.start              JFR.stop
Thread.dump_to_file     Thread.print           VM.flags
VM.system_properties    VM.uptime              VM.version
VM.class_hierarchy      VM.native_memory       VM.metaspace

几个最常用的(输出均为本机实测):

$ jcmd 99193 VM.flags
-XX:InitialHeapSize=536870912 -XX:MaxHeapSize=8589934592 -XX:+UseG1GC
-XX:+HeapDumpOnOutOfMemoryError -XX:MaxNewSize=5150605312 ...

VM.flags 是确认「线上到底用的哪套 GC、堆多大、有没有开 OOM 转储」的最快方式——很多时候问题就出在「参数没生效」上。接着看堆:

$ jcmd 99193 GC.heap_info
 garbage-first heap   total 69632K, used 20725K [0x0000000300800000, 0x0000000500800000)
  region size 4096K, 4 young (16384K), 2 survivors (8192K)
 Metaspace       used 29490K, committed 30080K, reserved 1114112K
  class space    used 3799K, committed 4096K, reserved 1048576K

这里能一眼看出:用的是 G1(garbage-first heap)、年轻代占用、Metaspace 用量。Metaspace 持续涨通常是类加载泄漏(频繁生成代理类、热部署)。

10.3.4 线程转储与死锁定位

jstack <pid> 等价于 jcmd <pid> Thread.print,两者输出一致。本机实测的头部:

$ jcmd 99193 Thread.print
2026-10-09 18:42:15
Full thread dump OpenJDK 64-Bit Server VM (21.0.12.1+1-LTS mixed mode, sharing):

"Reference Handler" #9 [30467] daemon prio=10 os_prio=31 cpu=1.73ms elapsed=17.98s ...
   java.lang.Thread.State: RUNNABLE
	at java.lang.ref.Reference.waitForReferencePendingList(Native Method)

读线程转储的顺序是固定的:

  1. 先看有没有死锁。JVM 会主动检测并打印 Found one Java-level deadlock: 段,直接给出互相持有的锁与线程名,命中就不用往下看了。
  2. 再按线程名分类计数。Tomcat 工作线程(http-nio-8080-exec-*)、连接池线程、业务线程池各自的 RUNNABLE / BLOCKED / WAITING 分布,能快速看出是哪类资源被打满。
  3. 看 BLOCKED 的 waiting to lock 目标。大量线程阻塞在同一个 monitor 上,说明那里是串行瓶颈(常见于误用 synchronized 的缓存或单例)。
  4. 看 RUNNABLE 且栈顶在 socket read / JDBC。说明卡在外部 IO,不是 CPU 问题,要查下游或连接池。

本机还实测了 JSON 格式导出,方便脚本化分析:

$ jcmd 99193 Thread.dump_to_file -format=json /tmp/td.json
Created /tmp/td.json

-format=json 产出的结构化转储可以直接喂给分析脚本或可视化工具,比文本更适合做「多份转储的差分对比」(比如间隔 10 秒抓两次,看哪些线程一直卡在同一栈上)。

10.3.5 堆转储:拿下来再分析

OOM 的第一道防线是启动参数 -XX:+HeapDumpOnOutOfMemoryError——OOM 发生瞬间自动落盘堆快照。本机实测确认这个参数确实出现在运行进程的 flags 里(见 10.3.3 的 VM.flags)。

线上也可以主动抓:

$ jcmd <pid> GC.heap_dump /tmp/app.hprof

或用 jmap -dump:format=b,file=/tmp/app.hprof <pid>。抓堆转储会触发 STW(Stop-The-World)并产生与堆同样大的文件,对生产是有代价的,务必确认磁盘与暂停窗口。

分析工具的选择要注意时代变化:

工具状态说明
Eclipse MAT推荐支配树(dominator tree)、retained size、泄漏嫌疑报告
VisualVM可用需装插件,图形化看直方图与引用链
jhat已在 JDK 9 移除老教程里的 jhat 现在跑不了
jfr print辅助JFR 的分配事件可与堆分析互相印证

MAT 里最该看的是支配树:它按「retained size」排序,直接指出「谁一旦被回收能释放最多内存」。堆直方图(jcmd <pid> GC.class_histogram,本机实测节选)只给按类聚合的实例数,能快速看出「哪个类的实例异常多」,但看不到引用链:

$ jcmd 99193 GC.class_histogram
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:         58288        3364304  [B (java.base@21.0.12.1)
   2:         56389        1353336  java.lang.String (java.base@21.0.12.1)
   3:          7875         938216  java.lang.Class (java.base@21.0.12.1)

两者配合:先用 GC.class_histogram 看哪个类多,再用堆转储 + MAT 看是谁持有这些对象不放。

10.3.6 jmap 与 jhsdb 的定位

jmap 与 jcmd 有大量重叠:jmap -histo 就是 jcmd GC.class_histogram,jmap -dump 就是 jcmd GC.heap_dump,jmap -clstats 对应 jcmd VM.classloader_stats。新代码统一用 jcmd,因为 jcmd 能对不支持 attach 的场景做 -f 批量命令,且是官方推荐入口;jmap 保留主要为了兼容老脚本。

jhsdb 是服务性调试工具(serviceability agent),定位是事后(post-mortem)分析:进程已经挂了,只剩 core dump 或 hprof 时,用 jhsdb jmap --heap --pid <pid> 看堆布局、jhsdb jstack --pid <pid> 看线程栈。本机确认 jhsdb 存在,其子命令为:

clhsdb        command line debugger
hsdb          ui debugger
jstack        ...
jmap          ...
jinfo         ...
jsnap         ...

jhsdb 需要 --pid(活进程)或 --core/--exe(core 文件)模式,且对 GC 实现有要求。日常排障优先 jcmd / jstack,jhsdb 留给「进程已死、只能看快照」的场景。

10.3.7 在线诊断工具:Arthas 之类

Arthas 这类 attach 式工具的价值在于不重启就能看运行时:watch 观测方法出入参、trace 看方法内部耗时分布、tt 录制调用现场、ognl 直接读 bean 属性、dashboard 看线程与内存。对「线上没法加日志、又必须看某个方法的实际参数」的场景,它几乎是唯一选择。

但它的风险必须讲清:

风险说明
字节码增强watch / trace 靠增强目标类实现,会改变方法执行路径,极端情况下引发 ClassCircularityError 或性能骤降
重定义限制受 JVM retransform 约束,不能改方法签名、加字段;退出时需 stop 卸载增强
attach 代价attach 到高负载进程会触发安全点,本身就可能造成秒级停顿
生产权限attach 相当于在进程内执行任意代码,必须走审批与审计,禁止在生产随意 ognl 改状态
与 AOT / native 不兼容GraalVM native image 下没有运行时可增强的字节码,这类工具用不了

原则:优先用「只读、无副作用」的手段(线程转储、JFR、堆转储),确认不够用时再用增强型工具,并且只在预发或经审批的生产窗口使用,用完立即 stop。

async-profiler 属于 CPU/分配采样工具,能出火焰图,本机未安装,下面是对比 JFR 采样的示例输出(非实测):

# 示例输出(本机未安装 async-profiler,非实测)
$ asprof -d 30 -f /tmp/flame.html <pid>
Profiling for 30s... done
  Total samples: 128934
  Top frame:  com.example.loan.LoanService.borrow

10.3.8 排障决策清单

现场现象先做什么再看什么
服务无响应、请求堆积jcmd <pid> Thread.print线程状态分布、Found one Java-level deadlock
CPU 打满JFR settings=profile 采 30sjdk.ExecutionSample 热点方法
响应慢但 CPU 不高线程转储RUNNABLE 栈顶是否卡在 socket / JDBC
OOM确认 -XX:+HeapDumpOnOutOfMemoryError 已开GC.class_histogram → 堆转储 + MAT 支配树
Metaspace 持续涨jcmd VM.metaspace是否频繁生成代理类(动态代理泄漏)
想不重启看方法参数评估后用 Arthaswatch / trace,用完 stop

10.3.9 知道之后能做什么

把诊断参数固化成启动模板。 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -XX:StartFlightRecording=... 写进容器启动参数,问题发生时自动留证,比事后想办法复现高效得多。

给线程转储加时间维度。 用 Thread.dump_to_file -format=json 间隔抓两份,差分出「一直卡在同一栈」的线程——单份转储看不出「卡住」,两份的差才能。

用 JFR 反查指标异常。 指标显示 P99 抖动时,同期抓一段 JFR,用 jdk.GCPhasePause 与 jdk.ExecutionSample 对齐时间线,能判断抖动来自 GC 还是业务热点。

守住 attach 的边界。 把「生产 attach 需审批、用完 stop」写进运维规范,避免在线工具从「救火」变成「新的故障源」。

小结

  • 先分问题域(CPU / 线程 / 内存)再选工具,用错工具只会拿到读不懂的输出。
  • JFR 是低开销的结构化事件记录器,jdk.ExecutionSample / jdk.ObjectAllocationSample / jdk.GCPhasePause 分别对应热点、分配、GC 暂停。
  • jcmd 是统一入口:VM.flags 验参数、GC.heap_info 看堆、Thread.print 看线程、GC.class_histogram 看类分布。
  • 线程转储先找死锁,再按线程名分类,最后看 BLOCKED 的锁目标。
  • 堆转储有 STW 代价;分析用 MAT 支配树,jhat 已在 JDK 9 移除。
  • jcmd 取代 jmap 做常规操作,jhsdb 留给事后分析。
  • Arthas 类在线工具能力最强、风险也最高,只读手段不够用时才上,用完即停。

第 10 章到此结束。下一章回到框架本身:Spring Boot 4 的模块化重构到底把什么拆开了,以及从 3.x 迁到 4.x 的完整清单与回滚策略。

阅读导航:上一节:10.2 分布式追踪实现 · 下一节:11.1 Spring Boot 4 的模块化重构 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计