Skip to content

06.4 - OOM排查方法论

定位: 从错误信息到根因定位的完整方法论——OOM不可怕,可怕的是不知道哪里OOM面试高频度: ⭐⭐⭐⭐⭐

一、什么是OOM?为什么它是个"系统性"问题?

OutOfMemoryError = JVM无法分配更多内存。但"为什么无法分配"才是问题的核心。

OOM不是故障,OOM是症状。 就像发烧是症状,病因可能是感冒、肺炎或癌症。

OOM = 现象
     ├── 如何确认类型?→ 读错误信息(它精确告诉你是哪块内存不够)
     ├── 为什么不够?  → 泄漏(GC回收不了)or 容量(确实需要这么大)
     └── 怎么修?     → 修泄漏 or 加容量

二、五大OOM类型详解

2.1 Java heap space — 堆空间不足

症状: java.lang.OutOfMemoryError: Java heap space

两种原因:

类型特征诊断方式
内存泄漏堆用量持续上升,重启才恢复多次jstat观测OU趋势
堆太小正常使用就触顶,压测更明显看对象总量是否合理

诊断流程:

┌─────────────────────────────────────────────────────────────────────┐
│                  Java heap space 诊断流程                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  Step 1: 确认是heap space                                           │
│  日志: "Java heap space" ✅ 不是Metaspace,不是Direct               │
│                                                                     │
│  Step 2: 获取堆dump                                                 │
│  方案A(推荐): 启动参数加 -XX:+HeapDumpOnOutOfMemoryError           │
│                 -XX:HeapDumpPath=/data/dump/                        │
│  方案B(生产慎用): jmap -dump:format=b,file=heap.hprof <pid>       │
│                                                                     │
│  Step 3: 分析堆dump (用MAT)                                         │
│  ① Histogram → 按size排序 → 看哪个对象实例数异常                    │
│  ② Leak Suspects → 自动分析泄漏链                                   │
│  ③ Dominator Tree → 最大的对象是什么                                │
│  ④ Path to GC Roots → 谁阻止了GC回收                                │
│                                                                     │
│  Step 4: 定位根因                                                   │
│  找到GC Root链,确定代码位置                                        │
│                                                                     │
│  Step 5: 修复验证                                                   │
│  修代码 / 加堆大小 → 压测验证 → 监控确认                            │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

MAT(Eclipse Memory Analyzer)关键操作:

1. 打开heap dump (.hprof)
2. "Leak Suspects Report" → 自动给出嫌疑对象和GC Root链
3. "Histogram" → 类维度排序,看哪个类的实例占用最大
4. 右键类名 → "Merge Shortest Paths to GC Roots" → exclude all phantom/weak/soft references
   → 看到这个类的对象被谁引用着
5. 根据引用链定位到业务代码

MAT的一个核心概念:Dominator Tree(支配树):

                                 Root

                            ┌─────┴─────┐
                            │           │
                        Object A    Object B

                      ┌─────┴─────┐
                      │           │
                  String[]     HashMap
                    (支配者)     (被支配)

支配树告诉你: "把A回收了,B也会被回收" → 找到支配树顶端的对象,
就是占用内存最大的"根对象"。

2.2 GC overhead limit exceeded — GC接近失效

症状: java.lang.OutOfMemoryError: GC overhead limit exceeded

含义: GC正在消耗 >98%的CPU时间,但回收了 <2%的堆空间。

诊断: 这不单独存在——它是Heap Space OOM的前奏。JVM发现GC没有用,主动抛出OOM。

诊断要点:

现象: CPU飙升 + GC频繁
jstat -gcutil <pid> 1000
  E    O    YGC    YGCT    FGC    FGCT
 99%  99%  10000  200.0    500   1500.0     ← FGC/GCT 极高

触发条件: 堆使用率接近100%,每次GC都几乎回收不了对象

解决方案: 要么重启(临时),要么解决堆泄漏(根治)。这个错误本质上和Java heap space是同一个问题,只是诊断阈值提前了。

2.3 Metaspace — 元空间溢出

症状: java.lang.OutOfMemoryError: Metaspace

根因: 加载了太多类(Class对象),元空间被填满。

常见场景:

场景说明示例
动态代理每次创建代理类都生成新的ClassCGLib/ASM动态生成
Groovy脚本引擎每个脚本编译成一个新类GroovyShell反复执行
热部署每次re部署生成新的ClassLoaderTomcat reload多次后触发
JSP/JSTLJSP编译为Servlet类JSP动态生成

诊断:

bash
# Step 1: 确认Metaspace OOM
jstat -gc <pid>
# MC    MU    ... FGC
# 256M  254M  ... 200     ← MU接近MC,说明元空间几乎满

# Step 2: 查看类加载统计
jstat -class <pid>
# Loaded  Bytes  Unloaded  Bytes  Time
# 50000   120MB  0         0      15.0

# Step 3: 打印所有已加载的类(分析哪些类异常多)
# 使用 -XX:+TraceClassLoading 启动参数

修复:

bash
# 预防方案: 设置上限,防止内存被耗尽
-XX:MaxMetaspaceSize=256m

# 对于热部署问题,检查是否ClassLoader泄漏
# 确认: 是否每次重新部署后Loaded类数在增加

2.4 Direct buffer memory — 直接内存溢出

症状: java.lang.OutOfMemoryError: Direct buffer memory

特点: ⚠️ 堆看起来正常! 堆使用率和GC都正常,但进程内存飙升。

根因: DirectByteBuffer分配了堆外内存,但没有及时回收。DirectByteBuffer本身很小(只有几十字节的"壳"),但指向的堆外内存可能很大(几百MB)。

回收机制的问题:

DirectByteBuffer回收链:
  DirectByteBuffer对象(堆内,几十字节) → Cleaner → Reference Handler线程
  → 执行Cleaner.clean() → 释放堆外内存

问题: 当堆内还有很多DirectByteBuffer对象未被GC回收,
      Reference Handler就不会执行clean()
      → 堆外内存一直被占用 → Direct buffer OOM

陷阱: -XX:+DisableExplicitGC 会阻止System.gc(),
      而System.gc()是触发Reference Handler的一种方式

诊断:

bash
# Step 1: 确认堆使用正常
jstat -gcutil <pid>
# 看到E和O都在合理范围内,排除堆OOM

# Step 2: 查看DirectBuffer使用量
jcmd <pid> VM.native_memory summary
# 或
pmap <pid> | sort -k2 | tail  # Linux查看进程内存映射

# Step 3: 检查DirectBuffer数量
jmap -histo <pid> | grep DirectByteBuffer

修复:

bash
# 设置明确上限
-XX:MaxDirectMemorySize=512m

# Netty应用中特别容易触发,检查是否调用了
# ((DirectBuffer) byteBuf).free() 释放堆外内存

2.5 unable to create new native thread — 线程数达到上限

症状: java.lang.OutOfMemoryError: unable to create new native thread

关键认知: ⚠️ 这不是内存问题!这是OS线程数限制问题。

原因: 当线程数达到OS限制时,即使堆内存充足,也无法创建新线程。

计算公式:

线程数 = (进程可用内存 - Xmx - MaxMetaspaceSize - 直接内存) / Xss

举例: 进程内存 4G, Xmx 3G, 其他 256MB, Xss 1MB
      可用线程数 = (4096 - 3072 - 256) / 1 = 约768

如果通过ulimit限制为1024,看起来够用。
但如果每个线程占用的native memory > Xss(实际是),
实际可创建线程数会更少。

诊断:

bash
# Step 1: 检查当前线程数
jstack <pid> | grep "java.lang.Thread.State" | wc -l

# Step 2: 检查OS限制
ulimit -u   # max user processes

# Step 3: 检查进程线程数
cat /proc/<pid>/status | grep Threads

# Step 4: 确认是不是线程泄漏
# 连续检查几次,看线程数是否持续增长

常见原因:

原因特征解决
线程池未限制每个请求创建新线程使用有界线程池
线程池配置过大core/max线程数设得太大根据CPU合理计算
线程泄漏线程执行完不归还排查线程池shutdown逻辑
Tomcat连接器线程过多maxThreads配置过高调整为合理值

三、OOM类型快速鉴别

收到OOM告警


读错误信息!

      ├── "Java heap space"
      │       → 堆内存泄漏或堆太小
      │       → MAT分析堆dump

      ├── "GC overhead limit exceeded"
      │       → GC已无法回收,是Java heap space的前兆
      │       → 同上,分析堆dump

      ├── "Metaspace"
      │       → 类加载过多
      │       → jstat -class 看加载数,TraceClassLoading

      ├── "Direct buffer memory"
      │       → NIO堆外内存泄漏
      │       → jcmd VM.native_memory,检查Netty使用

      ├── "unable to create new native thread"
      │       → OS线程数到顶
      │       → ulimit -u + jstack 统计线程数
      │       → 不是内存问题!

      └── "Requested array size exceeds VM limit"
          → 试图创建过大的数组(通常代码bug)
          → 检查异常附近的new数组代码

四、常见内存泄漏模式(代码级)

4.1 静态HashMap缓存(最经典)

java
public class CacheManager {
    // ❌ 错误: 没有清理机制
    private static Map<String, Object> cache = new HashMap<>();

    public void put(String key, Object value) {
        cache.put(key, value);  // == GC Root可达,永远不被回收
    }
}

为什么泄漏: static字段属于GC Root。只要类加载器存活,所有通过static字段可达的对象都不会被回收。

修复: 使用WeakHashMapGuava Cache(带最大容量+过期时间)。

4.2 ThreadLocal未清理(线程池场景)

java
public class RequestContext {
    // ❌ 错误: 没有在finally中remove
    private static ThreadLocal<UserInfo> currentUser = new ThreadLocal<>();

    public void process(Request request) {
        currentUser.set(request.getUserInfo());
        // 执行业务逻辑...
        // currentUser.remove();  ← 忘记清理!
    }
}

为什么泄漏: 线程池中的线程不会被销毁,ThreadLocal的value在线程的ThreadLocalMap中,永远不会被清理。

修复: 在finally块中调用ThreadLocal.remove()

4.3 监听器/回调未注销

java
public class EventBusUser {
    public EventBusUser(EventBus bus) {
        // ❌ 错误: 注册了但never unregister
        bus.register(this);  // ← EventBus持有this引用
    }
}

为什么泄漏: EventBus内部持有注册对象的强引用,对象无法被回收。

修复: 对象销毁时调用bus.unregister(this)

4.4 流/连接未关闭

java
public void readFile() {
    // ❌ 错误: 没有在finally中close
    FileInputStream fis = new FileInputStream("/tmp/data");
    // 读文件...
    // fis.close(); ← 如果中间抛异常,永远不会close
}

为什么泄漏: FileInputStream持有系统级文件描述符(native memory),close才会释放。

修复: 使用try-with-resources(Java 7+)。

4.5 内部类持有外部类引用

java
public class Outer {
    private byte[] bigData = new byte[1024 * 1024 * 100]; // 100MB

    class Inner {
        // 隐式持有Outer.this引用
        void doSomething() {
            System.out.println("working");
        }
    }

    public Inner getInner() {
        return new Inner();
    }
}

为什么泄漏: new Outer().getInner()创建了一个内部类实例,它持有外层大对象Outer.this的引用。即使Outer不再使用,只要Inner还被引用,Outer.this就不会被GC。

修复: 使用静态内部类或在必要时清空引用。

五、OOM排查工具总结

工具用途关键操作
jstat -gc各代使用率和GC次数看OU趋势
jstat -gcutil百分比查看快速判断
jmap -histo类实例统计找异常数量类
jmap -dump生成堆dumpMAT分析源
jcmd VM.native_memoryNative内存排查DirectBuffer
MAT堆dump分析Leak Suspects
jhat (已废弃)简单堆分析替代: MAT/Eclipse
-XX:+TraceClassLoading类加载追踪Metaspace问题

六、OOM预防体系

最务实的最低要求:

bash
# 每个Java应用必须加的参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/

有了这两个参数,OOM发生时自动dump堆,重启后堆dump文件等着你分析。

更完善的方案:

bash
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump/
-XX:+ExitOnOutOfMemoryError         # OOM时退出JVM(让守护进程重启)
-XX:MaxMetaspaceSize=256m           # 防止Metaspace无限制泄漏
-XX:+DisableExplicitGC              # 防止System.gc()触发Full GC
-XX:+PrintGCDetails -Xloggc:/data/gc.log

七、面试视角

高频面试题

面试题考察点回答要点
"OOM有哪些类型?"全面认知5种:heap space、GC overhead、Metaspace、Direct buffer、native thread
"如何排查堆内存OOM?"方法论-XX:+HeapDumpOnOOM → MAT分析 → Leak Suspects → Dominator Tree
"Metaspace OOM怎么排查?"类加载理解jstat -class看加载数,-XX:+TraceClassLoading追踪,检查动态代理/Groovy
"Direct buffer OOM为什么难排查?"Native内存理解堆看起来正常!需要jcmd VM.native_memory或pmap查看进程内存
"ThreadLocal为什么会导致OOM?"引用理解线程池不销毁线程,ThreadLocalMap的value一直在
"禁用System.gc()会有什么影响?"GC理解DirectByteBuffer的回收被阻塞,可能需要手动清理或增大MaxDirectMemorySize
"你遇到过什么OOM?怎么解决的?"实战经验描述具体场景 + 分析过程 + 根因 + 方案

面试回答模板

Q: 线上Java应用OOM了,你怎么排查? A: 第一步:看错误日志确定OOM类型。如果是heap space,检查是否设置了-XX:+HeapDumpOnOutOfMemoryError——如果没加,这是第一个要改的。第二步:用MAT打开堆dump,先看Leak Suspects Report自动生成的嫌疑对象。第三步:看Dominator Tree找到最大的对象。第四步:通过Path to GC Roots找到GC Root链,确定是哪个代码位置阻止了回收。我最常见的模式是静态HashMap作为缓存没有清理机制。

Q: Metaspace OOM和Java heap OOM的区别? A: 本质区别在于溢出区域。Java heap OOM是对象过多,需要分析对象引用链。Metaspace OOM是过多——每次加载类都会在Metaspace创建Class元数据。Metaspace OOM的典型案例是Groovy脚本动态编译:每个脚本生成一个类,如果脚本数量无限制,Metaspace就会涨满。


📚 相关链接

  • ← 返回 **调优索引**
  • **Java堆** - 堆内存结构
  • **方法区与元空间** - 元空间原理
  • **直接内存** - 直接内存详解
  • **常用工具(jps与jstat与jmap与jstack与jinfo)** - 工具基础
  • **CPU飙升排查方法论** - CPU排查(GC thrashing场景)
  • **FullGC频繁排查方法论** - Full GC问题

Knowledge4J — Java 知识库