OOM 引起原因以及如何排查?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书
《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于
Spring AI + Spring Boot 3.x + JDK 21...,查看介绍《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...,查看介绍;演示链接:http://116.62.199.48:7070/《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/
新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍
截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
原理理解:面试官不仅仅是想知道 OOM 是什么,更是想看你是否清楚 OOM 会发生在 JVM 的哪些区域,以及每个区域 OOM 的触发条件。这块能答清楚,说明你对 JVM 内存模型有真理解。
-
排查能力:这是重中之重。面试官想知道你遇到 OOM 之后有没有一套系统的排查方法论,而不是上来就瞎猜。会不会看日志、会不会用
jmap、会不会分析堆转储文件,这些才是区分 "背过八股文" 和 "真正处理过线上问题" 的分水岭。 -
预防意识:回答中能不能主动提到预防措施(比如加监控、配参数、代码 Review),体现了你是不是一个有生产意识的工程师。
核心答案
OOM 根据发生的内存区域不同,主要有以下几种类型:
| OOM 类型 | 错误信息 | 根本原因 | 高发场景 |
|---|---|---|---|
| 堆溢出 | Java heap space |
堆内存不足,对象太多回收不了 | 内存泄漏、大对象、集合只增不减 |
| 栈溢出 | StackOverflowError |
栈深度超限 | 递归调用过深、方法循环调用 |
| 方法区溢出 | Metaspace / PermGen space |
类加载过多 | 动态代理、CGLIB、JSP 热部署 |
| 直接内存溢出 | Direct buffer memory |
堆外内存不足 | NIO 的 DirectByteBuffer 使用不当 |
| GC 开销超限 | GC overhead limit exceeded |
GC 回收效率太低 | 堆几乎满了,98% 以上时间在 GC |
排查 OOM 的核心思路:保留现场 → 定位区域 → 抓取快照 → 分析根因 → 修复验证。
深度解析
一、OOM 的常见原因
1. 堆溢出——最常见
堆溢出占了线上 OOM 的 80% 以上,主要有两种情况:
- 内存泄漏:对象已经不用了,但 GC 无法回收。比如
static集合一直往里塞数据但从来不清理,或者内部类持有外部类引用导致外部类无法回收。 - 内存溢出:确实需要这么多内存,但堆给的不够。比如一次性加载了一个 500MB 的文件到内存。
// 典型内存泄漏:静态集合只增不减
public class OomDemo {
// static 集合生命周期跟类一样长,GC 永远回收不了里面的对象
private static final List<Object> CACHE = new ArrayList<>();
public void addToCache(Object obj) {
CACHE.add(obj); // 一直往里加,从不移除 → 最终堆爆掉
}
}
// 典型一次性加载大对象
public void loadBigFile() throws IOException {
// 一次性把整个文件读进内存,文件一大就 OOM
byte[] data = Files.readAllBytes(Paths.get("huge_file.dat"));
}
上面两段代码展示了堆溢出的两种典型场景。第一段是内存泄漏,static 集合持有对象引用导致 GC 无法回收;第二段是内存溢出,虽然对象用完就可以回收,但瞬时内存需求超过了堆大小。
2. 栈溢出
栈溢出相对好定位,看异常堆栈就能一眼看到递归调用链。
// 经典递归无终止条件 → StackOverflowError
public int fibonacci(int n) {
return fibonacci(n - 1) + fibonacci(n - 2); // 没有 n <= 1 的终止条件
}
3. 方法区(元空间)溢出
这块很多人容易忽略。Spring、MyBatis 这类大量使用动态代理的框架,运行时会生成大量类,如果元空间没限制好大小,就可能溢出。
// CGLIB 动态代理疯狂生成类 → Metaspace OOM
public class MetaspaceOomDemo {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MetaspaceOomDemo.class);
enhancer.setUseCache(false);
enhancer.setCallback((MethodInterceptor) (obj, method, args1, proxy) ->
proxy.invokeSuper(obj, args1));
enhancer.create(); // 每次创建都生成一个新类
}
}
}
4. 直接内存溢出
NIO 使用 DirectByteBuffer 分配堆外内存,不受 -Xmx 限制,但受 -XX:MaxDirectMemorySize 和物理内存限制。这块溢出时错误信息可能不太明确,排查起来相对棘手。
二、OOM 排查完整流程
这才是面试官最想听的部分,也是拉开差距的地方。
上图展示了 OOM 排查的完整 5 步流程,下面展开每一步的关键操作。
第一步:保留现场
这是最关键也最容易被忽视的一步。等 OOM 发生了再去想怎么抓现场,就来不及了。必须在 JVM 启动参数里提前配好:
# 必配参数:OOM 时自动 dump 堆内存
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
# 建议同时配 GC 日志
-Xlog:gc*:file=/data/logs/gc.log:time,uptime,level,tags
我就吃过这个亏,有次线上 OOM,没配自动 dump,只能干瞪眼重启,重启后又好了,根因找不到。所以这个参数 每个线上应用都必须配上,没商量。
第二步:确认 OOM 类型
看日志里的错误信息,快速判断是哪个区域出了问题,缩小排查范围。具体的类型对应关系上面流程图里已经列了。
第三步:命令行工具快速诊断
# 1. 找到 Java 进程
jps -l
# 2. 查看 GC 情况,观察各代内存变化
jstat -gcutil <pid> 1000 10 # 每秒输出一次,共输出 10 次
# 3. 查看堆中对象统计(按占用空间排序)
jmap -histo <pid> | head -20
# 4. 手动生成堆转储文件
jmap -dump:format=b,file=heap.hprof <pid>
# 5. 查看线程堆栈(排查是否有死锁或异常线程)
jstack <pid>
如果生产环境允许在线诊断,Arthas 是更好的选择,不用重启应用就能排查:
# Arthas 一键诊断
dashboard # 实时查看线程、内存、GC 概览
heapdump # 生成堆转储
thread -n 3 # 查看 CPU 占用最高的 3 个线程
memory # 查看各内存区域使用情况
Arthas 这玩意儿确实好用,我们团队现在线上排查基本都靠它,比 jmap、jstack 那一套方便太多。
第四步:分析堆转储文件
拿到 .hprof 文件后,用 MAT(Memory Analyzer Tool) 分析,这是排查内存泄漏的核心武器。
MAT 会自动生成一份 Leak Suspects Report(泄漏嫌疑报告),告诉你哪些对象占用了大量内存且无法被回收。重点关注:
- Dominator Tree(支配树):按内存占用从大到小排列,一眼看到哪个对象最 "吃内存"
- Leak Suspects(泄漏嫌疑):MAT 自动分析的内存泄漏嫌疑点
- GC Roots 引用链:从嫌疑对象追溯到 GC Root,找到是谁 "拽着" 这个对象不放
上图展示了 MAT 分析的核心思路——沿着 GC Roots 引用链往下追踪,找到哪个 "锚点" 导致大量对象无法被回收。通常你会发现一个 static 集合或者一个生命周期很长的对象,里面塞满了本该被回收的业务数据。
第五步:定位根因并修复
常见的修复手段:
| 问题类型 | 修复方式 |
|---|---|
| 集合只增不减 | 用完及时 remove / clear,或用 WeakHashMap |
| 大对象一次性加载 | 改为流式处理,分批读取 |
| 线程池无界队列 | 改为有界队列,配合适的拒绝策略 |
| 元空间溢出 | 调大 -XX:MaxMetaspaceSize,检查是否有类泄漏 |
| 直接内存溢出 | 检查 DirectByteBuffer 是否及时释放 |
| 堆本身不够大 | 调大 -Xmx,但先确认不是泄漏 |
三、预防 OOM 的最佳实践
排查是事后补救,预防才是正道。分享几个我们团队在生产环境积累的经验:
# 1. JVM 参数标配模板
-Xms2g -Xmx2g # 堆大小固定,避免动态扩缩容
-XX:+HeapDumpOnOutOfMemoryError # OOM 自动 dump
-XX:HeapDumpPath=/data/logs/heapdump.hprof
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m # 元空间限制
-XX:+UseG1GC # G1 收集器对大堆更友好
- 代码层面:集合类设初始容量避免频繁扩容;大文件用流处理;
ThreadLocal用完必须remove;静态集合定期清理或用弱引用。 - 监控层面:接入 Prometheus + Grafana 监控 JVM 内存、GC 频率;配告警阈值,比如堆使用率超过 85% 就告警。
- 测试层面:上线前用 JMeter 压测,观察内存走势是否持续上升不回落。
面试高频追问
-
追问:内存泄漏和内存溢出有什么区别?
内存泄漏是对象无法被 GC 回收(有引用链连着 GC Root),导致可用内存越来越少,最终可能引发溢出。内存溢出是确实需要这么多内存但给的不够。一句话:泄漏是 "不该留的没清掉",溢出是 "确实装不下了"。
-
追问:
jmap在生产环境使用有什么风险?jmap -histo在 JDK 8 及之前会触发 Full GC(加live参数),可能导致应用暂停。jmap -dump生成堆转储时会暂停应用(STW),堆越大暂停越久。生产环境建议优先用 Arthas,或者用-XX:+HeapDumpOnOutOfMemoryError提前配好自动 dump。 -
追问:
WeakHashMap和HashMap的区别?为什么WeakHashMap能防内存泄漏?WeakHashMap的 key 是弱引用,当 key 对象没有强引用指向时,GC 可以直接回收该 key,对应的 entry 也会在下次操作时被清除。适合做缓存场景,防止 key 对象一直被引用导致泄漏。
常见面试变体
- "线上 OOM 了怎么排查?说一下你的排查思路"
- "如何排查 Java 应用的内存泄漏?"
- "
jmap、jstack、jstat分别有什么用?" - "Arthas 用过吗?说说常用的命令"
记忆口诀
排查五步:保现场(配 dump) → 看类型(错误信息) → 用命令(jmap/jstat) → 分析 dump(MAT) → 追根因(GC Roots 链)
OOM 分类:堆(最常见)、栈(递归)、元空间(动态代理)、直接内存(NIO)
总结
OOM 排查的核心就三件事:提前配好自动 dump 保留现场,用命令行工具快速定位问题区域,用 MAT 分析堆转储找到泄漏根因。面试时从 "原因分类" 到 "排查流程" 再到 "预防措施" 三层递进地回答,基本能覆盖面试官的所有追问。最后别忘了提一嘴 Arthas,这个加分项很多面试官都认可。
