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对象),元空间被填满。
常见场景:
| 场景 | 说明 | 示例 |
|---|---|---|
| 动态代理 | 每次创建代理类都生成新的Class | CGLib/ASM动态生成 |
| Groovy脚本引擎 | 每个脚本编译成一个新类 | GroovyShell反复执行 |
| 热部署 | 每次re部署生成新的ClassLoader | Tomcat reload多次后触发 |
| JSP/JSTL | JSP编译为Servlet类 | JSP动态生成 |
诊断:
# 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 启动参数修复:
# 预防方案: 设置上限,防止内存被耗尽
-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的一种方式诊断:
# 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修复:
# 设置明确上限
-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(实际是),
实际可创建线程数会更少。诊断:
# 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缓存(最经典)
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字段可达的对象都不会被回收。
修复: 使用WeakHashMap或Guava Cache(带最大容量+过期时间)。
4.2 ThreadLocal未清理(线程池场景)
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 监听器/回调未注销
public class EventBusUser {
public EventBusUser(EventBus bus) {
// ❌ 错误: 注册了但never unregister
bus.register(this); // ← EventBus持有this引用
}
}为什么泄漏: EventBus内部持有注册对象的强引用,对象无法被回收。
修复: 对象销毁时调用bus.unregister(this)。
4.4 流/连接未关闭
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 内部类持有外部类引用
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 | 生成堆dump | MAT分析源 |
jcmd VM.native_memory | Native内存 | 排查DirectBuffer |
MAT | 堆dump分析 | Leak Suspects |
jhat (已废弃) | 简单堆分析 | 替代: MAT/Eclipse |
-XX:+TraceClassLoading | 类加载追踪 | Metaspace问题 |
六、OOM预防体系
最务实的最低要求:
# 每个Java应用必须加的参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/有了这两个参数,OOM发生时自动dump堆,重启后堆dump文件等着你分析。
更完善的方案:
-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问题