本节目标:把「服务卡住 / 内存涨 / 线程堆死」这类线上问题的定位手段系统化——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) |
| 内存 / GC | OOM、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.GCPhasePause | GC 各阶段暂停时长 |
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)
读线程转储的顺序是固定的:
- 先看有没有死锁。JVM 会主动检测并打印
Found one Java-level deadlock:段,直接给出互相持有的锁与线程名,命中就不用往下看了。 - 再按线程名分类计数。Tomcat 工作线程(
http-nio-8080-exec-*)、连接池线程、业务线程池各自的RUNNABLE/BLOCKED/WAITING分布,能快速看出是哪类资源被打满。 - 看 BLOCKED 的
waiting to lock目标。大量线程阻塞在同一个 monitor 上,说明那里是串行瓶颈(常见于误用 synchronized 的缓存或单例)。 - 看 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 采 30s | jdk.ExecutionSample 热点方法 |
| 响应慢但 CPU 不高 | 线程转储 | RUNNABLE 栈顶是否卡在 socket / JDBC |
| OOM | 确认 -XX:+HeapDumpOnOutOfMemoryError 已开 | GC.class_histogram → 堆转储 + MAT 支配树 |
| Metaspace 持续涨 | jcmd VM.metaspace | 是否频繁生成代理类(动态代理泄漏) |
| 想不重启看方法参数 | 评估后用 Arthas | watch / 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 的模块化重构 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。