OOM 类型全景:四种溢出,四种排查路径
一句话结论(30s)
OOM 排查的本质是先按错误信息定位溢出区域,再按区域走对应的排查路径——因为堆、元空间、栈、直接内存这四条路径的根因和工具完全不同。核心方法论是:堆 OOM 用 MAT 支配树找保留内存最大的泄漏源,Metaspace OOM 查 ClassLoader 动态生成类,栈溢出看 jstack 里的递归栈帧,直接内存 OOM 看 NIO 缓冲池。权衡是每种 OOM 都要对症下药,用错工具(比如拿堆的工具查直接内存)只会南辕北辙。
核心原理(2min)
四种溢出路径及对应排查:① Java heap space 由大对象或长生命周期对象持有无用引用造成,用 -XX:+HeapDumpOnOutOfMemoryError 抓 dump,MAT 的 Leak Suspects / 支配树定位保留内存最大的对象及其 GC Root 引用链;② Metaspace 由 CGLIB/Groovy/JSP 无限生成类、ClassLoader 未回收造成,用 jstat -gc 看 M 列趋势、jcmd VM.classloader_stats 看每个 ClassLoader 加载类数;③ StackOverflowError 由无限递归或死循环调用链造成,jstack 里同一方法反复出现几百上千栈帧即递归信号;④ Direct buffer memory 由 NIO allocateDirect 未释放且堆内存充足导致 GC 不触发造成,用 jcmd VM.native_memory summary 和 jconsole 的 BufferPool 观察,修复靠显式 cleaner().clean() 或 -XX:MaxDirectMemorySize 设上限。
底层深入(5-10min)
1. Java heap space(堆 OOM)
根因:对象太多(大对象)或内存泄漏(长生命周期对象持有无用引用)。
排查:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof
→ jmap -dump:live → MAT 打开 → Leak Suspects 或 Dominator Tree
→ 找保留内存最多的对象 → 看它的 GC Root 引用链
MAT 支配树:一个对象”支配”了它不可达后也必然不可达的所有对象。“保留大小”最大的那个就是值得怀疑的泄漏源。
想一想:为什么”支配树的保留大小”能定位泄漏源?因为一个对象支配的对象 = 它死掉后也跟着死掉的对象,保留大小最大的那个,就是”拖着最多内存一起活”的根,顺藤摸瓜看它的 GC Root 引用链即可找到泄漏点。
2. Metaspace(元空间 OOM)
根因:动态载入大量类——CGLIB 无限生成代理类、Groovy 脚本反复编译、JSP 无限加载。ClassLoader 未回收导致其下所有类元数据永久占用。
想一想:为什么 CGLIB/JSP 会指向 Metaspace OOM 而不是堆 OOM?因为每生成一个类都会在元空间占一份类元数据(方法字节码、常量池等),而 ClassLoader 不回收时,它下面的所有类元数据就永远占着 Metaspace——和堆里的对象泄漏是两条完全不同的路径。
排查:
jstat -gc <pid> # 看 M 列 (Metaspace) 增长趋势
jcmd <pid> VM.classloader_stats # 看哪个 ClassLoader 加载了多少类
3. StackOverflowError(栈溢出)
根因:递归过深(无终止条件或终止条件永远达不到)、无限循环调用链。
排查:jstack 看到同一个方法反复出现几百上千次栈帧——这是明显的递归信号。
4. Direct buffer memory(直接内存 OOM)
根因:NIO ByteBuffer.allocateDirect() 分配堆外内存未释放,且 GC 迟迟不触发(堆内存充足 → 堆外一直不回收)。
想一想:为什么堆外内存泄漏时,GC 不来”救场”?因为直接内存不在堆里,GC 只在堆紧张时才顺带触发 DirectByteBuffer 的 cleaner 回收堆外;堆内存充足时 GC 根本不触发,堆外就一直堆着不释放。
排查:
jcmd <pid> VM.native_memory summary # 看 Native Memory 的 Internal 区域
jconsole → MBeans → java.nio → BufferPool → direct → MemoryUsed
修复:显式 ((DirectBuffer) buf).cleaner().clean() 手动释放;或 -XX:MaxDirectMemorySize=512m 设置上限防止无限增长。
四种 OOM 速查
| 错误信息 | 区域 | 常见原因 |
|---|---|---|
| Java heap space | 堆 | 大对象/集合泄漏 |
| Metaspace | 元空间 | CGLIB/JSP 无限生成类 |
| StackOverflowError | 栈 | 无限递归 |
| Direct buffer memory | 直接内存 | NIO 堆外内存未释放 |
章末提问
追问 1:遇到 Java heap space 你第一步做什么?
回答思路:结论先行——先开 -XX:+HeapDumpOnOutOfMemoryError 抓 dump,再用 MAT 的支配树找”保留大小”最大的对象。因为堆 OOM 的根因要么是大对象、要么是长生命周期对象持有无用引用,支配树能定位”拖住最多内存”的对象及其 GC Root 引用链,比肉眼翻代码快得多。
追问 2:怎么区分是 Metaspace 泄漏还是堆泄漏?
回答思路:结论先行——看错误信息加工具维度:Metaspace 报 Metaspace 错误,用 jstat -gc 看 M 列增长趋势、jcmd VM.classloader_stats 看 ClassLoader 加载类数。因为元空间 OOM 的根因是 CGLIB/Groovy/JSP 无限生成类、ClassLoader 不回收,和堆里对象泄漏是两套工具;拿堆的工具(jmap)查元空间就是南辕北辙。
追问 3:直接内存 OOM 为什么堆内存充足也会发生?怎么修?
回答思路:结论先行——因为堆外内存不被 GC 主动回收,堆充足时 GC 不触发、DirectByteBuffer 的 cleaner 就一直不执行。因为 allocateDirect 分配的内存不在堆里,GC 只看堆压力;修复要么显式 ((DirectBuffer) buf).cleaner().clean() 手动释放,要么 -XX:MaxDirectMemorySize 设上限防止无限增长,排查用 jcmd VM.native_memory summary 和 jconsole 的 BufferPool。