OOM问题定位:8种OOM类型的快速判断与MAT分析实战
OOM问题定位:8种OOM类型的快速判断与MAT分析实战
适读人群:Java中高级开发工程师 | 阅读时长:约18分钟 | 适用JDK版本:JDK 8 / 11 / 17 / 21
开篇故事
2018年的一个凌晨三点,我被运维紧急呼叫。核心订单系统宕机,日志里满屏都是java.lang.OutOfMemoryError,服务完全不可用。
第一反应是扩堆,把-Xmx从4G改到8G,重启。撑了不到20分钟,又OOM了。
第二次重启之前,我临时加了-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof,等第三次OOM时,捞到了一个7.8G的heap dump文件。
用MAT(Eclipse Memory Analyzer)打开,第一个界面就直接给出了结论:"Problem Suspect 1: com.alibaba.druid.pool.DruidConnectionHolder占用了6.8GB内存,共持有15万个实例"。
连接池对象,15万个!正常情况下连接池的最大连接数是200,怎么可能有15万个Connection对象?
追查代码,发现一个新上线的定时任务,每次执行时都会new一个新的DataSource,完成后没有close,也没有被任何引用持有(只有静态Map里的一个弱引用)。DataSource里面的连接池对象生命周期跟着DataSource,但DataSource没有被关闭,连接池里的Connection全部保持活跃状态。定时任务每分钟执行一次,跑了两个多小时,积累了几千个DataSource实例,每个实例里有几十个Connection对象,总共几十万个连接对象撑爆了堆。
这次事故让我深刻地意识到:OOM的种类很多,快速判断OOM类型、找到正确的分析工具,是解决问题的关键。
一、8种OOM类型全景
JVM的OOM错误消息各不相同,每种消息对应不同的原因和解决方向:
类型1:Java heap space
java.lang.OutOfMemoryError: Java heap space最常见的OOM类型。堆内存不足,无法为新对象分配空间。原因分两类:
- 内存泄漏:对象不断创建但无法被GC,最终堆被耗尽。这是需要重点排查的情况。
- 堆空间确实不够:业务增长,数据量超出了设计预期,需要扩容。
快速判断方法:看GC日志,如果Full GC频繁而且每次回收后堆使用率依然很高(>80%),很可能是内存泄漏;如果堆使用率在合理范围内(<70%),只是偶发高峰导致,考虑扩堆。
类型2:GC overhead limit exceeded
java.lang.OutOfMemoryError: GC overhead limit exceededGC时间占比过高的保护机制。当JVM用超过98%的时间做GC,但只回收了不到2%的堆空间,触发此OOM。
本质上是堆内存严重不足,GC在无望地挣扎。根本原因与Java heap space类似,通常是内存泄漏或堆太小。
可以通过-XX:-UseGCOverheadLimit禁用此保护(不推荐),通常最终还是会变成Java heap space的OOM。
类型3:Direct buffer memory
java.lang.OutOfMemoryError: Direct buffer memory直接内存(堆外内存)不足。常见于Netty、NIO应用。
触发原因:
- 直接内存上限设置太低(
-XX:MaxDirectMemorySize) - DirectByteBuffer对象堆积,Cleaner得不到执行
- Netty的ByteBuf没有正确release
类型4:unable to create new native thread
java.lang.OutOfMemoryError: unable to create new native thread无法创建新的操作系统线程。原因不是堆内存不足,而是:
- 进程的线程数达到操作系统限制(
/proc/sys/kernel/threads-max,Linux默认约3万) - 进程的文件描述符数量达到限制(线程需要文件描述符)
- 虚拟内存不足以分配线程栈(每个线程栈默认512KB-1MB)
- 代码中线程泄漏(线程创建了但没有终止)
类型5:Metaspace
java.lang.OutOfMemoryError: MetaspaceMetaspace(JDK 8+的方法区实现)耗尽。常见原因:
- 动态代理、CGLIB、Groovy脚本引擎等在运行时生成大量类
- ClassLoader泄漏(自定义ClassLoader没有被GC回收)
- 没有设置
-XX:MaxMetaspaceSize,让Metaspace无限增长后耗尽系统内存
类型6:request {} byte for {} out of swap space
java.lang.OutOfMemoryError: request 1073741824 bytes for ... out of swap space?操作系统层面的内存分配失败。JVM进程消耗的内存(堆+非堆+直接内存+JVM自身)超过了系统可用内存+Swap。
这种情况通常需要从OS层面调查,不只是JVM参数问题。
类型7:Compressed class space
java.lang.OutOfMemoryError: Compressed class space压缩类空间(-XX:+UseCompressedClassPointers开启时生效)不足。这是Metaspace的子区域,专门存储类元数据中的Klass对象。默认最大1G,可以通过-XX:CompressedClassSpaceSize调整。
类型8:reason stack_trace_with_native_method
java.lang.OutOfMemoryError: stack_trace_with_native_methodJNI本地方法的内存分配失败。这种情况比较罕见,通常出现在大量使用JNI的系统中。需要排查本地代码的内存管理。
二、原理深度解析
2.1 OOM快速判断决策树
2.2 MAT(Memory Analyzer Tool)核心功能
MAT是分析Heap Dump的首选工具,核心功能包括:
Dominator Tree(支配树):找出哪些对象"支配"了最多内存。一个对象A支配对象B,意味着GC要回收B必须先回收A。支配树能快速找出持有最多内存的对象路径。
Leak Suspects(泄漏嫌疑报告):MAT自动分析,给出最可疑的内存泄漏点,通常直接指向问题。
Histogram(对象直方图):按类型统计对象数量和内存占用,快速识别异常多的对象类型。
Retained Heap vs Shallow Heap:
- Shallow Heap:对象本身占用的内存(不含它引用的对象)
- Retained Heap:如果这个对象被GC回收,总共能释放多少内存(包含它引用的所有对象)
Retained Heap大的对象才是真正的内存消耗大户。
2.3 引用链分析
找到可疑对象后,需要分析它的引用链(GC Roots Path),理解为什么它没有被GC回收:
MAT操作步骤:
1. 打开Histogram,找到数量或内存异常的类
2. 右键 → List Objects → with incoming references(谁引用了它)
3. 右键最顶层对象 → Path to GC Roots → exclude weak/soft references
4. 分析GC Root链,找出真正持有引用的根对象三、诊断工具与命令
3.1 生成Heap Dump
# 方式1:OOM时自动生成(推荐在生产配置)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heapdump.hprof
# 方式2:手动触发(应用还在运行时)
jmap -dump:format=b,file=/tmp/heap.hprof <pid>
# 注意:会触发Full GC,有短暂停顿
# 方式3:只dump存活对象(文件更小,分析更准确)
jmap -dump:live,format=b,file=/tmp/heap_live.hprof <pid>
# 方式4:通过jcmd(JDK 9+推荐)
jcmd <pid> GC.heap_dump /tmp/heap.hprof3.2 快速分析(不用MAT)
# 查看对象直方图(不需要dump文件)
jmap -histo <pid> | head -30
jmap -histo:live <pid> | head -30 # 只统计存活对象
# 输出示例:
# num #instances #bytes class name
# -----------------------------------------------
# 1: 123456 987654321 [B (byte数组)
# 2: 56789 456789012 java.lang.String
# 3: 34567 345678901 [C (char数组)
# 如果某个类的实例数异常高,就是重点排查对象
# 查看ClassLoader情况
jcmd <pid> GC.class_stats 2>/dev/null | sort -k3 -rn | head -203.3 MAT分析命令行模式
# MAT提供命令行版本(mat.sh),适合服务器分析
./mat.sh -application org.eclipse.mat.api.parse \
/tmp/heap.hprof \
org.eclipse.mat.api:suspects \
org.eclipse.mat.api:overview \
org.eclipse.mat.api:top_components
# 分析Metaspace OOM
jcmd <pid> VM.class_hierarchy | wc -l # 查看总类数量
jcmd <pid> GC.class_stats | awk '{sum += $2} END {print sum, "classes loaded"}'3.4 直接内存OOM分析
# 开启NMT追踪直接内存
# 启动参数加:
-XX:NativeMemoryTracking=detail
# 查看直接内存使用
jcmd <pid> VM.native_memory detail scale=MB | grep -A5 "Internal"
# 查看DirectByteBuffer数量
jmap -histo:live <pid> | grep DirectByteBuffer
# Netty直接内存泄漏检测
# 启动参数加:
-Dio.netty.leakDetection.level=paranoid # 全量检测(有性能开销,调试用)
-Dio.netty.leakDetection.level=simple # 抽样检测(推荐生产使用)3.5 线程OOM分析
# 查看当前线程数
jstack <pid> | grep -c "java.lang.Thread"
# 查看线程状态分布
jstack <pid> | grep "java.lang.Thread.State" | sort | uniq -c | sort -rn
# 查看系统线程限制
cat /proc/sys/kernel/threads-max
cat /proc/<pid>/limits | grep "Max processes"
cat /proc/<pid>/status | grep Threads
# 列出所有线程名称(找规律性的线程泄漏)
jstack <pid> | grep '"' | awk -F'"' '{print $2}' | sort | uniq -c | sort -rn | head -20四、完整调优方案
4.1 生产必备的OOM预防配置
# OOM时自动dump(关键!)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
# 建议HeapDumpPath指向一个有足够空间的目录(堆多大文件就多大)
# OOM时执行脚本(可以用来自动重启或告警)
-XX:OnOutOfMemoryError="kill -9 %p; sh /scripts/restart.sh"
# 直接内存上限保护
-XX:MaxDirectMemorySize=2g
# Metaspace上限保护
-XX:MaxMetaspaceSize=512m
# GC overhead保护(默认开启)
# 可以通过如下关闭(不推荐)
# -XX:-UseGCOverheadLimit4.2 各类OOM的针对性解决方案
Java heap space + 内存泄漏:
# 1. 生成Heap Dump
# 2. MAT分析Dominator Tree和Leak Suspects
# 3. 找到引用链,定位代码中未关闭/未清理的资源
# 4. 典型案例:静态集合、监听器未移除、ThreadLocal未remove、缓存无上限Metaspace OOM:
# 检查是否有ClassLoader泄漏
jmap -histo:live <pid> | grep ClassLoader
# 检查CGLIB/动态代理生成的类数量
jmap -histo:live <pid> | grep "\$\$" | head -20
# $$标识的通常是动态生成的类
# 解决方案:
# 1. 设置-XX:MaxMetaspaceSize防止无限增长
# 2. 检查自定义ClassLoader是否正确实现了equals/hashCode
# 3. 减少不必要的动态代理,缓存并复用代理对象unable to create native thread:
# 增加系统线程限制
echo "* soft nproc 65535" >> /etc/security/limits.conf
echo "* hard nproc 65535" >> /etc/security/limits.conf
# 减小线程栈大小
-Xss256k # 从512K降到256K,允许创建更多线程
# 代码层面:使用线程池而不是无限制创建线程
# 使用虚拟线程(JDK 21+)替代平台线程五、踩坑实录
坑一:Heap Dump文件太大,MAT打不开
dump文件有12GB,本地机器内存只有16GB,MAT打开时直接OOM崩溃。
解决方案:在服务器上用MAT的命令行版本分析,或者在MAT的MemoryAnalyzer.ini中把-Xmx设置为dump文件大小的1.5倍。也可以先用jmap -histo:live做初步筛查,锁定可疑类,再分析。
坑二:dump下来发现问题对象已经消失
内存泄漏时,有时候触发了手动dump(不是OOM自动dump),GC恰好执行了一次Full GC,把"泄漏"的对象回收了,dump文件里看不到任何异常。
解决方案:一定要配置-XX:+HeapDumpOnOutOfMemoryError,让OOM触发时自动dump,此时堆的状态是最"真实"的问题状态。另外,可以用jmap -dump:live和jmap -dump(不加live)的文件大小对比,判断垃圾对象的比例。
坑三:Metaspace OOM但类数量看起来正常
排查一个Metaspace OOM问题,用jmap看到的类数量是8万个,感觉不算太多。但MAT里看到ClassLoader的Retained Heap异常大,每个ClassLoader持有大量的类元数据。
原因:自定义ClassLoader每次请求都创建一个新实例,加载同样的类。虽然类的总实例数不多,但ClassLoader实例数达到了几千个,每个ClassLoader都各自持有一份类元数据,总占用达到了几GB。
解决方案:ClassLoader必须复用,用单例或缓存管理ClassLoader实例。
坑四:线程OOM但线程数看起来不多
系统报unable to create new native thread,但jstack显示当前只有300个线程,远低于系统限制(32768)。
排查发现:-Xss被设置成了1MB(在一个旧的脚本里),300个线程就占用了300MB的栈内存,加上堆8G、Metaspace等,进程总内存超过了容器的内存限制(8G),被cgroup限制了新的内存分配,导致thread创建失败。
解决方案:容器化部署时,JVM总内存(堆+Metaspace+直接内存+栈+JVM自身)必须低于容器内存限制,留出一定余量。
六、总结
OOM排查的核心是"快速判断类型,找对分析工具,定位引用链"。
8种OOM类型中,Java heap space最常见,原因是内存泄漏或堆太小。遇到它先看GC日志,判断是泄漏还是容量问题。是泄漏就dump分析;是容量问题就扩堆。
MAT是分析Java heap space的主力工具,Dominator Tree和Leak Suspects能快速定位问题。学会看Retained Heap,找到真正持有大量内存的支配对象。
Metaspace类型关注ClassLoader泄漏和动态类生成;unable to create native thread关注线程泄漏和系统限制;Direct buffer memory关注ByteBuf的release和MaxDirectMemorySize配置。
生产环境必须配置-XX:+HeapDumpOnOutOfMemoryError,在问题发生时自动捕获现场,否则等到你想分析时,现场可能已经消失了。
