Skip to content

OOM 排查与堆转储分析 ​

OutOfMemoryError 不是一种错误,是六种不同问题的同一个出口。先看文案再动手。

一、先分清是哪一种 OOM ​

报错文案真实含义常见根因
Java heap space堆装不下了大结果集、缓存无上限、内存泄漏
GC overhead limit exceeded98% 时间在 GC 只回收 2%同上,且已接近死循环
Metaspace类元数据区满动态代理/字节码生成、热部署泄漏 ClassLoader
unable to create new native thread建不了新线程线程数爆炸、ulimit -u 太小、栈太大
Direct buffer memory堆外直接内存满Netty/NIO 未释放、MaxDirectMemorySize 太小
StackOverflowError单线程栈深超限递归无出口、循环引用序列化

容器里"没有报错就死了"

如果日志里没有 OOM 文案、进程直接消失,几乎一定是 cgroup 把整个 JVM 杀了(OOMKilled)。这时要查内核日志而不是应用日志:

bash
dmesg -T | grep -i -E 'oom|killed process' | tail
grep -i oom /var/log/messages
cat /sys/fs/cgroup/memory/memory.oom_control     # cgroup v1

应用侧看不到任何异常,因为 JVM 是被 SIGKILL 的,没机会打印。

二、抓现场(进程还活着时) ​

bash
jstat -gcutil <pid> 1000 5                       # GC 是否已失效
jcmd <pid> GC.class_histogram | head -25         # 谁占着内存(轻量,先做这个)
jcmd <pid> GC.heap_info
jcmd <pid> VM.native_memory summary              # 需启动时加 -XX:NativeMemoryTracking=summary

GC.class_histogram 输出:

text
 num     #instances         #bytes  class name (module)
-------------------------------------------------------
   1:        8412003     1076736384  [B  (java.base@17.0.11)
   2:        4102998      459535776  com.bfcube.model.OrderDetail
   3:        6210884      198748288  java.util.HashMap$Node
   4:        4098771      131159072  java.lang.Long

解释:OrderDetail 有 410 万个实例、占 440MB,且 HashMap$Node 数量与之一致 → 有个 Map/List 在无限增长。到这一步往往已经能定位到代码,不必等 dump。

三、抓堆转储 ​

bash
# 首选:jcmd(JDK 8+ 都支持)
jcmd <pid> GC.heap_dump /data/dump/app-$(date +%Y%m%d-%H%M).hprof

# 兜底:老 JDK
jmap -dump:live,format=b,file=/data/dump/app.hprof <pid>

输出:

text
Heap dump file created [3421876543 bytes in 41.228 secs]

解释:

  • :live 只导存活对象(会先触发一次 Full GC),文件小、分析快;不带 live 保留垃圾,适合查"为什么回收不掉"。
  • 4G 堆耗时 41 秒且全程 STW——务必先摘流量。
  • 文件要落到有足够磁盘的分区,写一半失败等于白停一次。

四、提前配置,别等出事 ​

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-XX:+ExitOnOutOfMemoryError

解释:OOM 那一刻自动落盘,是唯一"零额外停顿"的取证方式。HeapDumpPath 给目录比给文件名好——多实例不会互相覆盖。配合 ExitOnOutOfMemoryError 让进程干净退出、由 systemd/K8s 拉起新实例。

五、用 MAT / jhat 分析 ​

服务器上没有 GUI 时,先把 hprof 拉回本地,用 Eclipse MAT:

  1. Leak Suspects Report —— 直接告诉你哪个对象占了 80% 以上
  2. Dominator Tree —— 按" retained size"排序,找真正的持有者
  3. 右键 → Path to GC Roots → exclude weak/soft references —— 看它为什么不能被回收

命令行快速看结构(不依赖 MAT):

bash
jhat -J-Xmx4g /data/dump/app.hprof     # 起 http://localhost:7000(JDK 9 起已移除,需装独立版)
strings app.hprof | grep -oE 'com\.bfcube\.[A-Za-z.]+' | sort | uniq -c | sort -rn | head

解释:strings 这招很糙但快——能在 30 秒内看出"哪类业务对象在堆里出现次数异常多",适合没有 MAT 环境时先定方向。

六、五类典型场景的处置 ​

1. 一次性查太多数据 ​

java
// 现象:List<Order> 有 200 万个元素
SELECT * FROM orders WHERE status = 0        -- 全表扫

处置:分页 + 流式(fetchSize)、只查需要的列、把聚合下推到数据库。

2. 本地缓存无上限 ​

java
private static final Map<String, Object> CACHE = new ConcurrentHashMap<>();  // 只放不清

处置:换 Caffeine 并设 maximumSize + expireAfterWrite。任何 static Map 都要问一句"它会停吗"。

3. Metaspace 泄漏 ​

text
java.lang.OutOfMemoryError: Metaspace
bash
jcmd <pid> GC.class_stats | head        # 需 -XX:+UnlockDiagnosticVMOptions
jstat -gcutil <pid> 1000                # 看 M 列是否只涨不降

处置:查动态代理生成(Groovy/SpEL/CGLIB 每次 new 一个 ClassLoader)、热部署未卸载旧 ClassLoader。临时缓解是加大 -XX:MaxMetaspaceSize,但那只是延后爆炸。

4. 线程数导致的 OOM ​

bash
cat /proc/<pid>/status | grep Threads
ulimit -u

处置:线程池收敛到合理规模;-Xss 从 1M 降到 512k 能多撑一倍线程数;系统侧调 /etc/security/limits.conf 的 nproc。

5. 堆外内存(Direct Buffer) ​

bash
jcmd <pid> VM.native_memory summary
text
Total: reserved=5832MB, committed=5210MB
-                 Java Heap (reserved=4096MB, committed=4096MB)
-                     Class (reserved=1058MB, committed=210MB)
-                  Internal (reserved=1284MB, committed=1284MB)   ← 这里
-                Thread Stack (reserved=204MB, committed=204MB)

解释:Internal 1.2G 基本就是 DirectByteBuffer。查 Netty 的 ByteBuf 是否 release、-XX:MaxDirectMemorySize 是否设太小。

七、复盘清单 ​

一次 OOM 处理完,这五项要留下:

  1. 触发时间点的流量/任务特征(是不是刚好跑了个大报表)
  2. GC.class_histogram 截图 + hprof 文件归档位置
  3. 从 OOM 到恢复的完整时间线(含 dmesg 时间戳)
  4. 修复动作与验证方式
  5. 兜底参数是否已加(HeapDumpOnOutOfMemoryError 有没有配)