1. 完整回答
JVM 问题排查主要是针对 Java 应用运行过程中出现的 CPU 使用率过高、内存持续增长、频繁 GC、接口响应缓慢、线程阻塞以及内存溢出 等问题,通过监控指标和诊断工具定位根本原因。
实际排查一般遵循 先观察现象、再缩小范围、最后定位根因 的思路。
首先,通过监控系统查看 CPU、内存、GC 次数和耗时、线程数量以及接口响应时间,结合应用日志判断异常发生的时间和影响范围。
其次,根据问题类型选择工具。如果 CPU 使用率过高,可以通过 top 找到高 CPU 线程,再结合 jstack 或 jcmd 查看线程堆栈,判断是否存在死循环、热点计算或者频繁 GC。
如果内存持续增长,可以通过 jstat 和 GC 日志观察堆使用情况以及垃圾回收效果,必要时使用 jmap 或 jcmd 导出堆转储文件,再通过 MAT 分析对象数量、引用关系和内存占用。
如果接口响应缓慢,可以分析线程状态,检查锁竞争、线程池耗尽、数据库连接池等待以及外部接口调用是否存在阻塞。
最后,根据定位结果采取针对性的措施,比如修复内存泄漏、优化热点代码、调整线程池配置,或者在确认原因后优化 JVM 参数,并通过监控验证效果。
整个过程最重要的是 结合业务现象、系统指标和 JVM 运行时证据进行判断,避免仅凭单一指标直接调整参数。
2. 核心原理与关键概念
2.1 核心排查流程
第一步:确认异常范围
业务告警 → 查看 CPU、内存、GC、线程和接口耗时 → 对比正常时段 → 判断异常属于计算、内存、垃圾回收还是线程等待问题。
这里需要结合请求量、发布时间和异常日志,避免把正常的业务流量增长误判为 JVM 故障。
第二步:采集运行时证据
根据异常类型选择对应工具:
-
CPU 高 → 定位高 CPU 线程 → 分析线程堆栈或 CPU 采样。
-
内存高 → 查看 GC 后内存变化 → 分析对象分布和引用关系。
-
GC 频繁 → 分析 GC 日志 → 判断分配速率、回收效果和停顿时间。
-
请求卡顿 → 分析线程堆栈 → 定位锁等待、资源池等待或外部调用。
-
进程退出 → 检查异常日志、容器事件及 JVM 崩溃日志。
第三步:定位根因并验证
异常指标 → 关联具体线程、对象或代码路径 → 找到触发原因 → 修复或调整配置 → 对比修复前后的 CPU、GC、内存和响应时间。
2.2 常用诊断工具
| 工具 | 主要作用 | 典型场景 |
|---|---|---|
top、top -H |
查看进程和线程的 CPU 占用 | CPU 飙高 |
jps |
查找 Java 进程 | 确认 JVM 进程 ID |
jstat |
观察堆使用率和 GC 统计 | GC 频繁、内存异常 |
jcmd |
查看线程、堆、JVM 配置等诊断信息 | 综合故障排查 |
jstack |
导出线程堆栈 | 死锁、线程阻塞 |
jmap |
查看对象统计、导出堆转储 | 内存泄漏分析 |
JFR |
采集 CPU、分配、GC、锁等运行事件 | 持续性能分析 |
MAT |
分析堆转储中的对象引用和保留内存 | 定位内存泄漏 |
现代 HotSpot JVM 通常优先使用 jcmd 执行诊断命令,jstack、jmap 仍然可以使用。
2.3 关键分析指标
| 指标 | 需要关注的现象 |
|---|---|
| CPU 使用率 | 是否持续偏高,是否集中在少数线程 |
| 堆内存使用量 | GC 后的存活对象占用是否持续增长 |
| Young GC | 发生频率是否异常,单次停顿是否过长 |
| Full GC | 是否频繁发生,回收后释放了多少内存 |
| GC 停顿时间 | 是否影响接口延迟和业务吞吐量 |
| 线程数量和状态 | 是否大量阻塞、等待或持续增长 |
| 进程实际内存(RSS) | 是否超过预期,是否接近容器内存限制 |
其中,GC 后存活对象的长期变化、GC 总耗时以及异常线程堆栈,通常比单次内存使用率或 CPU 峰值更有判断价值。
3. 高频面试追问
3.1 线上 Java 进程 CPU 使用率达到 100%,怎么排查?
首先确认高 CPU 是否来自目标 Java 进程,然后定位具体线程,最后通过线程堆栈和性能采样找到热点代码。
排查流程:
-
使用
top找到 CPU 占用较高的 Java 进程,确定 PID。 -
使用
top -H -p PID查看进程内部线程的 CPU 占用,找到异常线程的线程 ID。 -
将线程 ID 转换成十六进制,与线程堆栈中的
nid对应。 -
使用
jcmd PID Thread.print -l或jstack获取线程堆栈,检查异常线程正在执行的方法。 -
连续采集多次堆栈,必要时结合 JFR 的 CPU 采样,确认热点代码。
如果线程长期执行相同的计算逻辑,可能存在死循环、低效算法或者大量重复计算。
如果 CPU 主要消耗在 GC 线程上,就需要结合 GC 日志检查是否存在频繁垃圾回收。
关键是同时确认 CPU 消耗来自哪里,以及对应线程执行了什么操作。 单次线程堆栈只能反映一个瞬间,连续采样更容易判断问题。
3.2 JVM 堆内存持续上涨,怎么判断是否发生内存泄漏?
判断内存泄漏,主要看 GC 后仍然存活的对象是否持续增长,以及这些对象是否还被业务需要。
Java 堆内存本身就会随着对象创建而上涨,垃圾回收后下降,这种周期性变化属于正常现象。
如果在业务负载相对稳定的情况下,经过多轮有效垃圾回收,存活对象占用仍然持续增长,就需要怀疑存在内存泄漏。
具体排查流程:
-
通过监控和 GC 日志观察堆内存曲线,重点比较不同 GC 周期后的存活对象占用。
-
使用
jcmd GC.class_histogram查看对象数量和占用分布,必要时导出 Heap Dump。 -
使用 MAT 分析对象的保留内存(Retained Heap),找出占用大量内存的对象集合。
-
通过 GC Roots 引用链,定位哪些对象仍然持有这些数据。
-
结合代码检查缓存、静态集合、监听器或
ThreadLocal是否长期持有已经失效的对象。
例如,一个本地缓存不断加入数据,却没有设置容量限制或者淘汰策略,缓存对象始终可达,就可能导致堆内存持续增长。
继续追问:为什么 Java 有 GC,仍然会发生内存泄漏?
因为 GC 主要根据对象的可达性判断能否回收。如果对象已经没有业务价值,但仍然被静态集合、线程局部变量或者其他存活对象引用,GC 就无法回收它。
3.3 线上出现 OutOfMemoryError,应该怎么排查?
出现 OOM 时,首先查看具体错误信息,判断是哪一类内存资源耗尽,再选择对应的诊断方式。
常见情况包括:
| 错误或现象 | 可能原因 | 排查方向 |
|---|---|---|
Java heap space |
堆空间不足、大量对象存活 | GC 日志、Heap Dump |
GC overhead limit exceeded |
GC 消耗大量时间,回收效果很差 | GC 时间与回收量 |
Metaspace |
类元数据占用过高、类加载器未释放 | 类加载数量、类加载器引用 |
Direct buffer memory |
直接缓冲区分配超出限制 | 直接内存分配及释放情况 |
unable to create native thread |
线程数、进程限制或本地内存不足 | 线程数量、系统资源限制 |
容器 OOMKilled |
容器内存使用量超过限制 | 容器事件、内存指标 |
如果是堆内存 OOM,可以通过 -XX:+HeapDumpOnOutOfMemoryError 在相应 OOM 发生时自动生成堆转储,再使用 MAT 分析大对象及引用链。
如果是本地内存问题,就需要分析直接内存、线程栈、元空间以及其他本地内存分配。
另外,容器被操作系统 OOM Killer 终止,不一定会产生 Java 的 OutOfMemoryError,也可能没有 Heap Dump,这种情况要结合容器状态和系统日志判断。
解决时还需要区分内存配置不足、瞬时峰值过高和真实内存泄漏。单纯增加堆大小可能暂时缓解问题,但无法修复对象长期无法回收的情况。
3.4 Young GC 或 Full GC 频繁,怎么排查?
GC 频繁主要从 对象分配速率、内存回收效果、GC 触发原因以及停顿时间 四个方面分析。
首先通过 jstat -gcutil PID 1000 或 GC 日志观察回收频率、老年代占用和 GC 耗时。
如果 Young GC 频繁,但每次停顿很短、回收效果良好,可能只是短生命周期对象创建速度较快,需要结合吞吐量和业务延迟判断是否真的存在问题。
如果 Full GC 频繁,就需要重点检查老年代空间压力、对象晋升情况和每次 GC 后的内存占用。
排查时通常关注:
-
对象分配过快:短时间产生大量临时对象,使年轻代频繁回收。
-
长期存活对象过多:缓存、集合或业务对象长期占据堆空间。
-
内存配置不合理:堆空间不足或者收集器参数与业务负载不匹配。
-
回收效率低:Full GC 后仍然有大量存活对象,甚至出现分配失败。
以 G1 为例,Mixed GC 会回收年轻代和部分老年代区域;如果正常的回收过程无法及时释放足够空间,严重时可能退化为 Full GC。
继续追问:增加 JVM 堆内存能减少 GC 吗?
在堆空间确实偏小的情况下,适当扩大堆可以降低内存压力,减少部分 GC 的发生频率。但如果存在内存泄漏或者对象分配过快的问题,增加堆大小只能缓解症状,还可能增加内存成本,因此需要先确认根因。
3.5 接口响应很慢,但是 CPU 使用率不高,怎么排查?
这种情况通常优先考虑线程等待或外部资源瓶颈,例如锁竞争、数据库连接池耗尽、网络 I/O 阻塞或者下游服务响应缓慢。
首先通过链路追踪和接口日志定位耗时较长的调用环节,然后采集线程堆栈,查看业务线程具体等待在哪里。
如果大量线程处于 BLOCKED 状态,需要检查锁竞争。
如果线程停留在连接池获取连接、Future.get() 或网络读取等位置,就需要继续检查相关资源池、异步任务和下游服务。
同时,还要检查线程池活跃线程数、队列长度、数据库连接池使用率和超时配置。
例如,数据库连接池最大只有 20 个连接,而慢 SQL 长时间占用连接,就可能导致大量请求线程排队。此时即使 CPU 使用率不高,接口仍然可能大面积超时。
3.6 如何通过线程堆栈定位死锁和线程阻塞?
主要通过线程状态、调用栈、锁持有关系以及多次采样结果定位。
使用 jcmd PID Thread.print -l 获取线程信息后,重点观察 BLOCKED、WAITING 和 TIMED_WAITING 状态的业务线程。
如果大量线程处于 BLOCKED,需要查看正在等待的锁,以及持有该锁的线程正在执行什么操作。
如果存在死锁,典型情况是线程 A 持有锁 1、等待锁 2,线程 B 持有锁 2、等待锁 1,形成循环等待。JVM 的线程诊断工具可以识别部分死锁并输出相关线程和锁信息。
如果没有检测到死锁,但大量请求长期无法继续执行,就需要检查资源池等待、网络调用和线程之间的依赖关系。
继续追问:线程处于 WAITING 状态就代表发生了问题吗?
不一定。线程池中的空闲工作线程可能正常处于 WAITING 状态。判断是否异常,要结合线程职责、等待位置、持续时间以及业务请求是否受到影响。
3.7 Java 堆内存使用正常,但是进程内存很高,是什么原因?
这种情况需要重点检查堆外内存和 JVM 本地内存。
Java 进程占用的内存除了 Java 堆,还包括元空间、线程栈、直接内存、代码缓存以及 JVM 自身使用的本地内存。
因此,-Xmx 限制的是 Java 堆的最大大小,不能直接代表整个 Java 进程的内存上限。
排查时,首先对比 JVM 堆使用情况和操作系统中的进程实际内存占用,然后检查线程数量、类加载数量和直接内存使用情况。
如果 JVM 启动时启用了 Native Memory Tracking,可以通过:
jcmd PID VM.native_memory summary
查看 JVM 内部不同类别的本地内存使用情况。
需要注意,NMT 必须提前启用,而且无法覆盖所有第三方本地库或 JNI 分配的内存;它统计的保留内存、已提交内存也不能直接等同于进程 RSS。
如果 JVM 堆和内部本地内存都无法解释增长,就需要进一步使用操作系统工具检查内存映射和本地内存分配。
3.8 线上排查 JVM 问题时,怎样避免影响正在运行的服务?
线上排查应当先使用监控和日志,再使用运行时诊断工具,最后才考虑高开销的数据采集。
例如,查看 CPU、GC 日志和已有 JFR 数据,通常适合作为初步排查手段。
线程堆栈可以帮助定位阻塞问题,但在线程数量很大时,采集过程也会产生一定开销。
Heap Dump 和对象直方图的开销更明显。尤其是 jcmd GC.heap_dump,默认会请求 Full GC,并且可能带来较长停顿、大量磁盘写入和额外资源消耗。
所以在线上导出大堆文件前,需要先确认磁盘空间、业务流量以及服务是否能够承受停顿,必要时选择低峰期或隔离实例操作。
同时,建议提前配置 GC 日志、合理的 JFR 持续记录,以及 -XX:+HeapDumpOnOutOfMemoryError,并准备足够的存储空间。采集文件还可能包含敏感业务数据,需要做好访问控制。
核心原则是先保留现场,再根据证据选择诊断手段,控制排查操作对业务的影响。
3.9 如果线上出现一次 JVM 性能故障,如何完整定位并解决?
以订单查询服务出现 CPU 持续偏高、接口响应时间明显增长为例。
首先查看监控,确认故障期间请求量没有明显增加,但 CPU 使用率和 Young GC 频率同时上升。
随后通过 top -H 和线程堆栈分析,发现 GC 线程消耗了大量 CPU,再结合 GC 日志发现年轻代回收频繁,但每次回收后内存能够明显下降。
这个现象说明短生命周期对象分配过快的可能性较高。
接着使用 JFR 分析对象分配情况,假设最终发现订单查询接口在循环中反复进行 JSON 序列化和对象转换,产生大量临时对象。
处理时,可以减少循环中的重复转换,复用必要的中间结果,并对查询逻辑进行针对性优化。
优化上线后,继续对比相同负载下的对象分配速率、GC 频率、CPU 使用率和接口 P95、P99 延迟。
如果这些指标明显改善,并且经过持续观察没有出现新的内存问题,就能形成比较完整的故障定位和验证闭环。
4. 易混淆概念对比
4.1 内存泄漏 vs 对象分配过快
| 对比维度 | 内存泄漏 | 对象分配过快 |
|---|---|---|
| 核心原因 | 无用对象仍被引用 | 短时间创建大量对象 |
| GC 后表现 | 存活对象占用可能持续增长 | 多数临时对象能够回收 |
| 常见现象 | 老年代压力增加、可能 OOM | Young GC 频繁、CPU 开销增加 |
| 处理方式 | 找到引用链并释放无效引用 | 减少不必要的对象创建 |
4.2 Java 堆内存 vs 进程实际内存
| 对比维度 | Java 堆内存 | 进程实际内存(RSS) |
|---|---|---|
| 范围 | JVM 管理的对象堆空间 | 操作系统统计的进程驻留物理内存 |
| 主要内容 | Java 对象和数组 | 堆、本地内存、线程栈及相关驻留页面等 |
| 主要工具 | jstat、Heap Dump、MAT |
top、pmap、NMT 辅助分析 |
| 关键区别 | 受 -Xmx 限制 |
可能明显大于 Java 堆的实际使用量 |
4.3 Young GC vs Mixed GC vs Full GC
以下以 G1 垃圾收集器为例。
| 对比维度 | Young GC | Mixed GC | Full GC |
|---|---|---|---|
| 回收范围 | 年轻代 | 年轻代和部分老年代区域 | 整个 Java 堆 |
| 主要目的 | 回收短生命周期对象 | 逐步回收老年代空间 | 进行全堆回收与整理 |
| 停顿特点 | 通常较短 | 受回收区域数量等因素影响 | 通常开销较大 |
| 排查关注 | 分配速率、年轻代大小 | 老年代回收效果 | 触发原因、回收效果和停顿时间 |
G1 的 Mixed GC 有助于分阶段回收老年代,减少对代价较高的 Full GC 的依赖。
4.4 RUNNABLE vs BLOCKED vs WAITING
| 线程状态 | 含义 | 排查方向 |
|---|---|---|
RUNNABLE |
可运行或正在执行,也可能处于部分本地 I/O 操作中 | 结合 CPU 指标分析热点代码 |
BLOCKED |
等待获取 Java 对象监视器锁 | 锁竞争、锁持有者 |
WAITING |
无限期等待某个条件或其他线程操作 | 任务依赖、线程池、条件等待 |
TIMED_WAITING |
带超时时间的等待 | 超时设置、重试、定时等待 |
线程状态只能说明当时的执行情况,判断是否存在故障还需要结合具体堆栈。
4.5 Thread Dump vs Heap Dump vs JFR
| 对比维度 | Thread Dump | Heap Dump | JFR |
|---|---|---|---|
| 记录内容 | 线程状态和调用堆栈 | 堆中对象及引用关系 | 一段时间内的 JVM 运行事件 |
| 主要用途 | 死锁、阻塞、线程卡顿 | 内存泄漏、大对象分析 | CPU、GC、分配、锁和 I/O 分析 |
| 数据特点 | 某个时刻的线程快照 | 某个时刻的堆快照 | 带时间信息的事件记录 |
| 性能开销 | 通常中等,取决于线程数量 | 较高,取决于堆大小 | 常规持续记录通常较低 |
三者可以配合使用:Thread Dump 定位线程等待位置,Heap Dump 分析对象引用,JFR 观察问题发生前后的运行过程。
5. 总结
JVM 问题排查主要解决 Java 服务运行过程中出现的 CPU 飙高、内存异常、GC 频繁以及线程阻塞等问题。整体思路是先通过监控确定异常现象,再根据问题类型使用 jstat、jcmd、线程堆栈、GC 日志、Heap Dump 或 JFR 定位具体原因。排查过程中主要关注三个方面:CPU 消耗在哪些线程上、内存为什么无法有效回收、业务线程为什么无法继续执行。同时需要区分正常内存增长与内存泄漏、Java 堆占用与进程整体内存占用。最终结合业务代码和运行时证据进行修复,并通过相同负载下的性能指标验证效果,形成完整的排查闭环。