JVM 线上故障排查实战:CPU 飙高、内存泄漏、频繁 Full GC 与线程死锁

JVM 线上故障排查实战:CPU 飙高、内存泄漏、频繁 Full GC 与线程死锁

线上 Java 服务突然 CPU 100%、内存持续上涨、Full GC 接连不断,或者接口全部卡住时,最危险的做法往往不是“不会使用某个命令”,而是没有形成一条可靠的证据链:看到 CPU 高就重启,看到内存大就加堆,看到 Full GC 就换收集器,看到线程阻塞就认定发生了死锁。

真正有效的 JVM 故障排查,需要把操作系统、JVM、线程、对象、GC 日志和业务调用链放在同一条时间线上,回答下面几个问题:

  1. 故障发生在什么时间,影响了哪些实例和接口?
  2. 异常资源究竟被哪个进程、哪个线程、哪类对象消耗?
  3. 看到的是原因、结果,还是伴随现象?
  4. 重启或扩容之前,是否保存了足以复盘的现场证据?
  5. 修复后用什么指标证明问题真的消失,而不是暂时被掩盖?

本文以 Linux 上的 HotSpot JVM 为主要环境,给出一套可以直接用于生产值班的排查方法,重点覆盖:

  • CPU 飙高;
  • Java 堆与堆外内存泄漏;
  • 频繁 Full GC;
  • Java 线程死锁与线程池饥饿;
  • Docker、Kubernetes 场景下的诊断差异;
  • 现场采集、根因分析、修复验证和长期预防。

文中的命令以 JDK 17、JDK 21 等现代 JDK 为主要基线。不同 JDK 发行版、操作系统和 GC 收集器支持的诊断命令可能不同,执行前应先用 jcmd <pid> helpjava -Xlog:help 确认当前环境实际支持的参数。


1. 先建立正确的排查模型

JVM 线上故障很少是孤立问题。CPU、内存、GC、线程和外部依赖之间经常互相影响:

对象分配速度过快
    -> Young GC 频繁
    -> 对象晋升和老年代增长
    -> 并发标记来不及完成
    -> Full GC
    -> Stop-The-World 时间增加
    -> 请求堆积、超时与重试
    -> 更多线程、对象和 CPU 消耗

另一种常见链路是:

下游接口变慢
    -> 工作线程长时间等待
    -> 连接池和线程池耗尽
    -> 请求在队列中堆积
    -> 上游重试
    -> 堆内临时对象快速增长
    -> GC 压力升高

所以排查时不能只看一张监控图,也不能只拿一次线程快照。应该同时观察四个层次:

层次重点证据常用工具
业务层错误率、延迟、吞吐、慢接口、重试、发布变更APM、日志、链路追踪、发布平台
系统层CPU、负载、RSS、磁盘 I/O、网络、上下文切换toppidstatvmstatiostatss
JVM 层堆、元空间、线程、GC、Safepoint、JIT、类加载jcmdjstat、GC 日志、JFR
代码层热点方法、对象引用链、锁顺序、无界容器、超时配置火焰图、Heap Dump、线程转储、源码

一个可靠结论至少应包含:

异常时间窗口
+ 资源趋势
+ JVM 证据
+ 代码调用链
+ 可复现或可验证的修复结果

只有“某个类实例很多”或“某个线程是 RUNNABLE”,还不等于找到根因。


2. 生产排查的基本原则

2.1 先保业务,再保现场

如果故障已经造成大面积不可用,应先执行经过授权的止损措施,例如:

  • 摘除异常实例;
  • 限流或关闭高风险入口;
  • 暂停非核心定时任务;
  • 回滚刚发布的版本;
  • 扩容健康实例承接流量。

但在重启异常实例之前,最好争取几十秒到几分钟采集低风险证据:

  • 系统时间与实例信息;
  • 进程级 CPU、内存、线程数;
  • 3 次线程转储;
  • JVM 参数与堆概况;
  • GC 统计与最近的 GC 日志;
  • 必要时采集一段短时 JFR。

如果机器已经失去响应、Heap Dump 会进一步压垮磁盘,或者继续采集会扩大事故,则应优先恢复服务,不要为了“证据完整”牺牲可用性。

2.2 先做低影响操作,再做高影响操作

常见诊断动作的风险大致如下:

动作一般影响注意事项
pstoppidstat、读取监控适合第一时间使用
jcmd <pid> VM.flagsGC.heap_info低到中仍需关注目标 JVM 当前负载
jcmd <pid> Thread.print -l线程很多时输出大,通常会有短暂停顿
短时 JFR低到中settings=profile 比默认配置更详细,应限制采集时间
GC.class_histogram中到高遍历对象可能带来明显开销
GC.heap_dump可能触发 Full GC、长时间停顿和大量磁盘写入
主动执行 GC.run不应作为常规排查或“优化”手段

风险不是固定值,它与堆大小、对象数量、线程数量、磁盘速度和 JVM 当前状态有关。对一个 512 MB 堆很快的操作,在 100 GB 堆上可能成为二次事故。

2.3 使用正确的用户、命名空间和工具版本

Attach 类工具通常要求:

  • 使用与 Java 进程相同的操作系统用户,或具备足够权限;
  • 使用与目标 JVM 兼容的 JDK 工具;
  • 在容器内或正确的 PID Namespace 中执行;
  • 目标 JVM 没有通过 -XX:+DisableAttachMechanism 禁用 Attach。

排查前可以先确认:

id
ps -o user,pid,ppid,cmd -p <PID>
jcmd <PID> VM.version
jcmd <PID> VM.command_line
jcmd <PID> VM.flags

2.4 所有证据必须带时间

不要保存成反复覆盖的 thread.txtheap.hprof。建议统一使用:

<系统>-<实例>-<PID>-<时间>-<证据类型>

例如:

order-prod-10.0.8.21-18472-20260830T143012+0800-thread-1.txt
order-prod-10.0.8.21-18472-20260830T143125+0800-cpu.jfr
order-prod-10.0.8.21-18472-20260830T143500+0800-heap.hprof

3. 故障发生后的前 10 分钟

3.1 第一步:确认故障窗口和最近变更

先记录,不要依赖事后回忆:

故障开始时间:
报警时间:
发现时间:
受影响实例:
受影响接口:
错误率与 P99:
最近一次发布:
配置、流量、数据量或下游依赖变化:
已执行的止损动作:

重点检查:

  • 是否刚完成发布、扩容、JDK 升级或 GC 参数调整;
  • 是否出现突发流量、批处理、缓存失效或大查询;
  • 是否只有一个实例异常;
  • 是否同一宿主机上的其他进程也异常;
  • 是否发生下游超时、连接池耗尽或消息积压。

3.2 第二步:确认主机是否真的资源紧张

date -Is
hostname
uptime
uname -a
free -h
vmstat 1 10
mpstat -P ALL 1 5

重点理解:

  • load average 高不等于 CPU 一定高,处于不可中断 I/O 等待的任务也会推高负载;
  • us 高通常偏向用户态计算,sy 高要关注系统调用、网络、内核或频繁上下文切换;
  • wa 高要检查磁盘和存储;
  • siso 持续非零说明正在发生 Swap 换入换出;
  • 单核 100% 和整机多核 100% 是不同量级的问题。

3.3 第三步:找到 Java 进程

ps -eo pid,ppid,etime,%cpu,%mem,rss,vsz,nlwp,cmd --sort=-%cpu | head -n 20
jcmd -l

字段含义:

  • RSS:当前驻留在物理内存中的页面总量;
  • VSZ:虚拟地址空间大小,不等于真实物理内存占用;
  • NLWP:轻量级进程数,在 Linux 中可近似理解为线程数;
  • %CPU:进程消耗的 CPU,比瞬时单次采样更应关注一段时间趋势。

确认 PID 后,持续观察 30 秒到 1 分钟:

PID=18472
pidstat -p "$PID" -u -r -d -w 1 30

这里可以同时看到 CPU、缺页、内存、I/O 和上下文切换。不要只截取一次 top 就下结论。

3.4 第四步:快速采集 JVM 基线

PID=18472
TS=$(date +%Y%m%dT%H%M%S%z)

jcmd "$PID" VM.version > "vm-version-$TS.txt"
jcmd "$PID" VM.command_line > "vm-command-line-$TS.txt"
jcmd "$PID" VM.flags > "vm-flags-$TS.txt"
jcmd "$PID" GC.heap_info > "heap-info-$TS.txt"
jstat -gcutil "$PID" 1000 30 > "jstat-gcutil-$TS.txt"

如果 jcmd 不可用,不要立即判断 JVM 已经挂死。还要排除用户权限、容器命名空间、工具版本和 Attach 被禁用等因素。


4. CPU 飙高排查实战

CPU 飙高的目标不是简单找出“最忙线程”,而是确认 CPU 时间究竟消耗在:

  • Java 业务代码;
  • 垃圾回收线程;
  • JIT 编译线程;
  • 锁竞争与自旋;
  • JNI 或本地库;
  • 内核态、I/O 或其他进程;
  • 容器被限流后的错误表象。

4.1 找出高 CPU 进程

top -b -n 1 | head -n 30
ps -eo pid,ppid,%cpu,%mem,rss,nlwp,cmd --sort=-%cpu | head -n 20

如果 Java 进程 CPU 不高,但整机 CPU 很高,根因很可能在其他进程。此时继续分析 Java 线程只会浪费时间。

4.2 找出高 CPU 线程

PID=18472
top -H -b -n 1 -p "$PID" | head -n 40

在 Linux 的线程视图中,PID 列显示的是线程 ID,也叫 LWP/TID。假设最忙线程的十进制 TID 是 18531,把它转换成十六进制:

TID=18531
printf '0x%x\n' "$TID"

输出示例:

0x4863

4.3 将操作系统线程映射到 Java 栈

采集线程转储:

PID=18472
jcmd "$PID" Thread.print -l > "thread-$(date +%Y%m%dT%H%M%S%z).txt"

在线程转储中搜索:

nid=0x4863

典型结果:

"pool-8-thread-17" #126 prio=5 os_prio=0 cpu=92134.45ms elapsed=102.31s tid=0x... nid=0x4863 runnable
   java.lang.Thread.State: RUNNABLE
        at com.example.pricing.RuleEngine.match(RuleEngine.java:218)
        at com.example.pricing.PriceService.calculate(PriceService.java:94)
        at com.example.order.OrderController.create(OrderController.java:61)

这时才能建立第一条证据链:

Java 进程 CPU 高
-> TID 18531 持续占用 CPU
-> 十六进制 nid=0x4863
-> 多次转储都停留在 RuleEngine.match
-> 对应代码存在高复杂度循环或无法退出的条件

4.4 为什么必须连续采集多次线程转储

一次线程转储只是瞬时快照。一个正常请求也可能恰好出现在某个方法中。

建议间隔 5 到 10 秒采集 3 次:

PID=18472
for i in 1 2 3; do
  jcmd "$PID" Thread.print -l > "thread-$i-$(date +%Y%m%dT%H%M%S%z).txt"
  sleep 5
done

如果同一个高 CPU 线程连续停留在同一条调用链,根因可信度会明显提高。如果栈持续变化,则更可能是正常计算热点,需要用采样分析确认 CPU 时间分布。

4.5 使用 JFR 采集 CPU 与运行时证据

Java Flight Recorder 可以把 CPU 采样、锁、GC、类加载、线程和 I/O 等事件放到同一条时间线上。

短时诊断示例:

PID=18472
jcmd "$PID" JFR.start \
  name=cpu-incident \
  settings=profile \
  duration=120s \
  filename=/tmp/cpu-incident.jfr

检查录制状态:

jcmd "$PID" JFR.check

也可以手动导出正在运行的录制:

jcmd "$PID" JFR.dump name=cpu-incident filename=/tmp/cpu-incident-now.jfr

使用 JDK Mission Control 打开 .jfr 文件,重点查看:

  • Method Profiling:热点方法;
  • Threads:线程状态随时间的变化;
  • Lock Instances:锁竞争;
  • Garbage Collections:GC 是否消耗大量 CPU;
  • Socket/File I/O:是否在等待外部资源;
  • Code Cache / Compilations:JIT 编译是否异常活跃。

settings=profile 适合短时问题分析,不建议未经压测就长期开启在所有高分配应用上。

4.6 使用 async-profiler 生成 CPU 火焰图

在 Linux HotSpot 环境中,可以使用 async-profiler 进行低开销采样:

./asprof -d 30 -f /tmp/cpu-flamegraph.html <PID>

火焰图的阅读方法:

  • 横向宽度表示该调用栈被采样到的相对频率,不是时间轴;
  • 越宽的方法越可能是 CPU 热点;
  • 从下往上看调用关系;
  • 不要只盯着最上层方法,还要寻找下方是谁调用了它;
  • CPU 模式适合定位正在消耗 CPU 的代码,等待和阻塞问题更适合结合 Wall Clock、线程转储或 JFR 分析。

4.7 CPU 高的典型根因

4.7.1 死循环或退出条件错误
while (task.isRunning()) {
    if (queue.isEmpty()) {
        continue;
    }
    process(queue.poll());
}

队列为空时仍然忙轮询,会持续占用 CPU。可以改为阻塞队列、事件通知或带退避的等待机制。

4.7.2 算法复杂度突然放大
for (Order order : orders) {
    for (Rule rule : rules) {
        match(order, rule);
    }
}

数据量从几百增长到几十万后,原本隐藏的 O(n × m) 复杂度会突然爆发。火焰图通常能看到某个业务方法明显变宽。

4.7.3 正则表达式灾难性回溯

复杂正则在特定输入上可能产生指数级回溯。线程栈中通常会反复出现 java.util.regex 相关调用。

4.7.4 序列化、压缩、加密或大 JSON 处理

这类操作本身就是 CPU 密集型工作。需要结合输入大小、吞吐和火焰图判断是正常容量不足,还是代码重复计算、缺少缓存或异常大报文导致。

4.7.5 GC 线程消耗 CPU

如果热点线程是 GC Worker,业务线程只是受害者,应转到 GC 与对象分配问题排查,而不是优化某个 Controller。

4.7.6 锁竞争和自旋

大量 CAS 失败、自旋或高竞争锁也可能消耗 CPU。此时应结合:

  • JFR 的锁事件;
  • async-profiler 的 lock 模式;
  • 线程状态和上下文切换;
  • 共享热点对象与临界区代码。
4.7.7 JIT 或类加载风暴

大量动态生成类、脚本编译、代理类或频繁重新定义类时,JIT 编译线程和类加载可能异常活跃。可查看:

jstat -compiler <PID> 1000 20
jstat -class <PID> 1000 20

4.8 CPU 排查的结论模板

现象:14:20 起实例 order-3 CPU 从 35% 上升到 780%,P99 从 120 ms 上升到 8 s。
系统证据:同宿主机其他进程正常,Java PID 18472 占用主要 CPU。
线程证据:TID 18531 连续 3 次为最高 CPU,对应 nid=0x4863。
JVM 证据:3 次线程转储均指向 RuleEngine.match:218;GC CPU 占比正常。
代码证据:新规则使双层遍历数据量从 400×20 增长到 100000×80。
根因:规则匹配算法复杂度放大,不是 GC 或机器抖动。
修复:规则预索引,将逐单扫描改为按 ruleType 查询。
验证:同等流量下 CPU 峰值 210%,P99 160 ms,热点栈占比从 71% 降至 8%。

5. 内存泄漏排查实战

5.1 内存高不等于内存泄漏

内存泄漏的核心不是“内存很大”,而是:

已经不再具有业务价值的对象,仍然被 GC Root 间接引用,导致垃圾回收器无法回收。

下面这些现象都可能导致内存高,但根因不同:

现象可能原因
堆使用量呈锯齿状,GC 后能回到稳定基线正常对象分配
Old 区在多次 GC 后的最低点持续上升疑似堆内对象泄漏或业务缓存增长
Heap 基本稳定,但 RSS 持续上升直接内存、线程栈、JNI、mmap、分配器碎片等
Metaspace 持续增长,类卸载很少ClassLoader 泄漏、动态类生成
线程数持续增长线程池误用、线程未退出,线程栈消耗本地内存
容器被 OOMKilled,但没有 Java OOM 日志cgroup 总内存超限,可能不只是 Java 堆

因此,第一步必须先回答:增长的是 Java Heap、Metaspace、Direct Memory,还是整个进程 RSS?

5.2 观察 GC 后的堆基线

PID=18472
jstat -gcutil "$PID" 1000 60
jcmd "$PID" GC.heap_info

jstat -gcutil 常见列:

  • E:Eden 使用比例;
  • O:Old 区使用比例;
  • M:Metaspace 使用比例;
  • YGCYGCT:Young GC 次数与累计耗时;
  • FGCFGCT:Full GC 次数与累计耗时;
  • GCT:GC 累计总耗时。

不要仅凭 O=90% 判断泄漏。更有价值的是观察一段时间内每次 GC 后 Old 区的最低点:

第一次 GC 后:3.2 GB
第二次 GC 后:3.8 GB
第三次 GC 后:4.5 GB
第四次 GC 后:5.1 GB

如果业务负载没有同步增长,而 GC 后基线持续抬升,就需要进一步分析对象增长。

5.3 使用类直方图寻找增长对象

PID=18472
jcmd "$PID" GC.class_histogram > "histo-1-$(date +%Y%m%dT%H%M%S%z).txt"

间隔一段时间再采集:

jcmd "$PID" GC.class_histogram > "histo-2-$(date +%Y%m%dT%H%M%S%z).txt"

类直方图通常包含:

num     #instances         #bytes  class name
1:        3200000      460800000  [B
2:        1700000      136000000  java.lang.String
3:         900000       86400000  com.example.UserSession

分析时不要犯三个错误:

  1. byte[]char[]String 很多,只说明底层载体多,还要找到业务持有者;
  2. 实例数量大不等于泄漏,缓存、索引和连接池可能是有意保留;
  3. 单次直方图缺少增长趋势,至少应对比两个时间点。

GC.class_histogram 可能产生明显开销,超大堆应在评估后使用,不要无脑高频执行。

5.4 生成 Heap Dump

在满足以下条件后再生成 Heap Dump:

  • 确认磁盘空间充足;
  • 确认 Dump 文件有安全的持久化位置;
  • 评估停顿对业务的影响;
  • 最好先摘流或在只读副本上操作;
  • 确认文件不会包含不应外传的密码、Token、用户数据等敏感信息。

先检查磁盘:

df -h /data/dumps

再执行:

PID=18472
TS=$(date +%Y%m%dT%H%M%S%z)
jcmd "$PID" GC.heap_dump "/data/dumps/heap-$PID-$TS.hprof"

GC.heap_dump 是高影响操作。现代 JDK 中默认会请求一次 Full GC,然后导出可达对象;-all 会包含不可达对象。命令的实际语法应以当前目标 JVM 的帮助为准:

jcmd "$PID" help GC.heap_dump

5.5 使用 MAT 分析 Heap Dump

使用 Eclipse Memory Analyzer 分析 .hprof 时,建议按照下面的顺序:

  1. 查看 Leak Suspects Report,获得初步线索;
  2. 查看 Dominator Tree,寻找 Retained Heap 最大的支配对象;
  3. 按类名或 ClassLoader 分组;
  4. 查看某个可疑对象的 Path to GC Roots;
  5. 排除弱引用、软引用等不符合目标的路径;
  6. 回到源码确认“为什么这个引用没有释放”。

两个重要概念:

  • Shallow Heap:对象自身占用的内存;
  • Retained Heap:如果该对象被回收,可以连带释放的内存总量。

真正关键的对象未必自身很大。例如一个 ConcurrentHashMap 对象的 Shallow Heap 很小,但它可能支配几 GB 的业务对象,因此 Retained Heap 很大。

5.6 一个典型的无界缓存泄漏

public class UserProfileCache {
    private final ConcurrentHashMap<Long, UserProfile> cache = new ConcurrentHashMap<>();

    public UserProfile get(Long userId) {
        return cache.computeIfAbsent(userId, this::loadFromDatabase);
    }

    private UserProfile loadFromDatabase(Long userId) {
        return new UserProfile(userId, loadLargePayload(userId));
    }
}

问题不是使用了 ConcurrentHashMap,而是:

  • Key 空间可能无限增长;
  • 没有最大容量;
  • 没有过期时间;
  • 没有基于业务生命周期的删除;
  • 缓存值可能包含大对象。

Heap Dump 中可能看到:

GC Root
-> Spring Singleton Bean
-> UserProfileCache
-> ConcurrentHashMap.table
-> Node
-> UserProfile
-> byte[]

修复应围绕容量与生命周期,而不是单纯增大 -Xmx

Cache<Long, UserProfile> cache = Caffeine.newBuilder()
        .maximumSize(100_000)
        .expireAfterAccess(Duration.ofMinutes(30))
        .recordStats()
        .build();

还应监控命中率、淘汰数、加载耗时和当前条目数,避免“修复泄漏后缓存失效导致数据库被打穿”。

5.7 ThreadLocal 泄漏

private static final ThreadLocal<byte[]> CONTEXT = new ThreadLocal<>();

public void handle() {
    CONTEXT.set(new byte[10 * 1024 * 1024]);
    process();
}

在线程池中,工作线程会被长期复用。如果没有清理,值可能随线程一起长期存活:

public void handle() {
    try {
        CONTEXT.set(new byte[10 * 1024 * 1024]);
        process();
    } finally {
        CONTEXT.remove();
    }
}

即使 ThreadLocal 的 Key 被回收,Value 也不保证立刻释放。在线程池、Web 容器和异步任务中,必须在 finally 中清理。

5.8 ClassLoader 与元空间泄漏

常见场景:

  • 热部署或插件卸载后,旧 ClassLoader 仍被线程、ThreadLocal、JDBC Driver 或静态集合引用;
  • CGLIB、ByteBuddy、Groovy 等持续生成新类;
  • 每次请求都创建新的代理类或脚本类加载器。

观察类加载趋势:

jstat -class <PID> 1000 60
jcmd <PID> VM.classloader_stats

如果 Loaded Class 持续增长、Unloaded 几乎不变,且 Metaspace 同步上涨,应进一步按 ClassLoader 分析引用链。

5.9 堆外内存与 RSS 持续上涨

如果 Heap Dump 不大,但 RSS 持续增长,要检查:

  • DirectByteBuffer;
  • Netty 堆外池;
  • 线程栈;
  • Metaspace 与 Code Cache;
  • JNI、本地库和本地分配器;
  • 内存映射文件;
  • glibc arena 与内存碎片。

HotSpot 的 Native Memory Tracking 需要在启动时开启:

-XX:NativeMemoryTracking=summary

建立基线:

jcmd <PID> VM.native_memory baseline

一段时间后比较:

jcmd <PID> VM.native_memory summary.diff scale=MB

查看当前汇总:

jcmd <PID> VM.native_memory summary scale=MB

需要注意:

  • NMT 默认关闭,不能在 JVM 启动后再开启;
  • NMT 会带来额外开销,是否在生产开启应经过压测;
  • NMT 主要跟踪 HotSpot/JVM 自身的本地内存,并不能完整覆盖第三方 Native Code 的所有分配;
  • 如果 NMT 各分类稳定但 RSS 仍涨,需要继续分析 JNI、分配器、mmap 和系统层内存。

5.10 线程过多导致本地内存耗尽

每个 Java 平台线程都需要本地线程资源和栈空间。可观察:

ps -o pid,nlwp,rss,vsz,cmd -p <PID>
jcmd <PID> Thread.print -l | grep '^"' | wc -l

粗略估算时可以关注:

线程栈总预留量约等于:线程数 × -Xss

但这不是 RSS 的精确计算,因为栈页面通常按需提交,JVM 和操作系统实现也会影响实际占用。真正的问题往往是线程生命周期失控:每次请求创建线程、线程池数量过多、下游永久阻塞导致线程不退出等。

5.11 自动保存 OOM 证据

可以在经过磁盘容量和安全评估后配置:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dumps
-XX:+ExitOnOutOfMemoryError

注意:

  • HeapDumpPath 所在目录必须存在、可写且有足够空间;
  • Heap Dump 可能包含敏感数据,应限制权限并建立传输、留存和销毁流程;
  • 容器中的 Dump 必须写入持久卷,否则 Pod 被替换后文件会丢失;
  • 是否使用 ExitOnOutOfMemoryError 取决于系统是否能由编排平台可靠拉起新实例,以及应用在 OOM 后继续运行是否安全。

6. 频繁 Full GC 排查实战

6.1 Full GC 是现象,不是根因

Full GC 表示 JVM 需要执行覆盖范围更大的垃圾回收或退化回收,但触发原因可能完全不同:

  • 老年代存活对象过多;
  • 对象分配或晋升速度过快;
  • 堆设置过小;
  • G1 并发回收来不及完成;
  • Humongous Object 占用大量 Region;
  • Metaspace 压力;
  • 显式调用 System.gc()
  • 收集器发生 Evacuation Failure 或退化;
  • 内存泄漏使回收后基线持续升高。

所以正确的问题不是“怎么禁止 Full GC”,而是:

谁触发了 Full GC?回收前后释放了多少?停顿多久?为什么下一次又很快发生?

6.2 首先保证有可用的 GC 日志

现代 JDK 使用统一日志框架。一个常用的起点是:

-Xlog:gc*,safepoint:file=/var/log/myapp/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=20M

其中:

  • %p 会展开为进程 PID;
  • %t 会展开为 JVM 启动时间戳;
  • filecountfilesize 用于滚动;
  • gc* 记录包含 GC 标签的详细事件;
  • safepoint 用于区分停顿是否全部来自 GC。

应根据日志量、磁盘容量和 JDK 版本压测后确定最终配置。可以用下面的命令查看当前 JVM 支持的日志标签:

java -Xlog:help

6.3 在线观察 GC 频率和原因

jstat -gcutil <PID> 1000 60
jstat -gccause <PID> 1000 60

重点观察:

  • FGC 是否持续增加;
  • FGCT 增长速度;
  • Old 区在 GC 后能降到多少;
  • Metaspace 是否逼近上限;
  • LGCCGCC 显示的最近、当前 GC 原因。

jstat 适合现场快速观察,但它被官方标记为实验性工具,输出格式可能变化,不应把固定列位解析脚本当成长期稳定协议。

6.4 从 GC 日志回答四个问题

每次异常 GC 都应分析:

  1. 原因:Allocation Failure、Metadata GC Threshold、System.gc()、Humongous Allocation,还是收集器退化?
  2. 回收效果:例如 8G -> 7.7G,还是 8G -> 2G
  3. 停顿时间:一次 5 秒,还是每秒一次 100 毫秒?
  4. 恢复速度:回收后多久再次打满?

可以用一个简单的判断矩阵:

GC 后表现更可能的方向
Old 区降不下来,基线持续上涨对象长期存活、缓存失控或内存泄漏
GC 后能明显下降,但很快再次涨满分配速率过高、突发大对象、堆容量不足
Heap 不高,但 Metadata GC 频繁类加载或 ClassLoader 问题
日志出现 System.gc()代码或三方库显式触发
G1 出现 Humongous 相关事件超大数组、字符串、序列化缓冲区等
G1 出现 Evacuation Failure / To-space Exhausted可用 Region 不足、晋升压力或参数不匹配

6.5 计算分配速率和回收收益

只看“每分钟 GC 次数”不够。两个更有价值的指标是:

分配速率 ≈ 两次采样间 Eden 增量 + 期间已回收的新生代对象
晋升速率 ≈ GC 前后 Old 区增长量

实际生产中可以借助 GC 日志分析工具、JFR 或监控平台计算:

  • Allocation Rate;
  • Promotion Rate;
  • GC Pause P99;
  • GC Time Ratio;
  • Live Set;
  • Full GC 后 Old 区占用。

其中 Live Set 可以理解为经过充分回收后仍然存活的对象集合大小。堆容量必须在 Live Set 之外为突发分配、复制、晋升和收集器保留空间。

6.6 G1 下的常见问题

6.6.1 Humongous Object

在 G1 中,大于或等于单个 Region 一半的对象会被当作 Humongous Object 处理,常见来源包括:

  • 超大 byte[]
  • 一次性读取完整文件;
  • 超大 JSON/XML;
  • 大批量序列化缓冲区;
  • 大图像或压缩数据。

如果 GC 日志显示 Humongous Region 快速增长,应回到分配栈确认是谁创建了大对象。优化方向通常是流式处理、分块、限制请求体大小和避免多份复制,而不是先修改 Region Size。

6.6.2 并发标记启动太晚

如果老年代增长速度超过并发回收速度,G1 可能来不及完成标记,最终退化为更重的回收。应先检查:

  • 对象分配与晋升速率是否异常;
  • CPU 是否已经饱和,导致并发 GC 线程得不到执行时间;
  • 堆是否缺少足够余量;
  • 是否存在大对象和泄漏。

调整 IHOP、并发线程数等参数之前,必须先基于 GC 日志和压测证明默认自适应策略确实无法满足当前负载。

6.6.3 Evacuation Failure

存活对象复制时没有足够可用 Region,可能出现 Evacuation Failure。常见原因是:

  • 堆接近满载;
  • 存活对象比例过高;
  • 晋升压力大;
  • 大对象造成 Region 碎片;
  • 预留空间不足。

这通常不是把暂停目标改小就能解决的。暂停目标越激进,每次回收的 Region 可能越少,反而需要结合实际负载评估。

6.7 显式 GC

GC 日志如果频繁出现 System.gc(),需要搜索:

  • 业务代码中的 System.gc()
  • RMI、NIO、三方组件的显式回收行为;
  • 诊断脚本是否调用了 jcmd <pid> GC.run

可以考虑:

-XX:+DisableExplicitGC

但这不是无条件推荐项。某些直接内存、RMI 或遗留组件可能依赖显式 GC 行为;使用前必须结合组件语义和压测结果评估。

6.8 Full GC 的正确优化顺序

推荐顺序:

  1. 确认 GC 原因和回收效果;
  2. 排除内存泄漏和无界缓存;
  3. 找出主要对象分配栈和大对象;
  4. 降低重试、批量加载、序列化复制等异常分配;
  5. 根据容器上限与 Live Set 重新评估堆容量;
  6. 确认当前收集器是否符合延迟和吞吐目标;
  7. 最后才微调 GC 参数,并通过压测和回放验证。

常见反模式:

  • 看到 Full GC 就增大堆;
  • 看到停顿就把 MaxGCPauseMillis 调得很小;
  • 同时修改十几个 GC 参数,导致无法知道哪个参数有效;
  • 只看平均暂停,不看 P99、最大值和业务超时;
  • 换成低延迟收集器,却不给足 CPU 和内存余量。

6.9 Full GC 根因模板

现象:支付实例每 40~60 秒发生一次 Full GC,接口周期性超时。
GC 证据:Full GC 前 7.8 GB,回收后仍有 7.3 GB,FGCT 每分钟增加约 9 秒。
对象证据:两个时间点的直方图显示 PaymentContext 增长 42 万个。
Heap Dump:单例 RetryRegistry 支配 5.1 GB,通过 requestId 持有失败请求完整报文。
代码证据:重试完成后只删除成功记录,永久失败记录没有清理。
根因:RetryRegistry 无界增长导致 Live Set 持续抬升,Full GC 是结果。
修复:状态落库,内存仅保留有界时间窗口,增加最大容量和过期策略。
验证:24 小时压测后 Old 区回收基线稳定在 2.1~2.4 GB,无 Full GC,P99 恢复正常。

7. 线程死锁排查实战

7.1 什么是 Java 线程死锁

经典死锁需要同时满足:

  1. 互斥:资源一次只能由一个线程占有;
  2. 占有并等待:线程持有一个资源,同时等待另一个资源;
  3. 不可剥夺:资源不能被其他线程强制抢走;
  4. 循环等待:多个线程形成首尾相接的等待环。

示例:

public class DeadlockDemo {
    private static final Object LOCK_A = new Object();
    private static final Object LOCK_B = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (LOCK_A) {
                sleep(100);
                synchronized (LOCK_B) {
                    System.out.println("t1 done");
                }
            }
        }, "deadlock-t1");

        Thread t2 = new Thread(() -> {
            synchronized (LOCK_B) {
                sleep(100);
                synchronized (LOCK_A) {
                    System.out.println("t2 done");
                }
            }
        }, "deadlock-t2");

        t1.start();
        t2.start();
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

7.2 获取线程转储

首选:

jcmd <PID> Thread.print -l > thread-deadlock.txt

也可以在 Linux 上向 JVM 发送 SIGQUIT,HotSpot 通常会把线程转储写到标准错误输出:

kill -3 <PID>

这不是终止进程的 SIGKILL,但输出位置取决于进程的日志接管方式。使用前要确认容器、systemd 或日志平台在哪里收集标准错误。

jstack 仍可作为兼容性手段:

jstack -l <PID> > thread-deadlock.txt

现代 JDK 的故障排查一般优先使用 jcmd。如果进程已经严重无响应,Attach 工具也可能无法成功,此时需要结合操作系统栈、Core Dump 或经过演练的故障转储机制。

7.3 识别死锁报告

典型输出:

Found one Java-level deadlock:
=============================
"deadlock-t1":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "deadlock-t2"

"deadlock-t2":
  waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
  which is held by "deadlock-t1"

阅读顺序:

  1. 哪些线程参与死锁;
  2. 每个线程当前在等待哪把锁;
  3. 这把锁被谁持有;
  4. 线程在什么业务方法中获取锁;
  5. 不同代码路径的加锁顺序是否相反。

7.4 BLOCKED 不等于死锁

一个线程处于 BLOCKED,只代表它正在等待进入 synchronized 临界区。只要持锁线程最终释放锁,它就能继续执行。

判断死锁需要看到:

  • JVM 明确报告 Java-level deadlock;或
  • 根据多次线程转储构造出稳定的循环等待关系。

大量 BLOCKED 更常见的原因是:

  • 临界区过大;
  • 锁内执行慢 SQL、HTTP、磁盘 I/O;
  • 单个热点 Key 导致串行化;
  • 锁粒度过粗;
  • 持锁线程卡在另一个非锁资源上。

7.5 JVM 检测不到的“业务死锁”

7.5.1 线程池饥饿
ExecutorService pool = Executors.newFixedThreadPool(2);

Future<String> a = pool.submit(() -> pool.submit(() -> "A").get());
Future<String> b = pool.submit(() -> pool.submit(() -> "B").get());

两个工作线程都在等待提交到同一个线程池的子任务,但已经没有空闲线程执行子任务。这种情况未必被 JVM 报告为 monitor deadlock。

7.5.2 数据库锁或分布式锁

Java 线程可能只是等待 JDBC 或网络响应,真正的等待环发生在数据库事务、Redis 锁或其他服务中。线程转储只能告诉你“线程卡在外部调用”,还需要查看:

  • 数据库锁等待图;
  • 事务和慢 SQL;
  • 分布式锁持有者、租约和续期;
  • 调用链超时与重试;
  • 外部服务线程池和连接池。
7.5.3 CompletableFuture 相互等待

异步任务之间互相 join(),或者在公共线程池中执行阻塞操作,也可能造成无进展状态。线程栈可能显示为 WAITING,却没有 JVM 的死锁摘要。

7.6 修复死锁的常用方法

7.6.1 固定加锁顺序
所有代码路径都必须先获取 LOCK_A,再获取 LOCK_B。

不要让一个方法 A -> B,另一个方法 B -> A

7.6.2 缩小临界区

不要在持锁期间执行:

  • HTTP/RPC 调用;
  • 慢 SQL;
  • 文件 I/O;
  • 大量计算;
  • 等待另一个异步任务完成。
7.6.3 使用超时锁
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try {
        doWork();
    } finally {
        lock.unlock();
    }
} else {
    recordLockTimeout();
}

超时不能自动修复业务一致性问题,但能避免线程无限等待,并为监控提供明确事件。

7.6.4 消除嵌套锁和共享可变状态

可以考虑:

  • 不可变对象;
  • 消息串行化;
  • Actor/事件循环模型;
  • 数据分片;
  • 按 Key 锁而不是全局锁;
  • 数据库唯一约束或乐观锁。

7.7 死锁结论模板

现象:库存接口线程池 200 个线程全部占满,吞吐降为 0,CPU 仅 15%。
线程证据:JVM 报告一个 Java-level deadlock,inventory-42 持有 skuLock 等待 batchLock,batch-3 持有 batchLock 等待 skuLock。
代码证据:单品扣减路径按 skuLock -> batchLock,加批次路径按 batchLock -> skuLock。
根因:两个业务路径加锁顺序相反形成循环等待。
止损:摘除并重启异常实例,临时暂停批次变更任务。
修复:统一按 lockType + id 排序后获取锁,锁内移除数据库调用,增加锁等待指标。
验证:并发回归与 12 小时压测无死锁,锁等待 P99 小于 8 ms。

8. 四类故障的快速决策表

现象第一批证据关键判断深入工具
CPU 高进程 CPU、线程 CPU、3 次线程转储是 Java 业务、GC、JIT、Native 还是其他进程JFR、async-profiler
内存上涨RSS、Heap、Old GC 后基线、线程数、Metaspace堆内泄漏还是堆外增长Heap Dump、MAT、NMT
Full GC 频繁GC 日志、jstat -gccause、回收前后内存回收无效还是分配过快GC 日志分析、JFR、对象分配火焰图
请求卡死3 次线程转储、线程池/连接池指标Java 死锁、锁竞争、池饥饿还是下游阻塞JFR Lock、数据库锁图、链路追踪

也可以按资源表现快速分流:

CPU 高、吞吐也高
    -> 可能是正常容量到顶或计算热点

CPU 高、吞吐下降
    -> 死循环、GC、锁自旋、重试风暴、算法退化

CPU 低、线程很多、吞吐为零
    -> 锁等待、线程池饥饿、下游阻塞、连接池耗尽

RSS 高、Heap 低
    -> Direct Memory、线程栈、Metaspace、JNI、mmap

Heap 高、Full GC 后仍高
    -> 大 Live Set、无界缓存、对象泄漏

Heap 高、Full GC 后明显下降
    -> 分配过快、突发大对象、堆容量不足

9. Docker 与 Kubernetes 场景的特殊处理

9.1 宿主机 PID 和容器 PID 可能不同

宿主机看到的 Java PID 可能是 18472,容器内却是 1。Attach 工具通常应在目标 JVM 所在的容器和 PID Namespace 中执行。

Docker 环境可以先看:

docker stats <container>
docker top <container> -eo pid,ppid,pcpu,pmem,nlwp,cmd

容器内确认:

docker exec <container> sh -c 'ps -ef; jcmd -l'

9.2 先确认容器限制,而不是只看宿主机空闲内存

cgroup v2:

cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/cpu.max

如果 memory.maxmax,表示没有设置该项硬上限;如果是数字,则单位是字节。

还应检查:

  • JVM 实际识别到的最大堆;
  • Pod memory request/limit;
  • Java Heap、Metaspace、Direct Memory、线程栈和 Native Memory 的总和;
  • sidecar 是否共享 Pod 级资源压力;
  • 是否发生 CPU Throttling。

查看 JVM 的容器与堆信息:

java -XshowSettings:system -version
java -XshowSettings:vm -version

这两条命令会启动一个新的短生命周期 JVM,只适合确认当前镜像内这套 Java 运行时如何识别 cgroup 和默认自适应参数,不能代表正在运行的目标进程实际使用了多少堆。目标进程的真实启动参数和当前堆信息仍应以 jcmd <PID> VM.command_lineVM.flagsGC.heap_info 为准。

9.3 Kubernetes 排查命令

kubectl top pod <POD> -n <NAMESPACE> --containers
kubectl describe pod <POD> -n <NAMESPACE>
kubectl logs <POD> -n <NAMESPACE> -c <CONTAINER> --previous

重点查看:

  • Last State 是否为 OOMKilled
  • Restart Count;
  • 资源 requests/limits;
  • Readiness/Liveness 探针失败;
  • Node MemoryPressure;
  • Evicted、CPU throttling 和磁盘压力。

进入容器:

kubectl exec -it <POD> -n <NAMESPACE> -c <CONTAINER> -- sh

如果镜像是 Distroless,没有 Shell 和 JDK 工具,可使用经过安全审批的调试容器、预置诊断 Sidecar,或者在宿主机正确的命名空间中诊断。不要在事故时临时下载来源不明的二进制文件。

9.4 导出诊断文件

kubectl cp \
  <NAMESPACE>/<POD>:/tmp/cpu-incident.jfr \
  ./cpu-incident.jfr \
  -c <CONTAINER>

Heap Dump 往往很大,导出前应考虑:

  • Pod 临时磁盘是否足够;
  • 是否会触发 Ephemeral Storage 驱逐;
  • 是否应直接写到持久卷;
  • 文件是否含敏感信息;
  • 传输是否限速并加密。

9.5 为什么 Pod 被 OOMKilled 却没有 Heap OOM

Linux cgroup 限制的是容器总内存,不只是 Java Heap:

容器总内存
= Java Heap
+ Metaspace
+ Code Cache
+ Direct Memory
+ 线程栈
+ JVM Native Memory
+ JNI/本地库
+ 进程页面与文件映射

如果 -Xmx 已经接近容器 Limit,就没有给其他内存留余量。内核可能直接杀死进程,JVM 没机会抛出 OutOfMemoryError 或生成 Heap Dump。


10. 一份可直接使用的现场采集脚本

下面的脚本只采集系统信息、JVM 基线、GC 统计和 3 次线程转储,不自动执行 Heap Dump,也不主动触发 GC。

#!/usr/bin/env bash

set -u

if [ "$#" -lt 1 ]; then
  echo "Usage: $0 <java-pid> [output-dir]" >&2
  exit 1
fi

PID="$1"
TS="$(date +%Y%m%dT%H%M%S%z)"
OUT_DIR="${2:-./jvm-incident-${PID}-${TS}}"

if ! kill -0 "$PID" 2>/dev/null; then
  echo "Process $PID does not exist or is not accessible" >&2
  exit 1
fi

mkdir -p "$OUT_DIR"

date -Is > "$OUT_DIR/time.txt"
hostname > "$OUT_DIR/hostname.txt"
uname -a > "$OUT_DIR/uname.txt"
uptime > "$OUT_DIR/uptime.txt"
free -h > "$OUT_DIR/free.txt" 2>&1 || true
vmstat 1 10 > "$OUT_DIR/vmstat.txt" 2>&1 || true

ps -o user,pid,ppid,etime,%cpu,%mem,rss,vsz,nlwp,cmd -p "$PID" \
  > "$OUT_DIR/process.txt" 2>&1 || true

top -H -b -n 1 -p "$PID" \
  > "$OUT_DIR/top-threads.txt" 2>&1 || true

pidstat -p "$PID" -u -r -d -w 1 10 \
  > "$OUT_DIR/pidstat.txt" 2>&1 || true

jcmd "$PID" VM.version \
  > "$OUT_DIR/vm-version.txt" 2>&1 || true

jcmd "$PID" VM.command_line \
  > "$OUT_DIR/vm-command-line.txt" 2>&1 || true

jcmd "$PID" VM.flags \
  > "$OUT_DIR/vm-flags.txt" 2>&1 || true

jcmd "$PID" GC.heap_info \
  > "$OUT_DIR/heap-info.txt" 2>&1 || true

jstat -gcutil "$PID" 1000 20 \
  > "$OUT_DIR/jstat-gcutil.txt" 2>&1 || true

jstat -gccause "$PID" 1000 20 \
  > "$OUT_DIR/jstat-gccause.txt" 2>&1 || true

for INDEX in 1 2 3; do
  jcmd "$PID" Thread.print -l \
    > "$OUT_DIR/thread-${INDEX}.txt" 2>&1 || true
  if [ "$INDEX" -lt 3 ]; then
    sleep 5
  fi
done

echo "Evidence collected in: $OUT_DIR"
echo "Heap dump and JFR were NOT collected automatically."

使用方式:

chmod +x collect-jvm-incident.sh
./collect-jvm-incident.sh 18472 /data/incidents/order-20260830

上线前应在与生产一致的 JDK、容器和权限环境中演练。脚本里的 pidstatmpstat 等命令来自 sysstat 软件包,精简镜像中可能不存在。


11. 一次综合故障的完整推理过程

假设一个订单服务出现下面的现象:

14:00 发布新版本
14:25 流量上涨 20%
14:31 CPU 升到 700%
14:32 P99 超过 10 秒
14:33 Full GC 开始频繁出现
14:35 Pod 被重启

11.1 第一阶段:判断 CPU 根因

top -H 显示最忙的不是业务线程,而是多个 GC Worker。说明“CPU 高”很可能是 GC 压力的结果。

11.2 第二阶段:判断为什么 GC 频繁

GC 日志显示:

Young GC 间隔从 5 秒缩短到 300 毫秒
Old 区从 3 GB 持续升到 7.5 GB
Full GC 后只能降到 7.2 GB

这说明不是单纯的瞬时分配高,而是存活对象持续增加。

11.3 第三阶段:找到增长对象

两次类直方图对比发现:

OrderEvent 数量增加 80 万
byte[] 增加 3.4 GB
ConcurrentHashMap$Node 增加 90 万

Heap Dump 的 Dominator Tree 显示 EventRetryCache 支配大部分对象,Path to GC Roots 指向一个 Spring 单例 Bean。

11.4 第四阶段:回到代码

新版本为了支持失败重试,将完整事件放入本地 Map:

retryCache.put(event.getId(), event);

成功后删除,但达到最大重试次数的事件没有删除。下游短时失败导致永久失败事件快速堆积。

11.5 第五阶段:形成根因链

下游短时失败
-> 重试事件进入无界本地 Map
-> 永久失败记录未清理
-> Live Set 持续增大
-> Young GC 后大量对象晋升
-> G1 可回收空间不足
-> Full GC 频繁且回收效果差
-> GC Worker 消耗大量 CPU
-> 业务线程得不到 CPU,超时和重试进一步放大
-> 容器总内存超过限制,Pod 被 OOMKilled

这条链路说明:CPU 高、Full GC 和 OOMKilled 都是结果,真正根因是失败记录生命周期错误。

11.6 修复与验证

修复措施:

  • 重试状态持久化,不在单实例内存中无限保留;
  • 本地缓存设置最大容量和过期时间;
  • 永久失败进入死信队列;
  • 对事件体大小设置上限;
  • 增加缓存条目数、重试积压、Old 区基线和 GC Time Ratio 监控。

验证应覆盖:

  • 正常流量;
  • 下游持续失败;
  • 大事件体;
  • 重试达到上限;
  • Pod 重启恢复;
  • 24 小时以上稳定性压测。

最终以数据证明:

缓存条目数有明确上限
Old 区 GC 后基线稳定
无 Full GC
GC Time Ratio 小于既定阈值
CPU 与吞吐随流量近似线性变化
下游失败时不会无限堆积

12. 最容易误判的场景

12.1 RUNNABLE 不等于正在消耗 CPU

Java 线程状态是 JVM 视角。某些执行 Native Socket 读写的线程可能显示为 RUNNABLE,但实际正在内核中等待。必须结合线程 CPU、操作系统栈、JFR 或 Wall Clock 采样判断。

12.2 线程数多不等于线程泄漏

高并发服务可能本来就有大量线程。关键是:

  • 线程数是否持续增长;
  • 新线程来自哪个线程工厂;
  • 是否有大量同名线程;
  • 线程是否能随任务结束而回落;
  • 是否超过设计容量。

12.3 byte[] 最大不等于 byte[] 是根因

字符串、网络缓冲区、序列化结果和文件内容最终都可能表现为 byte[]。需要通过引用链找到真正持有它的业务对象。

12.4 Full GC 后释放很多不等于没有问题

如果每隔几十秒就分配数 GB 临时对象,即使每次 Full GC 都能释放,也会造成严重停顿。此时要处理异常分配和堆容量,而不是只看是否“回收成功”。

12.5 Heap Dump 正常不等于没有内存问题

Heap Dump 主要反映 Java 堆对象。Direct Memory、线程栈、JNI、mmap 和分配器碎片需要其他证据。

12.6 CPU 高不一定需要优化代码

如果吞吐同步增长、CPU 分布合理、延迟仍满足目标,可能只是容量接近上限。正确措施可能是扩容、限流或容量规划,而不是微优化业务代码。

12.7 平均值正常不等于系统健康

1 分钟 CPU 平均值可能掩盖 5 秒尖峰,平均 GC 停顿可能掩盖单次 10 秒 Full GC。线上诊断至少应关注:

  • P95、P99、最大值;
  • 时间序列;
  • 每实例分布;
  • 故障窗口内的原始事件。

13. 线上监控应该提前准备什么

13.1 JVM 指标

建议长期监控:

  • Heap Used / Committed / Max;
  • Old 区使用量及 GC 后基线;
  • Metaspace;
  • Direct Buffer Pool;
  • Young GC / Old GC 次数与暂停;
  • GC Time Ratio;
  • 对象分配速率与晋升速率;
  • Live Thread、Daemon Thread、Peak Thread;
  • Loaded Class 与 Unloaded Class;
  • Safepoint 次数和停顿时间;
  • 进程 CPU、系统 CPU、RSS、文件描述符。

13.2 应用指标

  • 请求量、错误率、P50/P95/P99;
  • 线程池 Active、Queue、Reject;
  • 数据库连接池 Active、Idle、Pending;
  • HTTP/RPC 连接池;
  • 下游超时、重试与熔断;
  • MQ 积压和消费延迟;
  • 缓存大小、命中率、加载与淘汰;
  • 大请求体、大响应体和批量任务规模。

13.3 告警不要只设静态阈值

比“Old 区超过 80%”更有效的告警包括:

  • Full GC 次数在 5 分钟内增量大于 0;
  • GC 后 Old 区最低点连续多个窗口上升;
  • GC Time Ratio 超过服务预算;
  • 线程数增长率异常;
  • RSS 与 Heap Used 的差值持续扩大;
  • 请求队列增长且吞吐下降;
  • 同一实例 CPU 异常偏离同组其他实例。

阈值应来自容量压测和服务 SLO,而不是机械照抄统一数值。


14. JVM 启动参数与故障预案

一个面向可诊断性的启动配置示例:

java \
  -Xms4g \
  -Xmx4g \
  -XX:+HeapDumpOnOutOfMemoryError \
  -XX:HeapDumpPath=/data/dumps \
  -XX:+ExitOnOutOfMemoryError \
  -XX:NativeMemoryTracking=summary \
  -Xlog:gc*,safepoint:file=/var/log/myapp/gc-%p-%t.log:time,uptime,level,tags:filecount=10,filesize=20M \
  -jar app.jar

这只是示例,不是所有应用都应照搬。上线前必须确认:

  • 容器内存是否为 Heap 之外留出足够余量;
  • Dump 目录是否是持久卷并有容量告警;
  • NMT 开销是否可接受;
  • GC 日志滚动是否符合磁盘预算;
  • OOM 后退出是否能被编排系统正确恢复;
  • 日志和 Dump 的敏感数据权限是否合规。

14.1 建议准备的故障工具箱

  • 与运行时兼容的 jcmdjstat
  • toppidstatvmstatiostatss
  • JFR 与 JDK Mission Control;
  • async-profiler;
  • Eclipse MAT;
  • GC 日志分析工具;
  • 已验证的现场采集脚本;
  • 足够且受控的诊断文件存储空间。

工具箱必须提前演练,重点验证容器权限、Attach、文件导出、磁盘容量和敏感数据流程。


15. 故障复盘模板

15.1 事件概述

故障时间:
影响范围:
用户表现:
SLO 影响:
发现方式:
恢复时间:

15.2 时间线

14:20 版本发布完成
14:31 CPU 告警
14:33 Full GC 告警
14:35 摘除异常实例
14:38 完成线程转储和 JFR 采集
14:42 回滚版本
14:47 指标恢复

15.3 证据链

监控证据:
系统证据:
线程证据:
GC 证据:
对象证据:
代码证据:
变更证据:

15.4 根因分层

  • 直接原因:最终触发不可用的技术事件;
  • 根本原因:为什么系统允许该问题发生;
  • 放大因素:重试、缺少限流、资源配置、监控缺失等;
  • 发现缺口:为什么没有更早发现;
  • 恢复缺口:为什么恢复时间过长。

15.5 行动项

每个行动项必须包含:

具体动作 + 负责人 + 截止时间 + 验收标准

例如“加强监控”不可验收,应改成:

为 RetryCache 增加当前条目数、写入速率、淘汰数监控;
当条目数超过容量 80% 持续 5 分钟时报警;
由订单组在 9 月 10 日前完成;
通过故障注入验证报警在 6 分钟内触发。

16. 最终排查清单

16.1 CPU 飙高

  • 确认整机 CPU、单核 CPU 和 Load;
  • 确认是否真的是 Java 进程;
  • 使用 top -Hpidstat 找到高 CPU 线程;
  • 十进制 TID 转十六进制 nid;
  • 连续采集 3 次线程转储;
  • 判断业务代码、GC、JIT、锁或 Native;
  • 必要时采集短时 JFR 或 CPU 火焰图;
  • 用吞吐和延迟验证优化结果。

16.2 内存泄漏

  • 区分 Heap、RSS、Metaspace、Direct Memory 和线程栈;
  • 观察 GC 后 Old 区基线;
  • 对比多个时间点的类直方图;
  • 评估风险和磁盘后再生成 Heap Dump;
  • 在 MAT 中查看 Dominator Tree 和 Path to GC Roots;
  • 排查无界缓存、ThreadLocal、监听器和 ClassLoader;
  • Heap 稳定而 RSS 上涨时使用 NMT 和 Native 工具;
  • 修复后做长时间稳定性验证。

16.3 频繁 Full GC

  • 保留完整 GC 日志;
  • 确认触发原因、停顿和回收效果;
  • 计算分配速率、晋升速率和 Live Set;
  • 检查泄漏、大对象、显式 GC 和元空间;
  • G1 下检查 Humongous 与 Evacuation Failure;
  • 先修对象生命周期,再调堆和收集器;
  • 压测验证 GC Pause P99 和 GC Time Ratio。

16.4 线程死锁或卡死

  • 连续采集多次线程转储;
  • 查看 JVM 是否明确报告死锁;
  • 构造线程、锁、持有者和等待者关系;
  • 区分普通 BLOCKED、锁竞争和循环等待;
  • 排查线程池饥饿、连接池耗尽和异步互等;
  • 检查数据库锁与分布式锁;
  • 统一锁顺序、缩小临界区并增加超时;
  • 用并发测试和长时间压测验证。

17. 总结

JVM 线上故障排查的核心不是记住多少命令,而是建立可验证的因果链。

CPU 飙高时,要从进程定位到线程,再从线程映射到 Java 调用栈;内存上涨时,要区分堆、堆外和容器总内存,再通过趋势、直方图和引用链定位持有者;频繁 Full GC 时,要分析触发原因、回收效果和对象生命周期;线程卡死时,要区分真正的循环等待、普通锁竞争、线程池饥饿和外部系统阻塞。

可以把整套方法浓缩成五句话:

先确认影响,再保护业务;
先保存现场,再重启实例;
先建立趋势,再分析快照;
先修生命周期,再调整参数;
先定义验收指标,再宣布问题解决。

当监控、GC 日志、JFR、线程转储、Heap Dump 和代码调用链能在同一时间窗口互相印证时,排查才从“经验猜测”变成了工程化诊断。


18. 参考资料

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值