JVM 综合诊断:从 OOM、Full GC 与 CPU 飙高反推根因
凌晨告警同时出现:CPU 95%、接口超时、Full GC 增多、容器内存接近上限。最危险的做法是立刻执行所有“排障命令”:heap dump 可能触发长停顿并写满磁盘,强制 GC 会改变现场,连续线程栈和 profile 也有成本。真正的诊断从证据优先级开始。
现场流程从冻结发布和时间线开始,先取得版本、参数、容器限额和低风险计数器;再根据“堆、native、线程、CPU、GC、类加载”分支选择线程栈、JFR、NMT 或 dump;最后做最小复现和修复后对照。命令名称不是能力,只有说清每条命令回答什么、改变什么、失败时怎样保住服务,诊断才算闭环。
动手取证前,先确认现场条件
JVM 故障发生后,先判断问题更接近堆、堆外内存、CPU、线程、类加载还是容器退出,再从 OOM 分类、Full GC、线程栈、JFR、class histogram、heap dump、MAT、NMT 和 hs_err 中选择证据。完整值班 SOP 和操作系统内核调优属于另一条问题链,这里只解决怎样从 JVM 现象追到运行时根因。
执行任何诊断前确认同用户/同容器可附加、磁盘空间、文件权限、数据脱敏和留存期限。JDK 25 jcmd 文档为每个命令标出影响级别,生产操作应先运行 jcmd PID help COMMAND,不要复制未知版本的命令模板。
先用受控目标练习“低风险证据先行”
examples/backend-development/jvm/jvm-diagnostics/DiagnosticTarget.java 保留约 12 MiB 堆对象并启动一个有名字的 CPU worker,随后输出 PID 并等待。它让类直方图、线程栈、JFR 和堆信息指向同一个已知现场,不需要在生产进程上练命令。
$sources = Get-ChildItem examples/backend-development/jvm -Recurse -Filter *.java
javac --release 17 -Xlint:all -Werror -d out/jvm $sources.FullName
java -cp out/jvm example.jvm.diagnostics.DiagnosticTarget 60000在另一终端使用它打印的 PID:
jcmd <pid> VM.version
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print -l
jcmd <pid> GC.class_histogram
jcmd <pid> JFR.start name=diagnostic settings=profile duration=20s filename=diagnostic.jfr线程栈应能找到 diagnostic-hot-worker,直方图能看到被保留的 byte[],JFR 则记录这段时间内的 CPU 采样。命令能返回不等于诊断完成:还要把线程名、数组所有者和时间线对回程序已知行为。正式环境执行前先用 jcmd <pid> help <command> 查看影响级别;heap dump、强制 GC 和高开销采集不能因为在练习程序上安全就直接复制到高峰生产。
jmap、jstack、MAT 怎么配合使用
怀疑内存泄漏时,top 和监控只能告诉你“内存涨了”,不能告诉你谁持有对象。怀疑线程阻塞时,接口日志只能告诉你“慢了”,不能告诉你线程卡在哪里。这个时候需要 heap dump、类直方图和线程栈。
先抓轻量信息:
jcmd "$PID" GC.class_histogram > class-histogram.txt
jcmd "$PID" Thread.print -l > thread-dump-1.txt
sleep 10
jcmd "$PID" Thread.print -l > thread-dump-2.txt
sleep 10
jcmd "$PID" Thread.print -l > thread-dump-3.txt如果必须导出堆:
jcmd "$PID" GC.heap_dump /var/log/myapp/heapdump/app-$(date +%Y%m%d%H%M%S).hprof也可以使用 jmap:
jmap -dump:format=b,file=/var/log/myapp/heapdump/app.hprof "$PID"
jmap -histo:live "$PID" | head -n 40jstack 的基本用法:
jstack -l "$PID" > thread-dump.txt如果已经在持续录 JFR,可以在怀疑泄漏时导出一份带 GC Roots 路径的记录。这个动作可能让应用短暂停顿,生产上要避开峰值窗口:
jcmd "$PID" JFR.check
jcmd "$PID" JFR.dump name=profile filename=/var/log/myapp/leak.jfr path-to-gc-roots=true拿到 .jfr 后,优先用 JDK Mission Control 看内存、方法采样、锁、线程、GC 和异常事件。没有图形环境时,先把 JFR 文件拉到分析机;不要为了打开 JMC 在生产机器上装桌面环境。短采样排障关注“事件是否覆盖故障窗口”,长期常开录制关注磁盘轮转、权限和脱敏。
拿到 .hprof 后,用 Eclipse MAT 离线分析。建议按这个顺序看:
Leak Suspects:先看 MAT 自动推断的可疑泄漏点。Histogram:看对象数量和浅堆大小,定位大类。Dominator Tree:看谁支配了大块内存。
Path To GC Roots:看对象为什么没被回收。Thread Overview:如果 dump 里包含线程信息,看线程局部变量、ThreadLocal、队列任务是否持有大对象。
真正的泄漏不是“某个类对象多”,而是“对象持续增长,并且有稳定引用链导致 GC 回收不掉”。MAT 里要看 Retained Heap 和 GC Roots,不能只盯 Shallow Heap。
导出和分析 dump 之前,还要防住三个风险:
Heap dump 可能非常大,也可能让进程暂停。高峰期直接 dump 可能雪上加霜。dump 里可能包含用户数据、token、请求参数和业务敏感信息,不能随意外传。jmap -histo:live 会触发一次 Full GC,生产高峰慎用。
只抓一次线程栈容易误判。线上阻塞、死锁、慢下游要连续抓 3 次以上,看同一批线程是否卡在同一位置。
核心服务平时就要准备好 dump 目录、磁盘容量、权限和清理策略。发生 OOM 时让 JVM 自动生成 heap dump,同时配套脱敏、访问控制和保留周期。dump 尽量拿到离线环境分析,不要在生产机器上打开 MAT。
OOM 按错误类型拆,不要只说“内存溢出”
OutOfMemoryError 有很多类型,每种排查入口不同。把所有 OOM 都当成堆不够,会导致堆越调越大,容器越容易被杀。
先收集现场:
grep -i "OutOfMemoryError" /var/log/myapp/app.log | tail -n 50
ls -lh /var/log/myapp/heapdump
dmesg -T | grep -E 'Killed process|Out of memory|oom-killer' | tail -n 20
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
jcmd "$PID" GC.heap_info
jcmd "$PID" VM.native_memory summary scale=MB如果在 Kubernetes 里,还要看容器是否被系统杀掉:
kubectl describe pod app-xxx | grep -A8 -E 'Last State|OOMKilled|Exit Code'
kubectl top pod app-xxx --containers常见 OOM 类型可以这样分:
| 现象 | 常见原因 | 先看什么 |
|---|---|---|
Java heap space | 堆对象增长、缓存无边界、大查询、大响应体 | heap dump、Histogram、Dominator Tree |
GC overhead limit exceeded | GC 大量耗时但回收很少 | GC 日志、老年代趋势、对象引用链 |
Requested array size exceeds VM limit | 一次性创建超大数组或集合扩容超过虚拟机限制 | 异常栈、入参大小、分页大小、响应体大小 |
Metaspace | 动态类、类加载器泄漏、插件热加载 | class loading 指标、Metaspace、类加载器 |
Direct buffer memory | NIO/Netty/文件处理直接内存失控 | NMT、BufferPool 指标、MaxDirectMemorySize |
unable to create native thread | 线程数过多、ulimit、PID 限制、栈过大 | Threads、ulimit -u、/proc/$PID/limits |
容器 OOMKilled | RSS 超过 cgroup 限制,未必是堆满 | 容器事件、RSS、堆外、线程数、cgroup 限制 |
更实用的做法是先按错误入口分流,再决定要不要扩大堆。下面这张决策树可以直接放进应急手册:它把“JVM 自己抛的 OOM”和“容器把进程杀掉”拆开,避免一看到内存事故就盲目调大 -Xmx。
修复后不能只看“没有再 OOM”,还要看根因指标是否下降:缓存条目数是否有上限,线程数是否稳定,直接内存是否可控,老年代是否能回落,容器 RSS 是否远离限制线。
OOM 类型不同,错误的扩容动作也不同:
看到 Requested array size exceeds VM limit 就只调堆。它通常是单次数组或集合尺寸不合理,先查分页、批量导出、聚合结果和响应体。看到容器 OOMKilled 就去调大 -Xmx。如果 RSS 已经超限,调大堆可能更快触发 OOMKilled。只保留一份 dump,下一次 OOM 覆盖上一份。HeapDumpPath 目录要支持多文件命名或按发布版本归档。
OOM 后自动重启,但没有把 OOM 原因、dump 路径和容器事件打进告警。
核心服务的监控面至少同时包含 heap used、non-heap、direct buffer、thread count、process RSS、container memory、GC pause、Full GC 和 OOM count。只监控堆使用率,解释不了堆外或容器层面的内存事故。
CPU 飙高:从热点线程反推代码位置
CPU 飙高时,不要先猜死循环。先把“哪个线程在烧 CPU”找出来,再把操作系统线程 ID 对到 Java 线程栈。
先把操作系统线程 ID 对回 Java 栈:
top -Hp "$PID"
pidstat -t -p "$PID" 1 5记下 CPU 高的线程 ID,比如十进制线程 ID 是 12345,转成十六进制:
printf "%x\n" 12345抓线程栈:
jcmd "$PID" Thread.print -l > thread-cpu.txt
grep -n "nid=0x3039" -A40 -B5 thread-cpu.txt如果问题持续,可以用 JFR 做一段短采样:
jcmd "$PID" JFR.start name=cpu settings=profile duration=120s filename=/var/log/myapp/app-cpu.jfr线程状态要先翻译成排障语言:
| Java 线程状态 | 常见现场 | 判断方式 |
|---|---|---|
RUNNABLE | 正在运行、可运行、native 调用或系统调用 | 结合 top -Hp 看是否真的烧 CPU |
BLOCKED | 等 monitor 锁 | 看 Thread.print -l 里的 blocked on 和持锁线程 |
WAITING | 等 LockSupport.park、Object.wait、Future.get、队列 | 看等待对象、线程名前缀和业务调用链 |
TIMED_WAITING | sleep、带超时等待、连接池等待、定时任务 | 看是否大量堆积在同一依赖或队列 |
NEW / TERMINATED | 一般不是线上主要状态 | 更多看线程创建速率和生命周期 |
JDK 21+ 如果使用虚拟线程,还要多看一层:
# 传统线程视角,适合平台线程和锁排查
jcmd "$PID" Thread.print -l > thread-platform.txt
# 虚拟线程很多时,优先导出结构化线程 dump
jcmd "$PID" Thread.dump_to_file -format=json /var/log/myapp/thread-vt.json虚拟线程排障的关键是区分“任务很多”和“承载线程被占住”。JDK 21 的虚拟线程已经正式可用,但如果虚拟线程在 synchronized 或 native/VM 帧里阻塞,可能把 carrier 平台线程一起占住;JDK 24 的 JEP 491 已经让 synchronized 里的阻塞大多可以释放 carrier,但 native/VM 帧、类初始化等待等剩余 pinning 仍要用 JFR 和结构化 dump 观察。
虚拟线程不是“更快的线程”。它适合大量等待型任务,尤其是网络 I/O、数据库 I/O、RPC 等阻塞等待;CPU 密集型任务不会因为换成虚拟线程就变快。虚拟线程也不应该像平台线程那样池化,限制下游并发要用连接池、信号量、限流器,而不是固定大小虚拟线程池。
引入虚拟线程后,传统线程经验有四处需要改写:
看到虚拟线程数量很大就恐慌。虚拟线程数量和 OS 线程数量不是一回事,OS 只能看到承载它们的 carrier/platform threads。把虚拟线程当成数据库连接池的替代品。虚拟线程能让等待成本降低,但不能让数据库承受无限并发。用 ThreadLocal 在虚拟线程里缓存昂贵对象。虚拟线程通常短生命周期、数量巨大,每个线程一份资源可能把内存打爆。
只用传统平铺 thread dump 看虚拟线程。虚拟线程很多时要用结构化 dump,并配合 JFR 看阻塞、锁、I/O 和 pinned 相关事件。
对齐热点线程与 Java 栈后,常见现场会分成四类:
热点线程栈停在业务循环、JSON 序列化、正则、加解密、日志格式化、集合遍历,通常是业务 CPU。热点线程是 GC 线程,同时 GC 日志密集,可能是 GC 压力。大量线程 BLOCKED 在同一把锁,更像锁竞争。
大量线程 WAITING/TIMED_WAITING 在连接池、Future、队列或下游调用,要继续看线程池和依赖耗时。
从热点线程走到根因,还要排除三个假象:
Java 线程状态为 RUNNABLE 不等于一定在消耗 CPU,可能在 native 调用或系统调用里。只看应用线程,不看 GC、JIT、日志异步线程、Netty event loop、ForkJoinPool。CPU 高峰过了才抓现场,只能看到恢复后的状态。高风险服务要提前准备自动采样或应急脚本。
CPU 排障时,要把线程栈、接口时间线、GC 日志、发布记录、下游耗时、日志量和机器负载放在一起看。修复后再检查 CPU 水位、上下文切换、接口 P99、错误率和线程池队列是否同时恢复。
这一节只讲真实生产里容易把 JVM 方案拖垮的部分。
容器内存不是堆内存
容器限制是 JVM 进程和系统开销的总预算。生产上不要这样配:
container memory = 2Gi
-Xmx = 2g更稳的起点是:
container memory = 2Gi
heap max = 1200m ~ 1500m
reserved = metaspace + direct memory + thread stacks + code cache + native + filesystem/cache上线前验证:
jcmd "$PID" GC.heap_info
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'
jcmd "$PID" VM.native_memory summary scale=MB如果容器 RSS 长期超过限制的 85%,不要只调堆,先拆堆外、线程、Metaspace 和直接内存。
GC 日志和 heap dump 要提前设计
GC 日志要轮转,dump 目录要可写、容量足够、权限受控。一次 4G 堆的 dump 文件通常接近数 GB,磁盘只剩 2G 时,OOM 现场可能写不完整。
检查:
df -h /var/log/myapp
test -w /var/log/myapp/heapdump && echo ok
ls -lh /var/log/myapp/gc.log*dump 既是事故证据,也是敏感数据。下载权限要受控,分析完成后按保留策略清理。
线程数要按内存和调度成本算账
线程不是免费的。线程越多,线程栈越多,上下文切换越多,排障越难。看到 unable to create native thread 时,不要只怪系统限制,先看是不是线程池无边界、定时任务泄漏或每个请求都创建线程。
检查:
cat /proc/"$PID"/status | grep Threads
cat /proc/"$PID"/limits | grep -E 'processes|open files|stack'
ps -L -p "$PID" | wc -l每个业务线程池都要有名字、线程上限、队列上限和拒绝策略。线程数告警除了看总数,还要按线程名前缀分组,否则无法定位是哪一类任务失控。
容量评估看活跃数据,不看拍脑袋堆大小
初始容量可以用压测和 GC 日志估算。稳定流量下观察 Full GC 或老年代回收后的存活对象规模,再结合峰值流量、对象生命周期和安全余量估算堆。
判断方式:
jstat -gcutil "$PID" 1000 30
grep -E 'Pause Full|Old|Humongous' /var/log/myapp/gc.log | tail -n 100如果回收后老年代仍持续升高,先怀疑对象生命周期和缓存边界;如果回收后下降明显但 GC 很频繁,才考虑堆大小、分配速率和批处理拆分。
监控告警要覆盖 JVM 与业务边界
最低监控项:
JVM:heap、non-heap、Metaspace、direct buffer、GC pause、Full GC、thread count、class loaded。进程:RSS、CPU、文件句柄、上下文切换、打开连接数。容器:memory working set、OOMKilled、CPU throttling、重启次数。
业务:QPS、P95/P99、错误率、下游耗时、线程池队列、拒绝次数。
JVM 指标只能解释进程状态,不能替代业务指标。一次稳定性事故最终要能串起“用户慢请求 -> 应用线程 -> JVM 状态 -> 下游依赖 -> 变更记录”。
故障排查速查
| 现象 | 第一组命令 | 重点判断 |
|---|---|---|
| 接口 P99 突增 | jstat -gcutil $PID 1000 10、jcmd $PID Thread.print -l | GC 停顿是否覆盖慢请求窗口,线程是否卡在下游或锁 |
| 堆内存持续上涨 | jcmd $PID GC.class_histogram、heap dump + MAT | 对象是否持续增长,Retained Heap 和 GC Roots 是什么 |
| 容器被 OOMKilled | kubectl describe pod、cat /proc/$PID/status、NMT | RSS 是否超限,堆外和线程是否失控 |
| CPU 100% | top -Hp $PID、printf "%x\n" tid、Thread.print | 热点线程栈属于业务、GC、锁竞争还是 native |
| 启动后 CPU/延迟抖动 | Compiler.codecache、JFR、线程栈 | 是否 JIT 预热、编译线程密集或代码缓存紧张 |
| 线程数暴涨 | /proc/$PID/status、jcmd Thread.print | 哪类线程名前缀增长,是否线程池无界或每请求建线程 |
| Full GC 频繁 | GC 日志、jstat、heap dump | 回收后老年代是否下降,是否缓存/大对象/类加载器泄漏 |
| Direct Memory OOM | NMT、BufferPool 指标、Netty 指标 | 是否有直接内存上限,ByteBuf 是否释放,文件处理是否限流 |
JDK 版本、镜像版本、启动命令和 JVM 参数已经记录。GC 日志已打开并轮转,日志目录有容量监控。OOM heap dump、hs_err 文件路径可写,保留和脱敏策略明确。
容器内存大于 heap + native + direct + metaspace + threads 的总预算。JIT 预热、代码缓存和 JFR 应急采样方案已经演练过。线程池有名字、上限、队列上限、拒绝策略和指标。
监控覆盖 JVM、进程、容器、业务和下游依赖。变更 JVM 参数时有灰度、对比指标和回滚命令。线上排障脚本已经演练过,确认当前运行镜像能执行 jcmd 或有替代诊断方式。
常用命令模板
# 找 Java 进程
ps -ef | grep java | grep -v grep
# 看最终 JVM 参数
jcmd "$PID" VM.command_line
jcmd "$PID" VM.flags
jcmd "$PID" VM.classloaders
# 看堆、类直方图、线程栈
jcmd "$PID" GC.heap_info
jcmd "$PID" VM.metaspace basic
jcmd "$PID" VM.native_memory summary scale=MB
jcmd "$PID" Compiler.codecache
jcmd "$PID" GC.class_histogram > class-histogram.txt
jcmd "$PID" Thread.print -l > thread-dump.txt
# 导出 heap dump
jcmd "$PID" GC.heap_dump /var/log/myapp/heapdump/app.hprof
# 看 GC 概况
jstat -gcutil "$PID" 1000 10
# 查 CPU 热点线程
top -Hp "$PID"
printf "%x\n" "$TID"
jcmd "$PID" Thread.print -l | grep -A40 -B5 "nid=0x$HEX_TID"
# 看进程 RSS 和线程数
cat /proc/"$PID"/status | grep -E 'VmRSS|VmHWM|Threads'重启恢复,不等于根因已经找到
诊断完成的标准不是“重启后恢复”,而是形成可证伪结论:现象与时间线一致,证据指向具体资源和所有者,修复改变了对应机制,同等负载下指标恢复,并且没有把压力转移到另一个池或下游。若只能确认重启释放了内存,就应保留为未定位事件,而不是编造一个泄漏类名。
现场需要的官方工具入口
Prepare Java for Troubleshooting:怎样提前保留 GC 日志、崩溃文件、版本和启动参数。
JDK 25 Diagnostic Tools:JFR、heap dump、线程信息和其他诊断工具各自适合回答什么问题。JDK 25 jcmd:每条诊断命令的参数、影响级别和 help 入口。
JDK 25 Native Memory Tracking:堆稳定但 RSS 上涨时,怎样比较 HotSpot 内部 native 内存。
事故前能准备的东西越多,事故中需要冒险执行的命令就越少。至少让版本、启动参数、GC 日志、容器限额和 JFR 基线在平时就可取得;真正出事时,再根据堆、native、CPU、线程或类加载的现象逐级加证据。这样既保住现场,也不会为了“多抓一点”把服务和磁盘一起拖垮。
