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 实现、诊断接口或工具约定。工程分析时应区分三类事实:

  1. 规范保证:例如 Java 线程执行方法时存在栈帧,引用关系决定对象是否可达。
  2. JDK 25 的常见实现行为:例如 HotSpot 提供 JFR、Attach、jcmd、G1 和 ZGC。
  3. 经验性判断:例如看到一次 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 信息。

事件有两种重要形态:

  1. 瞬时事件:例如异常抛出、类加载、线程启动。
  2. 持续事件:例如一次 GC 暂停、一次锁等待或一段方法执行。

对持续事件,关键关系是:

tend=tstart+dt_{\text{end}} = t_{\text{start}} + d

其中:

  • tstartt_{\text{start}} 是事件开始时间;
  • dd 是持续时间;
  • tendt_{\text{end}} 是事件结束时间。

当要判断某个请求是否受到 GC 影响时,不能只看“同一分钟发生过 GC”,而应检查请求时间区间 [rs,re][r_s, r_e] 与 GC 区间 [gs,ge][g_s, g_e] 是否重叠:

max(rs,gs)<min(re,ge)\max(r_s, g_s) < \min(r_e, g_e)

但区间重叠仍然只是时间相关性。要建立更强的因果证据,还需要确认请求线程或其依赖线程确实因该暂停、锁竞争或资源耗尽而延迟。

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 的 jcmdjstack 等工具。若需要在该模式下诊断,应依赖启动时配置的 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 一类事件通过周期性采样观察线程栈。假设在观察窗口内采样 NN 次,其中某方法栈出现 kk 次,可以用:

p^=kN\hat{p} = \frac{k}{N}

估计该方法在采样时刻占据的 CPU 或执行栈比例。

这只是统计估计,不是该方法的精确总耗时。它可能受以下因素影响:

  • 采样间隔;
  • 线程是否正在运行;
  • 栈展开是否成功;
  • 线程数量;
  • 短时尖峰是否落在采样点之间。

因此,采样中某方法出现很多次,说明它是值得检查的热点;不能据此直接计算“该方法占请求总耗时的 37%”。

7.2 分配速率:高分配不等于内存泄漏

在时间窗口 [t0,t1][t_0,t_1] 内,如果记录到的分配字节数为 BB,则平均分配速率为:

Ralloc=Bt1t0R_{\text{alloc}} = \frac{B}{t_1-t_0}

高分配速率会增加 GC 压力,但它通常表示对象被创建得快,不表示对象一定长期存活。

要区分两种情况:

  • 高分配、低存活:大量临时对象导致 Young GC 频繁;
  • 中等分配、高存活:缓存、集合、监听器或 ThreadLocal 保留对象,可能导致老年代增长。

JFR 的分配事件、GC 事件和堆转储应结合使用。仅凭“某类对象分配很多”不能证明它造成了泄漏,因为它们可能很快就被回收。

7.3 锁竞争:等待者和持有者必须同时看

Java 对象监视器竞争至少涉及两个角色:

  1. 等待获取锁的线程;
  2. 当前持有锁的线程。

如果只看到等待线程的栈,例如:

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 死锁分析的推导

假设有两个锁 L1L_1L2L_2,两个线程:

  • 线程 T1T_1 已持有 L1L_1,等待 L2L_2
  • 线程 T2T_2 已持有 L2L_2,等待 L1L_1

等待图为:

T1L2T2L1T1T_1 \rightarrow L_2 \rightarrow T_2 \rightarrow L_1 \rightarrow T_1

这是一个环,意味着没有线程能主动推进。转储中的 Found one Java-level deadlock 或等价死锁报告,通常会列出持有者和等待者。

但“没有报告死锁”不等于“没有并发问题”。以下情况可能不是 Java 监视器死锁:

  • ReentrantLock 的业务级活锁;
  • 线程池互相等待;
  • 数据库连接池耗尽;
  • 网络读无超时;
  • 锁顺序正确但临界区极慢;
  • 队列生产速度持续超过消费速度。

这些问题需要把线程栈与指标、JFR、连接池状态和 Trace 结合起来。

8.4 jstackjcmd 的关系

jstack <pid> 是传统线程转储工具,jcmd <pid> Thread.print 是更统一的 JDK 诊断入口。两者都依赖目标 JVM 能响应诊断请求,但输出细节和可用选项可能随 JDK 版本变化。

不要把操作系统的:

kill -3 <pid>

当成“终止进程”。在 HotSpot 常见实现中,向 JVM 发送 SIGQUIT 会触发线程信息输出,但输出位置和内容受实现、启动方式及标准输出重定向影响。它可能污染应用日志,也不便于结构化采集,因此生产环境应先确认输出路径。


九、堆转储:从 GC Root 沿引用边分析存活对象

9.1 可达性模型

垃圾回收器判断对象是否存活,本质上是从一组 GC Root 出发沿引用边遍历对象图。

设对象图为:

G=(V,E)G=(V,E)

其中:

  • VV 是对象集合;
  • EE 是对象引用边;
  • RVR \subseteq V 是 GC Root 集合。

若对象 vv 满足:

rR,rv\exists r \in R,\quad r \leadsto v

即存在一条从某个 GC Root 到 vv 的引用路径,则 vv 对当前回收判定而言是可达的。

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:如果该对象以及由它支配的对象都不可达,理论上可以释放的总空间。

若缓存对象 CC 引用了 100 万个条目,则:

  • CC 的 shallow heap 可能很小;
  • CC 的 retained heap 可能很大。

“按对象自身大小排序”可能找不到真正的保留者;应进一步查看支配树、保留堆和到 GC Root 的路径。

但 retained heap 也不是业务泄漏证明。它描述的是当前对象图中的保留关系,不说明:

  • 对象是否本来就应该长期存活;
  • 数据是否会在下一轮业务操作中被删除;
  • 内存增长是否稳定;
  • 对象图是否在转储期间正处于过渡状态。

可靠的泄漏判断通常需要两个或多个时间点的堆转储,对比相同类的实例数量、浅表大小、保留路径和业务生命周期。

9.4 堆转储不能解释所有内存问题

当监控显示进程 RSS 持续增长,但 Java 堆使用量正常时,堆转储可能找不到答案。需要区分:

RSSJava Heap Used\text{RSS} \neq \text{Java Heap Used}

进程常驻内存还可能包括:

  • 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 或短暂睡眠状态。

推导过程是:

  1. lock-holder 进入 synchronized (LOCK),获得监视器;
  2. 它调用 sleep,但睡眠不会释放 synchronized 持有的监视器;
  3. lock-waiter 尝试进入同一临界区;
  4. 因为监视器仍被持有,lock-waiter 进入 BLOCKED
  5. 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 是主要延迟来源

应有类似链条:

  1. Trace 显示请求耗时集中在 JVM 内部,而非下游 span;
  2. 异常时间段存在明显 GC 暂停;
  3. 请求时间区间与暂停区间重叠;
  4. 多个请求或线程在相同时间出现停顿;
  5. GC 前后堆变化、分配速率和收集器行为相互一致;
  6. 调整或修复后,GC 暂停和 p99 同时改善。

仅有“GC 次数多”不能成立,因为高频短暂停可能不影响 p99。

结论二:锁竞争是主要来源

应有类似链条:

  1. 线程池队列长度或活动线程数异常;
  2. 多个线程转储中,大量线程等待同一监视器或锁;
  3. 能找到持锁线程;
  4. 持锁线程的 JFR 栈或线程转储显示临界区过长、外部调用或锁嵌套;
  5. Trace 中请求等待时间与锁竞争窗口一致;
  6. 缩小临界区或修复锁顺序后,等待线程数量和延迟下降。

单次看到一个 BLOCKED 线程不足以证明系统性锁竞争。

结论三:Java 堆泄漏

应有类似链条:

  1. Old 区或存活集在多个 GC 周期后持续增长;
  2. 两个时间点的堆转储显示同类对象数量和 retained heap 增长;
  3. 对象通过静态集合、ThreadLocal、监听器或类加载器路径保持;
  4. 该引用关系与业务生命周期不匹配;
  5. 修复清理逻辑后,存活集不再持续增长。

“堆使用率高”只说明当前占用高,不足以证明泄漏。


十三、生产环境中的操作风险、验证和恢复

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 可能使故障恶化。

生产操作顺序通常应是:

  1. 确认实例是否仍承载全部流量;
  2. 必要时先摘除实例;
  3. 确认目标磁盘容量;
  4. 采集 JFR 和线程转储;
  5. 在可接受窗口内执行堆转储;
  6. 校验文件完整性;
  7. 保护和传输文件;
  8. 分析完成后安全删除。

校验示例:

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 性能故障,可以按以下顺序形成最小但完整的证据包:

  1. 确定问题窗口:记录开始时间、结束时间、实例、版本和 SLO 影响。
  2. 保存业务信号:请求延迟分位数、错误率、吞吐、Trace 和关键日志。
  3. 采集 JFR:优先覆盖故障前后,而不是只录故障已经结束的时间。
  4. 采集线程转储:间隔数秒获取多份,观察等待关系是否稳定。
  5. 检查 JVM 和系统状态:堆、GC、CPU、线程池、连接池、容器限制。
  6. 有明确假设后再采集堆转储:避免把高风险操作当成常规探测。
  7. 把证据按时间线关联:确认事件区间、线程、实例和代码版本一致。
  8. 实施最小变更:例如修复锁持有范围、限制缓存、调整分配路径或修复下游超时。
  9. 用相同指标验证:比较变更前后的 p99、GC、线程等待、存活集和业务吞吐。

最终报告应能够回答四个问题:

  • 发生了什么;
  • 哪些证据支持该判断;
  • 哪些替代假设已被排除;
  • 修复后哪些可观测信号发生了预期变化。

JFR 给出时间连续性,线程转储给出并发瞬间,堆转储给出对象关系,JMC 和命令行工具负责把原始数据变成可检索的结构。只有把它们与 Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO 连接起来,JVM 诊断才会从“看到了异常”推进到“证明了故障路径”。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。