Java 基础体系 · 第 18/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
JVM 诊断与性能:JFR、JMC、jcmd、线程转储、堆转储和证据链
JVM 性能问题通常不是“某一行代码慢”这么简单。一次请求变慢,可能同时经历了线程排队、锁竞争、CPU 饱和、垃圾回收暂停、堆外内存压力、I/O 阻塞和下游超时。单个工具只能观察其中一部分:
- JFR 记录 JVM 和应用在一段时间内发生了什么;
- JMC 读取并分析 JFR 录制文件;
- jcmd 通过 Attach 机制向运行中的 JVM 发出诊断命令;
- 线程转储回答某个时刻线程在哪里、处于什么状态;
- 堆转储回答对象如何存活、哪些对象占据了堆;
- 证据链把指标、日志、Trace、JFR 和各种转储按时间、进程和假设关联起来,避免凭单一现象下结论。
这些工具的共同边界是:它们观察的是 JVM 进程中的不同状态,不能自动证明业务因果关系。一个可靠的诊断过程应当从可观测性信号提出假设,再用适合的证据验证或排除假设。
一、先区分 JVM 规范、JDK 实现和诊断工具
Java 虚拟机规范定义了 JVM 的抽象执行模型,例如:
- 线程执行 Java 虚拟机栈;
- 每次方法调用产生一个栈帧;
- 栈帧包含局部变量表、操作数栈和方法返回信息;
- 堆用于存放对象和数组;
- 类和接口的运行时表示存放在方法区这一抽象区域;
- 垃圾回收器的具体算法不由规范规定。
因此,规范可以帮助我们理解“线程栈帧”和“对象引用”的语义,但不会规定:
- JFR 事件必须如何记录;
jcmd必须有哪些命令;- 线程转储的文本格式;
- HPROF 堆转储文件的具体实现细节;
- G1、ZGC 何时暂停线程。
这些属于 JDK 实现、诊断接口或工具约定。工程分析时应区分三类事实:
- 规范保证:例如 Java 线程执行方法时存在栈帧,引用关系决定对象是否可达。
- JDK 25 的常见实现行为:例如 HotSpot 提供 JFR、Attach、
jcmd、G1 和 ZGC。 - 经验性判断:例如看到一次 GC Pause 就认为 GC 导致了所有延迟升高,这并不成立,必须结合时间窗口和请求 Trace 验证。
二、JVM 诊断中的三个观察维度
可以把诊断对象分为三个维度。
1. 时间维度:JFR
JFR(Java Flight Recorder)是 JVM 内置的事件记录系统。它记录事件发生的时间、持续时间、线程、类、堆区、GC 等上下文。
它适合回答:
- 某段时间内 CPU 消耗在哪里;
- 哪些线程发生了锁竞争;
- GC 暂停何时发生、持续多久;
- 哪些方法被采样为热点;
- 是否出现异常、线程停顿或类加载活动;
- 分配速率是否在故障前升高。
JFR 是时间序列证据,但通常不是每条方法调用的完整追踪。很多执行事件使用采样方式,因此它适合识别热点区域,不适合直接计算每一次方法调用的精确耗时。
2. 瞬时状态维度:线程转储
线程转储是某个或某几个时间点的线程栈快照。它适合回答:
- 哪些线程正在等待锁;
- 哪个线程持有某个监视器;
- 线程是否卡在连接池、队列、文件或网络调用;
- 是否存在死锁;
- 大量线程是否堆积在同一个调用路径。
它不记录过去发生过什么。一次线程转储只能说明“快照时刻”的状态,不能单独证明线程此前已经阻塞了多久。
3. 对象图维度:堆转储
堆转储是某一时刻 Java 堆中对象及其引用关系的快照。它适合回答:
- 哪些对象占用了大量浅表大小;
- 哪些 GC Root 保持着不应继续存活的对象;
- 哪些集合不断增长;
- 哪些类的实例数量异常;
- 某个对象的保留堆大小为何很大。
堆转储主要观察 Java 堆,不等于进程的完整内存画像。线程栈、Metaspace、Code Cache、JNI、直接内存、Arena、JIT 编译器和本地库分配,不能仅靠普通堆转储解释。
三、JFR:记录一段时间内的 JVM 行为
3.1 JFR 事件是什么
JFR 事件通常包含以下信息:
- 事件开始时间;
- 事件持续时间;
- 发生事件的线程;
- 事件类型;
- 线程名、类名、方法名等上下文;
- 某些事件附带堆栈;
- 某些事件附带对象、类加载器或 GC 信息。
事件有两种重要形态:
- 瞬时事件:例如异常抛出、类加载、线程启动。
- 持续事件:例如一次 GC 暂停、一次锁等待或一段方法执行。
对持续事件,关键关系是:
其中:
- 是事件开始时间;
- 是持续时间;
- 是事件结束时间。
当要判断某个请求是否受到 GC 影响时,不能只看“同一分钟发生过 GC”,而应检查请求时间区间 与 GC 区间 是否重叠:
但区间重叠仍然只是时间相关性。要建立更强的因果证据,还需要确认请求线程或其依赖线程确实因该暂停、锁竞争或资源耗尽而延迟。
3.2 JFR 的开销与采样边界
JFR 通常被设计为低开销生产诊断工具,但“低开销”不等于“零开销”。
开销来自:
- 事件写入线程本地缓冲区;
- 事件堆栈采集;
- 高频分配事件;
- 方法采样;
- 磁盘刷写;
- 事件过滤和阈值判断。
JFR 的常见配置会对高频事件设置阈值。例如,某类事件只记录持续时间超过一定阈值的操作。这降低了数据量,但也意味着:
- 短于阈值的锁等待可能不出现在录制中;
- 采样热点不是完整调用计数;
- 没有记录的事件不等于没有发生;
- 调低阈值会提高诊断分辨率,也会增加开销和文件大小。
因此,JFR 结论必须带上配置上下文:使用了哪个 .jfc 配置、录制了多久、事件是否启用、阈值是多少。
四、用 jcmd 控制正在运行的 JVM
jcmd 是 JDK 提供的诊断命令行工具。它通过 JVM Attach 机制向目标进程请求执行命令。
基本形式是:
jcmd <pid> <command> [arguments]
先查看目标 JVM:
jcmd -l
典型输出类似:
18421 com.example.OrderApplication
其中 18421 是目标 JVM 的进程号。jcmd -l 的可见性受操作系统权限和进程命名空间影响。在容器环境中,宿主机 PID 和容器内 PID 可能不同;必须在能看到目标 JVM 的 PID 命名空间中执行。
查看目标 JVM 支持的命令:
jcmd 18421 help
这是一个重要的兼容性步骤。不同 JDK 版本、不同发行版和不同 JVM 实现可能存在命令差异,不应仅依赖其他环境中的记忆。
Attach 可能失败的原因包括:
- 当前用户没有权限;
- 容器中没有目标 JVM 的完整 JDK 工具;
- JVM 启动时禁用了 Attach;
- 目标进程正处于严重资源耗尽状态;
- 目标 JVM 已经无法响应诊断请求;
- 目标 PID 在当前命名空间中不可见。
例如,使用以下选项启动的 JVM 会限制 Attach:
java -XX:+DisableAttachMechanism -jar app.jar
这提高了某些安全边界,但也意味着运行时无法使用依赖 Attach 的 jcmd、jstack 等工具。若需要在该模式下诊断,应依赖启动时配置的 JFR、外部监控,或在允许的情况下使用操作系统级别的 core dump 和其他手段。
五、启动、查看和停止 JFR 录制
5.1 使用 jcmd 启动录制
一个常见的短时录制命令是:
jcmd 18421 JFR.start \
name=checkout-diagnosis \
settings=profile \
duration=120s \
filename=/var/tmp/checkout-18421.jfr
参数含义:
name=checkout-diagnosis:录制名称;settings=profile:使用较详细的分析配置;duration=120s:录制 120 秒后自动结束;filename=...:录制文件路径。
profile 通常比 default 记录更多诊断信息,可能带来更多开销和更大的文件。生产环境应根据问题严重程度和磁盘空间选择配置,而不是默认永久使用高详细度配置。
如果不指定 duration,可以手动停止:
jcmd 18421 JFR.start \
name=checkout-diagnosis \
settings=default \
filename=/var/tmp/checkout-18421.jfr
查看当前录制:
jcmd 18421 JFR.check
预期输出会列出录制名称、ID、状态以及是否设置了持续时间。停止指定录制:
jcmd 18421 JFR.stop name=checkout-diagnosis
如果录制没有预先指定文件名,可以在停止时导出:
jcmd 18421 JFR.stop \
name=checkout-diagnosis \
filename=/var/tmp/checkout-18421.jfr
具体参数以目标 JDK 的帮助输出为准:
jcmd 18421 help JFR.start
jcmd 18421 help JFR.stop
jcmd 18421 help JFR.dump
5.2 长时间运行与故障时导出
JFR 支持持续运行的录制,通常使用磁盘存储和滚动保留策略。常见思路是让 JVM 持续保留最近一段时间的事件,故障发生后再导出。这种方式能捕获故障前的上下文,但必须控制:
- 文件所在磁盘的容量;
- 文件保留时间;
- 是否允许故障时大量写盘;
- 事件详细度;
- 敏感信息的暴露风险。
故障时可以先检查:
jcmd 18421 JFR.check
然后导出某个录制:
jcmd 18421 JFR.dump \
name=checkout-diagnosis \
filename=/var/tmp/checkout-before-failure.jfr
JFR 文件可能包含类名、方法名、线程名、异常信息、路径片段和其他运行时上下文。传输到分析环境前,应按组织的数据安全要求检查内容。
5.3 使用 JDK 自带的 jfr 命令行工具
JDK 还提供 jfr 命令行工具,可以对录制文件做基本检查:
jfr summary /var/tmp/checkout-18421.jfr
它用于查看录制中有哪些事件以及事件数量。查看文本化事件:
jfr print --events jdk.GarbageCollection \
/var/tmp/checkout-18421.jfr
查看执行采样:
jfr print --events jdk.ExecutionSample \
/var/tmp/checkout-18421.jfr
事件名称和可用选项应以目标 JDK 的帮助为准:
jfr help
jfr print --help
jfr print 适合快速确认“录制是否包含我需要的证据”,不适合替代完整的时间线和聚合分析。
六、JMC:把 JFR 事件转换为诊断模型
JMC(JDK Mission Control)是用于分析 JFR 录制的工具。它不是 JVM 内部的采集器,也不是单独的监控代理。数据流是:
flowchart LR
A[运行中的 JVM] -->|JFR 事件| B[JFR 录制文件]
B --> C[jfr 命令行检查]
B --> D[JMC 导入]
D --> E[事件时间线]
D --> F[线程与锁分析]
D --> G[GC 与堆分析]
D --> H[代码与分配分析]
JMC 常见分析路径如下。
6.1 General / 概览
首先确认:
- 录制起止时间;
- JVM 版本;
- 使用的 JFR 配置;
- CPU 和内存总体趋势;
- 是否存在异常峰值;
- 录制是否覆盖故障发生时间。
如果录制起止时间不包含请求延迟异常的时间,就不能从该文件中得出相关结论。
6.2 Automated Analysis / 自动分析
自动规则可以提示:
- 长 GC 暂停;
- 高 CPU;
- 锁竞争;
- 大量分配;
- 异常线程状态;
- 类加载或代码缓存相关问题。
自动规则是筛选器,不是最终诊断。它通常基于阈值判断,可能产生:
- 误报:某次暂停较长,但没有影响关键请求;
- 漏报:每次等待很短,但大量排队造成整体吞吐下降;
- 配置偏差:事件阈值让某类事件根本没有被记录。
6.3 Threads / 线程
线程视图可以把线程活动与时间轴结合。重点不是只看某个线程当前的名字,而是识别:
- 线程是否频繁从 RUNNABLE 转为阻塞;
- 多个线程是否等待同一把锁;
- 线程池工作线程是否长期等待任务;
- 是否有线程在执行 JDBC、HTTP、文件或 DNS 调用;
- 请求线程是否把时间消耗在业务代码,还是消耗在外部依赖。
6.4 Memory / 内存和 GC
需要同时观察:
- 堆使用量;
- Old 区或老年代占用趋势;
- GC 暂停时长;
- GC 周期频率;
- 分配速率;
- 晋升或并发标记活动;
- 元空间相关事件;
- 线程本地分配缓冲区和堆外分配活动。
不能使用单一指标判断“堆太小”或“GC 太多”。例如:
- 堆使用率高但 GC 及时回收,可能只是正常的高吞吐;
- 堆使用率不高但频繁 Young GC,可能是分配速率过高;
- Full GC 很少但请求仍慢,可能是锁、CPU 或外部 I/O;
- ZGC 的暂停通常较短,但并发阶段可能消耗 CPU,导致应用线程争用计算资源。
七、JFR 中的 CPU、分配、锁和 GC 证据
7.1 CPU 热点:采样不是精确计时
jdk.ExecutionSample 一类事件通过周期性采样观察线程栈。假设在观察窗口内采样 次,其中某方法栈出现 次,可以用:
估计该方法在采样时刻占据的 CPU 或执行栈比例。
这只是统计估计,不是该方法的精确总耗时。它可能受以下因素影响:
- 采样间隔;
- 线程是否正在运行;
- 栈展开是否成功;
- 线程数量;
- 短时尖峰是否落在采样点之间。
因此,采样中某方法出现很多次,说明它是值得检查的热点;不能据此直接计算“该方法占请求总耗时的 37%”。
7.2 分配速率:高分配不等于内存泄漏
在时间窗口 内,如果记录到的分配字节数为 ,则平均分配速率为:
高分配速率会增加 GC 压力,但它通常表示对象被创建得快,不表示对象一定长期存活。
要区分两种情况:
- 高分配、低存活:大量临时对象导致 Young GC 频繁;
- 中等分配、高存活:缓存、集合、监听器或 ThreadLocal 保留对象,可能导致老年代增长。
JFR 的分配事件、GC 事件和堆转储应结合使用。仅凭“某类对象分配很多”不能证明它造成了泄漏,因为它们可能很快就被回收。
7.3 锁竞争:等待者和持有者必须同时看
Java 对象监视器竞争至少涉及两个角色:
- 等待获取锁的线程;
- 当前持有锁的线程。
如果只看到等待线程的栈,例如:
java.lang.Thread.State: BLOCKED (on object monitor)
at com.example.Cache.get(Cache.java:42)
- waiting to lock <0x000000076ab12340>
还不能判断锁为何长时间不释放。必须找出持有该监视器的线程,再检查它是否:
- 执行慢计算;
- 调用了外部 I/O;
- 进入了另一个锁;
- 发生了死循环;
- 等待数据库或线程池。
锁竞争的一个常见反例是:大量线程显示 RUNNABLE,但 CPU 并不高。这些线程可能正在执行本地方法或等待内核 I/O;RUNNABLE 不等价于“正在占用一个 CPU 核心”。
7.4 GC 事件:暂停时间与并发工作要分开
GC 相关诊断至少要区分:
- 应用线程被暂停的时间;
- GC 线程并发执行的时间;
- GC 线程消耗的 CPU;
- GC 前后堆占用;
- 回收周期之间的分配和存活变化。
对于 G1,要关注 Young GC、Mixed GC、并发标记、晋升失败和 Full GC 等路径。对于 ZGC,要同时看极短的暂停和并发 GC 阶段的 CPU 资源消耗。不能把“GC 暂停短”推导成“GC 没有性能成本”。
八、线程转储:从栈快照还原等待关系
8.1 使用 jcmd Thread.print
执行基本线程转储:
jcmd 18421 Thread.print
如果需要锁信息:
jcmd 18421 Thread.print -l
某些 JDK 版本还支持扩展信息选项,具体以帮助为准:
jcmd 18421 help Thread.print
为了判断状态是否持续,通常应在间隔几秒后连续采集三次:
for i in 1 2 3; do
date -Is
jcmd 18421 Thread.print -l > "/var/tmp/thread-$i.txt"
sleep 5
done
每次输出都记录时间。单次快照是状态,连续快照才开始具备变化证据。
8.2 常见线程状态的准确含义
java.lang.Thread.State 是 Java 层线程状态,不应直接等同于操作系统调度状态。
- RUNNABLE:线程在 JVM 中可运行,也可能正在执行本地方法或等待操作系统 I/O。
- BLOCKED:线程正在等待进入一个由其他线程持有的对象监视器。
- WAITING:线程无限期等待,例如
Object.wait()、LockSupport.park()或join()。 - TIMED_WAITING:线程带超时等待,例如
sleep()、带超时的wait()或parkNanos()。 - TERMINATED:线程已结束。
例如,下面的线程处于 WAITING,不一定是故障:
"pool-1-thread-1" #21 prio=5 os_prio=0 tid=... WAITING
at jdk.internal.misc.Unsafe.park(Native Method)
- parking to wait for <0x000000076ac00020>
at java.util.concurrent.locks.LockSupport.park(...)
at java.util.concurrent.LinkedBlockingQueue.take(...)
这可能只是线程池工作线程正常等待任务。只有当请求线程、队列长度和任务提交关系也异常时,才说明它参与了故障。
8.3 死锁分析的推导
假设有两个锁 和 ,两个线程:
- 线程 已持有 ,等待 ;
- 线程 已持有 ,等待 。
等待图为:
这是一个环,意味着没有线程能主动推进。转储中的 Found one Java-level deadlock 或等价死锁报告,通常会列出持有者和等待者。
但“没有报告死锁”不等于“没有并发问题”。以下情况可能不是 Java 监视器死锁:
ReentrantLock的业务级活锁;- 线程池互相等待;
- 数据库连接池耗尽;
- 网络读无超时;
- 锁顺序正确但临界区极慢;
- 队列生产速度持续超过消费速度。
这些问题需要把线程栈与指标、JFR、连接池状态和 Trace 结合起来。
8.4 jstack 和 jcmd 的关系
jstack <pid> 是传统线程转储工具,jcmd <pid> Thread.print 是更统一的 JDK 诊断入口。两者都依赖目标 JVM 能响应诊断请求,但输出细节和可用选项可能随 JDK 版本变化。
不要把操作系统的:
kill -3 <pid>
当成“终止进程”。在 HotSpot 常见实现中,向 JVM 发送 SIGQUIT 会触发线程信息输出,但输出位置和内容受实现、启动方式及标准输出重定向影响。它可能污染应用日志,也不便于结构化采集,因此生产环境应先确认输出路径。
九、堆转储:从 GC Root 沿引用边分析存活对象
9.1 可达性模型
垃圾回收器判断对象是否存活,本质上是从一组 GC Root 出发沿引用边遍历对象图。
设对象图为:
其中:
- 是对象集合;
- 是对象引用边;
- 是 GC Root 集合。
若对象 满足:
即存在一条从某个 GC Root 到 的引用路径,则 对当前回收判定而言是可达的。
GC Root 常见来源包括:
- 活跃线程栈中的引用;
- 静态字段;
- JNI 引用;
- JVM 内部的活动对象;
- 同步相关对象;
- 某些类加载器和运行时结构。
所以“对象没有业务引用”并不充分。只要它仍然被静态集合、ThreadLocal、线程栈或某个全局缓存间接保持,就可能无法回收。
9.2 使用 jcmd GC.heap_dump
基本命令:
jcmd 18421 GC.heap_dump /var/tmp/heap-18421.hprof
在许多 JDK 25 HotSpot 环境中,还可以使用 -all 选项包含不可达对象:
jcmd 18421 GC.heap_dump -all /var/tmp/heap-all-18421.hprof
选项和行为必须以目标 JVM 的帮助为准:
jcmd 18421 help GC.heap_dump
默认堆转储通常更关注存活对象;包含不可达对象会显著增加分析数据量,并不适合作为常规第一选择。
执行堆转储前需要确认:
- 目标路径有足够空间;
- 文件系统不是业务关键盘;
- 目标 JVM 能承受诊断操作;
- 已评估暂停或吞吐影响;
- 文件不会被无保护地暴露。
堆转储可能触发较重的 GC 或暂停行为,具体影响取决于堆大小、收集器、对象数量和 JVM 实现。即使使用并发收集器,也不能假设转储操作对延迟没有影响。
9.3 浅表大小、保留堆大小和支配关系
堆分析中至少要区分两个大小:
- Shallow heap:对象自身占用的空间,不包括它引用的对象。
- Retained heap:如果该对象以及由它支配的对象都不可达,理论上可以释放的总空间。
若缓存对象 引用了 100 万个条目,则:
- 的 shallow heap 可能很小;
- 的 retained heap 可能很大。
“按对象自身大小排序”可能找不到真正的保留者;应进一步查看支配树、保留堆和到 GC Root 的路径。
但 retained heap 也不是业务泄漏证明。它描述的是当前对象图中的保留关系,不说明:
- 对象是否本来就应该长期存活;
- 数据是否会在下一轮业务操作中被删除;
- 内存增长是否稳定;
- 对象图是否在转储期间正处于过渡状态。
可靠的泄漏判断通常需要两个或多个时间点的堆转储,对比相同类的实例数量、浅表大小、保留路径和业务生命周期。
9.4 堆转储不能解释所有内存问题
当监控显示进程 RSS 持续增长,但 Java 堆使用量正常时,堆转储可能找不到答案。需要区分:
进程常驻内存还可能包括:
- Java 线程栈;
- Metaspace;
- Code Cache;
- GC 数据结构;
- 直接内存;
- JNI 和本地库分配;
- 内存映射文件;
- glibc 分配器保留的内存;
- JIT 编译器和类元数据。
可以在 JVM 启动时启用 Native Memory Tracking:
java -XX:NativeMemoryTracking=summary -jar app.jar
然后查询:
jcmd 18421 VM.native_memory summary
如果需要比较两个时间点:
jcmd 18421 VM.native_memory baseline
# 等待一段时间后
jcmd 18421 VM.native_memory summary.diff
NMT 需要启动时开启,不能在普通运行过程中可靠地补开;它自身也会带来开销。直接内存还应结合应用框架配置、BufferPoolMXBean、容器限制和本地分配工具分析。
十、不同收集器下的诊断边界
10.1 G1
G1 将堆划分为 Region,并通过年轻代、混合回收和并发标记管理对象生命周期。诊断时不能只看“堆使用率”,还应观察:
- Eden 分配是否过快;
- Survivor 和晋升是否异常;
- Mixed GC 是否能回收足够老年代;
- 是否发生 Full GC;
- GC 暂停目标是否经常无法满足;
- 大对象是否占据过多 Humongous Region。
如果 Full GC 后老年代仍持续增长,堆转储可以检查存活对象;如果 GC 后存活集正常但暂停仍长,则应进一步看根扫描、更新引用、类卸载、系统资源和 CPU。
10.2 ZGC
ZGC 主要通过并发阶段降低停顿,但应用仍可能受到:
- 并发 GC 线程消耗 CPU;
- 分配速率过高;
- 堆空间不足;
- 重定位或根处理阶段;
- 容器 CPU 配额过低。
因此不能把“暂停时间很短”误解为“GC 没有成本”。JFR 中应同时分析暂停事件、并发 GC 活动、CPU Load、分配速率和应用线程采样。
10.3 堆栈、元空间与堆不是同一个区域
“栈溢出”通常意味着某个线程的 Java 虚拟机栈无法继续扩展,典型表现是 StackOverflowError,常见原因是递归调用或栈帧过深。它不是堆耗尽。
“元空间不足”通常表现为 OutOfMemoryError: Metaspace,可能与动态生成类、类加载器泄漏或过多代理类有关。堆转储中可以看到类加载器和对象引用,但还应结合类加载事件和元空间指标。
“堆耗尽”通常表现为 OutOfMemoryError: Java heap space 或 GC overhead 相关错误。它需要结合 GC 日志、JFR 和堆转储确认是瞬时分配峰值、长期存活增长还是堆配置不足。
十一、JFR、线程转储和堆转储的端到端示例
下面给出一个可运行的故障复现程序。它同时制造:
- 一个持锁后睡眠的线程;
- 一个等待该锁的线程;
- 一个持续分配并保留字符串的列表。
保存为 DiagnosticDemo.java:
import java.util.ArrayList;
import java.util.List;
public class DiagnosticDemo {
private static final Object LOCK = new Object();
private static final List<byte[]> LEAK = new ArrayList<>();
public static void main(String[] args) throws Exception {
Thread holder = new Thread(() -> {
synchronized (LOCK) {
try {
Thread.sleep(30_000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}, "lock-holder");
Thread waiter = new Thread(() -> {
synchronized (LOCK) {
System.out.println("waiter acquired lock");
}
}, "lock-waiter");
holder.start();
Thread.sleep(200);
waiter.start();
Thread allocator = new Thread(() -> {
while (true) {
LEAK.add(new byte[1024 * 1024]);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}, "allocator");
allocator.start();
holder.join();
waiter.join();
allocator.join();
}
}
编译并运行:
javac DiagnosticDemo.java
java -XX:StartFlightRecording=filename=/var/tmp/demo.jfr,settings=profile,duration=60s DiagnosticDemo
也可以先运行,再通过 jcmd 控制 JFR:
java DiagnosticDemo &
PID=$!
jcmd "$PID" JFR.start \
name=demo \
settings=profile \
duration=60s \
filename=/var/tmp/demo.jfr
11.1 线程转储的预期证据
执行:
jcmd "$PID" Thread.print -l
预期可以看到:
lock-holder处于TIMED_WAITING,栈中有Thread.sleep;lock-waiter处于BLOCKED,并显示等待LOCK对应的监视器;allocator处于RUNNABLE或短暂睡眠状态。
推导过程是:
lock-holder进入synchronized (LOCK),获得监视器;- 它调用
sleep,但睡眠不会释放synchronized持有的监视器; lock-waiter尝试进入同一临界区;- 因为监视器仍被持有,
lock-waiter进入BLOCKED; lock-holder醒来并退出同步块后,等待者才可能继续。
这个例子也说明了一个常见误解:Thread.sleep() 会让出 CPU,但不会自动释放 Java 对象监视器。
11.2 堆转储的预期证据
执行:
jcmd "$PID" GC.heap_dump /var/tmp/demo.hprof
在 Eclipse MAT、JMC 支持的堆分析工具或其他 HPROF 分析工具中,可以看到大量 byte[] 实例。沿着 GC Root 路径向上追踪,通常会经过:
GC Root
-> DiagnosticDemo.LEAK
-> ArrayList.elementData
-> byte[]
这比“byte[] 很多”更有价值,因为它解释了对象为什么不能回收:静态字段 LEAK 持有集合,集合持有数组。
如果只执行一次转储,仍不能严格区分:
- 程序有意缓存;
- 程序忘记删除;
- 只是测试代码短时间积累。
需要结合业务语义和多个时间点的增长趋势。
11.3 JFR 的预期证据
检查事件:
jfr summary /var/tmp/demo.jfr
查看线程相关事件:
jfr print --events jdk.JavaMonitorEnter \
/var/tmp/demo.jfr
查看分配相关事件:
jfr print --events jdk.ObjectAllocationInNewTLAB,jdk.ObjectAllocationOutsideTLAB \
/var/tmp/demo.jfr
配置是否记录这些事件取决于 JFR 配置和阈值。如果没有结果,不能直接推断程序没有锁竞争或分配,而要先检查录制配置是否启用了对应事件。
十二、把诊断结果组织成证据链
证据链不是工具列表,而是从问题到结论的可复核推理过程。一个实用结构是:
flowchart TD
A[SLO 或业务指标异常] --> B[确定时间窗口和影响范围]
B --> C[提出可证伪假设]
C --> D[指标与日志定位]
D --> E[JFR 观察时间线与 JVM 事件]
D --> F[线程转储观察瞬时等待关系]
D --> G[堆转储观察对象存活关系]
E --> H[形成或排除因果链]
F --> H
G --> H
H --> I[实施最小变更]
I --> J[用相同指标和窗口验证]
12.1 从 SLO 开始,而不是从工具开始
例如,某接口的 p99 从 200 ms 上升到 2 s。先记录:
- 发生时间;
- 受影响实例;
- 请求量;
- p50、p95、p99;
- 错误率;
- CPU、内存、GC、线程池、连接池;
- Trace 中各下游 span 的耗时。
然后提出互相可区分的假设:
- H1:GC 暂停导致请求线程延迟;
- H2:锁竞争导致请求排队;
- H3:数据库连接池耗尽;
- H4:CPU 配额不足;
- H5:某版本代码分配速率或缓存增长异常。
一个好的假设必须能被证据排除。例如:
如果 H1 成立,那么异常请求的时间区间应与较长 GC 暂停重叠,并且多个实例或受影响线程会出现相应停顿证据。
12.2 指标、日志和 Trace 负责业务定位
JFR 不知道每个业务请求的完整因果关系,除非应用通过自定义 JFR 事件或其他机制补充业务上下文。因此应先用:
- Micrometer 指标确定哪个实例、哪个接口、哪个时间段异常;
- 日志确定错误、超时和重试;
- OpenTelemetry Trace 确定请求在哪个服务或下游阶段变慢;
- SLO 确定问题是否真的影响用户目标。
然后把这些时间点与 JFR 的 JVM 时间线对齐。
时间对齐时应注意:
- 使用统一时区展示;
- 保留原始时间戳;
- 记录实例 ID、容器 ID、JVM PID 和版本;
- 记录 JFR 文件的起止时间;
- 不要只用“分钟级”日志文本进行相关性判断。
12.3 三类典型证据链
结论一:GC 是主要延迟来源
应有类似链条:
- Trace 显示请求耗时集中在 JVM 内部,而非下游 span;
- 异常时间段存在明显 GC 暂停;
- 请求时间区间与暂停区间重叠;
- 多个请求或线程在相同时间出现停顿;
- GC 前后堆变化、分配速率和收集器行为相互一致;
- 调整或修复后,GC 暂停和 p99 同时改善。
仅有“GC 次数多”不能成立,因为高频短暂停可能不影响 p99。
结论二:锁竞争是主要来源
应有类似链条:
- 线程池队列长度或活动线程数异常;
- 多个线程转储中,大量线程等待同一监视器或锁;
- 能找到持锁线程;
- 持锁线程的 JFR 栈或线程转储显示临界区过长、外部调用或锁嵌套;
- Trace 中请求等待时间与锁竞争窗口一致;
- 缩小临界区或修复锁顺序后,等待线程数量和延迟下降。
单次看到一个 BLOCKED 线程不足以证明系统性锁竞争。
结论三:Java 堆泄漏
应有类似链条:
- Old 区或存活集在多个 GC 周期后持续增长;
- 两个时间点的堆转储显示同类对象数量和 retained heap 增长;
- 对象通过静态集合、ThreadLocal、监听器或类加载器路径保持;
- 该引用关系与业务生命周期不匹配;
- 修复清理逻辑后,存活集不再持续增长。
“堆使用率高”只说明当前占用高,不足以证明泄漏。
十三、生产环境中的操作风险、验证和恢复
13.1 JFR 的风险
风险主要包括:
- 事件量过大导致额外 CPU 和磁盘开销;
- 高详细度录制生成较大文件;
- 录制文件包含敏感类名、路径或异常上下文;
- 在磁盘空间不足时影响应用;
- 使用错误的 PID 或文件路径。
验证方式:
jcmd 18421 JFR.check
jfr summary /var/tmp/checkout-18421.jfr
df -h /var/tmp
如果录制不再需要,应停止并清理:
jcmd 18421 JFR.stop name=checkout-diagnosis
rm -f /var/tmp/checkout-18421.jfr
不要在没有保留策略和磁盘监控的情况下无限制开启高详细度录制。
13.2 线程转储的风险
线程转储通常比堆转储轻,但在极端线程数量下仍会:
- 产生很大输出;
- 增加日志或终端压力;
- 暂时消耗诊断线程和 I/O;
- 暴露线程名、类名、连接地址或业务参数片段。
采集时应保存原始文件并加时间戳,不要只复制人工筛选后的几行。恢复通常不需要“停止线程”;线程转储本身是观察操作。如果确认是不可恢复的业务死锁,恢复动作应是重启实例或切换流量,但这会丢失现场,因此应先采集 JFR、线程转储、指标和日志。
13.3 堆转储的风险
堆转储是风险最高的常用 JVM 诊断操作之一:
- 文件可能接近堆规模;
- 操作可能引发较长暂停;
- 生成和传输都需要大量磁盘 I/O;
- 文件可能包含敏感数据;
- 对已经接近 OOM 的 JVM 可能使故障恶化。
生产操作顺序通常应是:
- 确认实例是否仍承载全部流量;
- 必要时先摘除实例;
- 确认目标磁盘容量;
- 采集 JFR 和线程转储;
- 在可接受窗口内执行堆转储;
- 校验文件完整性;
- 保护和传输文件;
- 分析完成后安全删除。
校验示例:
sha256sum /var/tmp/heap-18421.hprof
ls -lh /var/tmp/heap-18421.hprof
如果 JVM 已经频繁 OOM,优先考虑配置 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath=...,让故障发生时自动生成转储。但自动转储同样需要提前规划磁盘和敏感数据处理。
十四、常见误判及其修正
误判一:CPU 高,所以某个方法一定是根因
JFR 执行采样只能说明某方法栈经常出现在采样点。它可能是正常的工作热点,也可能只是被调用路径的末端。应结合:
- 请求量是否变化;
- 单请求 CPU 是否变化;
- 方法输入规模是否变化;
- Trace 和业务计数;
- 优化后的回归结果。
误判二:线程显示 RUNNABLE,所以线程没有等待
RUNNABLE 可能包含执行本地方法、等待 I/O 或可运行但尚未被操作系统调度的线程。要结合:
- 栈顶方法;
- OS 级 CPU;
- JFR 执行采样;
- 系统调用或网络指标。
误判三:堆占用高,所以存在内存泄漏
高堆占用可能是正常缓存,也可能是高存活集、堆配置偏小或故障前的业务峰值。必须观察 GC 后存活集和多个堆转储中的增长路径。
误判四:发生 Full GC,所以 Full GC 导致了所有超时
Full GC 可能是结果而不是根因。例如某个缓存泄漏使老年代持续增长,最终触发 Full GC。应先看对象保留路径,再看 Full GC 的时间和请求影响。
误判五:JFR 没有事件,所以问题不存在
事件可能因为以下原因未被记录:
- 配置未启用;
- 持续时间低于阈值;
- 采样未命中;
- 录制时间窗口错误;
- 事件名称或版本理解错误。
jfr summary、JMC 的事件列表和实际 .jfc 配置是判断“是否观察到”的前置证据。
误判六:JMC 的自动规则就是结论
JMC 规则是基于事件和阈值的分析建议。它没有业务语义,也不知道某个延迟是否违反了哪个 SLO。规则结果必须回到请求指标、Trace 和代码上下文中验证。
十五、一个可重复的诊断流程
面对一次 JVM 性能故障,可以按以下顺序形成最小但完整的证据包:
- 确定问题窗口:记录开始时间、结束时间、实例、版本和 SLO 影响。
- 保存业务信号:请求延迟分位数、错误率、吞吐、Trace 和关键日志。
- 采集 JFR:优先覆盖故障前后,而不是只录故障已经结束的时间。
- 采集线程转储:间隔数秒获取多份,观察等待关系是否稳定。
- 检查 JVM 和系统状态:堆、GC、CPU、线程池、连接池、容器限制。
- 有明确假设后再采集堆转储:避免把高风险操作当成常规探测。
- 把证据按时间线关联:确认事件区间、线程、实例和代码版本一致。
- 实施最小变更:例如修复锁持有范围、限制缓存、调整分配路径或修复下游超时。
- 用相同指标验证:比较变更前后的 p99、GC、线程等待、存活集和业务吞吐。
最终报告应能够回答四个问题:
- 发生了什么;
- 哪些证据支持该判断;
- 哪些替代假设已被排除;
- 修复后哪些可观测信号发生了预期变化。
JFR 给出时间连续性,线程转储给出并发瞬间,堆转储给出对象关系,JMC 和命令行工具负责把原始数据变成可检索的结构。只有把它们与 Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO 连接起来,JVM 诊断才会从“看到了异常”推进到“证明了故障路径”。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:JVM 内存与垃圾回收:堆、栈、元空间、G1、ZGC 和调优
- 下一篇:Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试
- 延伸:Java 可观测性:Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论