JVM 线上故障排查实战:CPU 飙高、内存泄漏、频繁 Full GC 与线程死锁
线上 Java 服务突然 CPU 100%、内存持续上涨、Full GC 接连不断,或者接口全部卡住时,最危险的做法往往不是“不会使用某个命令”,而是没有形成一条可靠的证据链:看到 CPU 高就重启,看到内存大就加堆,看到 Full GC 就换收集器,看到线程阻塞就认定发生了死锁。
真正有效的 JVM 故障排查,需要把操作系统、JVM、线程、对象、GC 日志和业务调用链放在同一条时间线上,回答下面几个问题:
- 故障发生在什么时间,影响了哪些实例和接口?
- 异常资源究竟被哪个进程、哪个线程、哪类对象消耗?
- 看到的是原因、结果,还是伴随现象?
- 重启或扩容之前,是否保存了足以复盘的现场证据?
- 修复后用什么指标证明问题真的消失,而不是暂时被掩盖?
本文以 Linux 上的 HotSpot JVM 为主要环境,给出一套可以直接用于生产值班的排查方法,重点覆盖:
- CPU 飙高;
- Java 堆与堆外内存泄漏;
- 频繁 Full GC;
- Java 线程死锁与线程池饥饿;
- Docker、Kubernetes 场景下的诊断差异;
- 现场采集、根因分析、修复验证和长期预防。
文中的命令以 JDK 17、JDK 21 等现代 JDK 为主要基线。不同 JDK 发行版、操作系统和 GC 收集器支持的诊断命令可能不同,执行前应先用
jcmd <pid> help和java -Xlog:help确认当前环境实际支持的参数。
1. 先建立正确的排查模型
JVM 线上故障很少是孤立问题。CPU、内存、GC、线程和外部依赖之间经常互相影响:
对象分配速度过快
-> Young GC 频繁
-> 对象晋升和老年代增长
-> 并发标记来不及完成
-> Full GC
-> Stop-The-World 时间增加
-> 请求堆积、超时与重试
-> 更多线程、对象和 CPU 消耗
另一种常见链路是:
下游接口变慢
-> 工作线程长时间等待
-> 连接池和线程池耗尽
-> 请求在队列中堆积
-> 上游重试
-> 堆内临时对象快速增长
-> GC 压力升高
所以排查时不能只看一张监控图,也不能只拿一次线程快照。应该同时观察四个层次:
| 层次 | 重点证据 | 常用工具 |
|---|---|---|
| 业务层 | 错误率、延迟、吞吐、慢接口、重试、发布变更 | APM、日志、链路追踪、发布平台 |
| 系统层 | CPU、负载、RSS、磁盘 I/O、网络、上下文切换 | top、pidstat、vmstat、iostat、ss |
| JVM 层 | 堆、元空间、线程、GC、Safepoint、JIT、类加载 | jcmd、jstat、GC 日志、JFR |
| 代码层 | 热点方法、对象引用链、锁顺序、无界容器、超时配置 | 火焰图、Heap Dump、线程转储、源码 |
一个可靠结论至少应包含:
异常时间窗口
+ 资源趋势
+ JVM 证据
+ 代码调用链
+ 可复现或可验证的修复结果
只有“某个类实例很多”或“某个线程是 RUNNABLE”,还不等于找到根因。
2. 生产排查的基本原则
2.1 先保业务,再保现场
如果故障已经造成大面积不可用,应先执行经过授权的止损措施,例如:
- 摘除异常实例;
- 限流或关闭高风险入口;
- 暂停非核心定时任务;
- 回滚刚发布的版本;
- 扩容健康实例承接流量。
但在重启异常实例之前,最好争取几十秒到几分钟采集低风险证据:
- 系统时间与实例信息;
- 进程级 CPU、内存、线程数;
- 3 次线程转储;
- JVM 参数与堆概况;
- GC 统计与最近的 GC 日志;
- 必要时采集一段短时 JFR。
如果机器已经失去响应、Heap Dump 会进一步压垮磁盘,或者继续采集会扩大事故,则应优先恢复服务,不要为了“证据完整”牺牲可用性。
2.2 先做低影响操作,再做高影响操作
常见诊断动作的风险大致如下:
| 动作 | 一般影响 | 注意事项 |
|---|---|---|
ps、top、pidstat、读取监控 | 低 | 适合第一时间使用 |
jcmd <pid> VM.flags、GC.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.txt 和 heap.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高要检查磁盘和存储;si、so持续非零说明正在发生 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 使用比例;YGC、YGCT:Young GC 次数与累计耗时;FGC、FGCT: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
分析时不要犯三个错误:
byte[]、char[]、String很多,只说明底层载体多,还要找到业务持有者;- 实例数量大不等于泄漏,缓存、索引和连接池可能是有意保留;
- 单次直方图缺少增长趋势,至少应对比两个时间点。
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 时,建议按照下面的顺序:
- 查看 Leak Suspects Report,获得初步线索;
- 查看 Dominator Tree,寻找 Retained Heap 最大的支配对象;
- 按类名或 ClassLoader 分组;
- 查看某个可疑对象的 Path to GC Roots;
- 排除弱引用、软引用等不符合目标的路径;
- 回到源码确认“为什么这个引用没有释放”。
两个重要概念:
- 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 启动时间戳;filecount和filesize用于滚动;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 是否逼近上限;
LGCC和GCC显示的最近、当前 GC 原因。
jstat 适合现场快速观察,但它被官方标记为实验性工具,输出格式可能变化,不应把固定列位解析脚本当成长期稳定协议。
6.4 从 GC 日志回答四个问题
每次异常 GC 都应分析:
- 原因:Allocation Failure、Metadata GC Threshold、System.gc()、Humongous Allocation,还是收集器退化?
- 回收效果:例如
8G -> 7.7G,还是8G -> 2G? - 停顿时间:一次 5 秒,还是每秒一次 100 毫秒?
- 恢复速度:回收后多久再次打满?
可以用一个简单的判断矩阵:
| 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 的正确优化顺序
推荐顺序:
- 确认 GC 原因和回收效果;
- 排除内存泄漏和无界缓存;
- 找出主要对象分配栈和大对象;
- 降低重试、批量加载、序列化复制等异常分配;
- 根据容器上限与 Live Set 重新评估堆容量;
- 确认当前收集器是否符合延迟和吞吐目标;
- 最后才微调 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 线程死锁
经典死锁需要同时满足:
- 互斥:资源一次只能由一个线程占有;
- 占有并等待:线程持有一个资源,同时等待另一个资源;
- 不可剥夺:资源不能被其他线程强制抢走;
- 循环等待:多个线程形成首尾相接的等待环。
示例:
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"
阅读顺序:
- 哪些线程参与死锁;
- 每个线程当前在等待哪把锁;
- 这把锁被谁持有;
- 线程在什么业务方法中获取锁;
- 不同代码路径的加锁顺序是否相反。
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.max 是 max,表示没有设置该项硬上限;如果是数字,则单位是字节。
还应检查:
- 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_line、VM.flags 和 GC.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、容器和权限环境中演练。脚本里的 pidstat、mpstat 等命令来自 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 建议准备的故障工具箱
- 与运行时兼容的
jcmd、jstat; top、pidstat、vmstat、iostat、ss;- 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 -H或pidstat找到高 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 和代码调用链能在同一时间窗口互相印证时,排查才从“经验猜测”变成了工程化诊断。

651

被折叠的 条评论
为什么被折叠?



