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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 原理理解:面试官不仅仅是想知道 OOM 是什么,更是想看你是否清楚 OOM 会发生在 JVM 的哪些区域,以及每个区域 OOM 的触发条件。这块能答清楚,说明你对 JVM 内存模型有真理解。

  2. 排查能力:这是重中之重。面试官想知道你遇到 OOM 之后有没有一套系统的排查方法论,而不是上来就瞎猜。会不会看日志、会不会用 jmap、会不会分析堆转储文件,这些才是区分 "背过八股文" 和 "真正处理过线上问题" 的分水岭。

  3. 预防意识:回答中能不能主动提到预防措施(比如加监控、配参数、代码 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 这玩意儿确实好用,我们团队现在线上排查基本都靠它,比 jmapjstack 那一套方便太多。

第四步:分析堆转储文件

拿到 .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 压测,观察内存走势是否持续上升不回落。

面试高频追问

  1. 追问:内存泄漏和内存溢出有什么区别?

    内存泄漏是对象无法被 GC 回收(有引用链连着 GC Root),导致可用内存越来越少,最终可能引发溢出。内存溢出是确实需要这么多内存但给的不够。一句话:泄漏是 "不该留的没清掉",溢出是 "确实装不下了"。

  2. 追问:jmap 在生产环境使用有什么风险?

    jmap -histo 在 JDK 8 及之前会触发 Full GC(加 live 参数),可能导致应用暂停。jmap -dump 生成堆转储时会暂停应用(STW),堆越大暂停越久。生产环境建议优先用 Arthas,或者用 -XX:+HeapDumpOnOutOfMemoryError 提前配好自动 dump。

  3. 追问:WeakHashMapHashMap 的区别?为什么 WeakHashMap 能防内存泄漏?

    WeakHashMap 的 key 是弱引用,当 key 对象没有强引用指向时,GC 可以直接回收该 key,对应的 entry 也会在下次操作时被清除。适合做缓存场景,防止 key 对象一直被引用导致泄漏。

常见面试变体

  • "线上 OOM 了怎么排查?说一下你的排查思路"
  • "如何排查 Java 应用的内存泄漏?"
  • "jmapjstackjstat 分别有什么用?"
  • "Arthas 用过吗?说说常用的命令"

记忆口诀

排查五步:保现场(配 dump) → 看类型(错误信息) → 用命令(jmap/jstat) → 分析 dump(MAT) → 追根因(GC Roots 链)

OOM 分类:堆(最常见)、栈(递归)、元空间(动态代理)、直接内存(NIO)

总结

OOM 排查的核心就三件事:提前配好自动 dump 保留现场,用命令行工具快速定位问题区域,用 MAT 分析堆转储找到泄漏根因。面试时从 "原因分类" 到 "排查流程" 再到 "预防措施" 三层递进地回答,基本能覆盖面试官的所有追问。最后别忘了提一嘴 Arthas,这个加分项很多面试官都认可。