外观
OOM 排查与堆转储分析
OutOfMemoryError 不是一种错误,是六种不同问题的同一个出口。先看文案再动手。
一、先分清是哪一种 OOM
| 报错文案 | 真实含义 | 常见根因 |
|---|---|---|
Java heap space | 堆装不下了 | 大结果集、缓存无上限、内存泄漏 |
GC overhead limit exceeded | 98% 时间在 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=summaryGC.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:
- Leak Suspects Report —— 直接告诉你哪个对象占了 80% 以上
- Dominator Tree —— 按" retained size"排序,找真正的持有者
- 右键 →
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: Metaspacebash
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 summarytext
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 处理完,这五项要留下:
- 触发时间点的流量/任务特征(是不是刚好跑了个大报表)
GC.class_histogram截图 + hprof 文件归档位置- 从 OOM 到恢复的完整时间线(含 dmesg 时间戳)
- 修复动作与验证方式
- 兜底参数是否已加(
HeapDumpOnOutOfMemoryError有没有配)