Java

Java 服务 OOM 了怎么排查?先分类型,再决定往哪条线查

OOM 一发生,最容易做的事就是先扩内存,或者直接认定成堆泄漏。但 OOM 这个词底下其实是好几类完全不同的事故,先分类型,后面的止血和取证才不会跑偏。

  • Java
  • OOM
  • JVM
  • 性能排查
15 分钟阅读

线上服务一旦 OOM,现场通常都很乱:实例重启、接口报错、报警连着响,大家也很容易马上分成两派,一派先扩内存,一派先抓泄漏。这两种动作都不算离谱,但如果类型没分清,后面很容易忙了一圈还没摸到根。

更重要的是,OOM 并不是一个统一问题。OutOfMemoryError 只是一个总称,它背后可能对应很多不同类型的资源耗尽:

  • Java 堆不够
  • 元空间打满
  • 直接内存耗尽
  • 线程过多导致无法再创建新线程
  • GC 长时间回收无效,最终进入崩溃状态

所以排查 OOM 最关键的第一步,不是马上去优化,而是先弄清楚:

到底是哪一种 OOM,资源耗尽发生在什么位置,为什么会在那里耗尽。

后面我按线上排障里最常用的一条顺序来拆:先认清 OOM 类型,再决定该去看 heap、容器、堆外、Metaspace 还是线程资源。

OOM 已经发生时,先把入口选对

如果你现在已经进到 OOM 事故现场,第一轮更值得先把完整异常类型、实例 / Pod 标识、重启或 OOMKilled 时间、Heap Dump 是否落盘,以及事故前后几分钟的 GC 与流量变化定下来。别急着扩内存,也别一口咬成堆泄漏。

先把这张表当成事故入口,判断现在是不是已经进入 OOM 事故,以及下一步该往哪条线走。

你现在看到的现象更像什么下一步
应用日志里已经出现 OutOfMemoryError已经进入 OOM 事故处理按下文把 OOM 类型分清
之前只是内存持续上涨、Full GC 变密,但还没真正报 OOM更像 OOM 前兆阶段先看 Java 内存持续上涨但没有 OOM,先怎么排查?
Pod 被 OOMKilled,但 JVM 未必来得及抛 heap OOM更像容器边界或堆外问题容器 OOMKilled 和 Java heap OOM,先怎么区分?
已经 OOM,但你最担心现场和日志马上丢掉更像现场保护优先搭配 线上 OOM 之后,第一时间该保留哪些现场?
错误是 GC overhead limit exceeded,不确定是不是普通 heap OOM更像 GC 风暴中间态GC overhead limit exceeded 怎么排查?它常常不是普通 OOM

先把 OOM 放回故障链

这里处理的是 OOM 已经发生、你需要先把方向定下来的那一段。

  • 如果还没真正报错,只是内存上涨、GC 变密,先回到前兆判断那条线。
  • 如果已经出现 OutOfMemoryError、实例频繁重启,或者服务已经明显不可用,就该从这里开始先分类型。
  • 如果已经知道是容器先 OOMKilled、Heap 解释不通,或者明显偏向堆外占用,也别在这里硬把所有原因揉在一起。

这篇只处理事故入口这一步:OOM 已经发生后,先把主路径选对。后面该看 heap、容器、堆外、Metaspace 还是线程资源,再顺着证据切过去。

一、别把所有 OOM 都当成“堆内存不够”

这是最常见的误区。

很多人一看到 OutOfMemoryError,第一反应就是:

  • 堆太小了
  • 内存泄漏了
  • 赶紧把 -Xmx 调大

但真实情况里,OOM 经常会以不同形式出现。常见的几种包括:

  • Java heap space
  • GC overhead limit exceeded
  • Metaspace
  • Direct buffer memory
  • Unable to create new native thread

为什么先分类型这么重要

因为它们的排查方向完全不一样。

Java heap space

更偏向:

  • 堆对象太多
  • 对象分配过猛
  • 内存泄漏
  • 缓存膨胀

Metaspace

更偏向:

  • 类元数据增长异常
  • 动态生成类过多
  • 类加载器未释放

Direct buffer memory

更偏向:

  • NIO / Netty 直接内存使用过大
  • 堆外内存没有及时释放

Unable to create new native thread

更偏向:

  • 线程数过多
  • 线程池失控
  • 系统线程资源不足

所以 OOM 排查最忌讳的就是一上来把它们混成一个问题。

二、第一步:先看异常信息和日志,把 OOM 类型定下来

排查开始时,最有价值的往往不是工具,而是错误日志本身。

你要先确认这些信息:

  • 异常完整文本是什么
  • 哪个实例报的错
  • 在什么时间点发生
  • 发生前后有没有明显业务高峰或异常日志
  • 是偶发一次,还是重复出现

为什么这一步重要

因为很多 OOM 问题,光看异常类型就已经能排掉一半错误方向。

例如:

java.lang.OutOfMemoryError: Java heap space

和:

java.lang.OutOfMemoryError: Metaspace

它们虽然都叫 OOM,但后续排查动作几乎是两套路线。

所以排查时先不要急着“解决”,先把错误类型和时间窗口固定下来。

三、第二步:回到监控看问题是在“瞬时爆发”,还是“长期恶化”

OOM 很多时候不是凭空出现的,它通常会有前兆。

建议先看 OOM 前后的监控:

  • 堆内存趋势
  • Old 区使用率
  • GC 次数和停顿时间
  • CPU
  • 线程数
  • 请求量和 RT
  • 错误率

你要重点判断的是两类形态

第一类:长期缓慢恶化

例如:

  • 内存基线越来越高
  • Full GC 越来越频繁
  • 每次回收后都降不下来
  • 最终某个时间点 OOM

这种很像:

  • 内存泄漏
  • 缓存膨胀
  • 长生命周期对象太多

第二类:某个时间窗口突然打爆

例如:

  • 某个批任务启动
  • 某次流量峰值
  • 某类请求参数异常大
  • 某个导出/聚合接口突然制造大量对象

这种更像:

  • 一次性数据量过大
  • 对象创建过猛
  • 参数和业务峰值不匹配

为什么这个区分重要

因为长期恶化更像“对象活太久”,瞬时爆发更像“对象来得太猛”。

四、如果是堆 OOM,最关键的证据通常还是 Heap Dump

当 OOM 类型明确偏向堆内存,例如:

  • Java heap space
  • GC overhead limit exceeded

这时真正最有价值的证据通常还是堆快照。

最理想的情况

是服务启动时已经配置好:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dumps

这样一旦 OOM,JVM 会自动把现场堆转储下来。

为什么这个非常重要

因为 OOM 现场往往转瞬即逝。服务一重启,很多证据就没了。

如果没有提前开这个参数,等你出事后再想补抓,很多时候已经来不及。

所以从长期运维角度看,这两个参数很值得作为线上服务默认配置之一。

五、拿到堆转储之后,先回答一个最关键的问题:谁占了内存

一旦拿到 heap.hprof,就可以用 MAT 之类工具分析。

第一步最重要的不是把每个图都点一遍,而是先问:

堆里到底是谁占了大头?

常用入口还是这几个

  • Histogram
  • Dominator Tree
  • Leak Suspects Report

真正最值得先看的是

  • 大对象
  • 大集合
  • 某类业务对象是否异常多
  • 谁在支配这些对象

例如你可能会看到:

  • 某个 HashMap 特别大
  • 某个缓存对象挂着大量 value
  • 某类 DTO / Entity 数量远超预期
  • byte[]StringArrayList 占用惊人

但这时不要急着下结论。真正下一步要继续问的是:

  • 它们为什么还活着
  • 是谁在持有它们
  • 这是业务上合理的缓存,还是不该存在的持有关系

六、如果没有堆转储,也不是完全没法查

线上并不总是理想状态,有时:

  • 没开 HeapDumpOnOutOfMemoryError
  • 磁盘空间不够
  • 服务重启太快
  • 现场已经丢了

这时虽然证据会少很多,但仍然可以从几个方向补判断。

1. 看 OOM 类型

先把方向收窄。

2. 看监控趋势

判断是:

  • 堆持续上涨
  • 线程数暴涨
  • 还是某个时间点直接打满

3. 看业务时间点

例如:

  • 是否某个任务触发
  • 是否某类接口请求暴增
  • 是否某个配置或发布后开始异常

4. 看应用日志

例如:

  • 某类数据量异常大
  • 某次导出或聚合明显超出预期
  • 某类异常导致重试风暴

虽然这种方式不如堆分析直接,但至少能帮助你先把嫌疑方向缩到一个合理范围。

七、不同 OOM 类型,大概应该先往哪里查

这里给一版实用的快速判断。

1. Java heap space

优先查:

  • 堆转储
  • 大对象和大集合
  • 本地缓存
  • 长生命周期对象
  • 一次性数据装载过多

2. GC overhead limit exceeded

这通常说明:

  • JVM 花了大量时间在 GC
  • 但回收效果很差

优先查:

  • 堆内对象是否已经接近打满
  • Full GC 后是否降不下来
  • 是否接近泄漏或缓存膨胀

3. Metaspace

优先查:

  • 类加载器问题
  • 动态代理/动态生成类
  • 热部署或反复加载类未释放

4. Direct buffer memory

优先查:

  • Netty / NIO 堆外内存使用
  • 直接缓冲区释放是否及时
  • 堆外内存参数是否合理

5. Unable to create new native thread

优先查:

  • 线程数是否失控
  • 线程池是否无限扩张
  • 是否存在线程泄漏
  • 系统级线程资源限制

只要这一步方向定对,后面很多无效排查都能省掉。

八、项目里最常见的几类堆 OOM 根因

如果是最常见的堆 OOM,根因通常集中在下面几类。

1. 本地缓存没有边界

例如:

  • ConcurrentHashMap 只增不减
  • 缓存按用户或维度无限增长
  • 没有过期策略和容量限制

2. 一次性查太多数据进内存

例如:

  • 批量导出
  • 大报表
  • 一次性聚合几十万条记录

3. 内存泄漏

例如:

  • 静态集合持续持有对象
  • ThreadLocal 没清理
  • 单例 Bean 错误持有请求级对象

4. 对象创建过快

例如:

  • 大量 JSON 反序列化
  • 深层转换和复制
  • 大列表反复 stream / collect

5. 参数和业务峰值不匹配

例如:

  • 服务承接流量变大了
  • 但堆大小和处理方式还停留在旧规模

这类不是“代码有 bug”,但同样会把服务推到 OOM。

九、一个更接近实战的例子:怎么一步步看出 OOM 根因

假设某个服务在下午高峰期反复报:

java.lang.OutOfMemoryError: Java heap space

第一步:看监控

发现:

  • 堆内存持续升高
  • Full GC 频繁
  • 回收后内存仍然降不下来

第二步:判断形态

这更像长期持有对象太多,而不是单纯瞬时高峰。

第三步:拿堆转储看 MAT

发现一个本地缓存对象支配了大量 HashMap$Node 和业务对象。

第四步:回到代码

发现服务里有一个无上限缓存,按用户维度不断塞数据,几乎没有淘汰机制。

第五步:得出结论

这时问题就清楚了:

  • 不是 JVM 随机抽风
  • 不是简单把堆调大就能根治
  • 而是缓存设计本身缺边界

第六步:后续修复方向

  • 增加容量限制
  • 加过期策略
  • 把部分数据下沉到外部缓存
  • 控制 value 体积

这类例子特别能说明:

OOM 真正需要找的,不是“哪一刻炸了”,而是“哪类对象一路把堆推到了崩溃边缘”。

十、几个很容易踩的误区

1. 一看到 OOM 就先加内存

有时能救火,但如果根因是泄漏或无边界缓存,只是晚点再炸。

2. 一看到 OOM 就默认是内存泄漏

也可能是一次性数据量过大、线程太多、堆外内存或元空间问题。

3. 只看异常,不看时间窗口和监控趋势

缺少趋势,很容易把长期恶化和瞬时爆发混为一谈。

4. 拿到堆转储后只看对象数量,不看引用链

真正关键的是:谁在持有这些对象。

5. 等问题反复炸很多次,才开始准备 Heap Dump

这个动作越晚做,现场越难保留完整。

十一、最后留一份够用的排查 checklist

以后再遇到 Java 服务 OOM,可以先沿这个顺序收现场:

第 1 步:先看 OOM 类型

  • Java heap space
  • Metaspace
  • Direct buffer memory
  • Unable to create new native thread

第 2 步:看监控趋势

  • 堆是否持续上涨
  • Full GC 是否频繁
  • 线程数是否异常
  • 是否在某个固定时间窗口爆发

第 3 步:判断更像哪类问题

  • 长期恶化
  • 瞬时打爆
  • 堆外资源耗尽
  • 线程资源耗尽

第 4 步:如果是堆 OOM,优先拿 Heap Dump

  • 有自动 dump 最好
  • 没有也尽量保留现场证据

第 5 步:分析谁占内存、谁在持有

  • Histogram
  • Dominator Tree
  • GC Roots

第 6 步:最后回到代码和业务场景修

  • 缓存边界
  • 对象生命周期
  • 批处理方式
  • 线程池和资源模型
  • JVM 参数

十二、FAQ:已经 OOM 之后,最常被问到的几个问题

1. 一出现 OOM,是不是先加内存最稳?

不一定。加内存有时能止血,但如果根因是无边界缓存、对象生命周期失控、线程过多或堆外占用抬升,它只是把事故延后,不会把问题消掉。

2. 只要报 OutOfMemoryError,是不是就一定是 heap 问题?

不是。Java heap spaceMetaspaceDirect buffer memoryunable to create new native thread 甚至容器 OOMKilled,排查主路径都不同,第一步一定是先分类型。

3. OOM 之后最怕先丢掉什么证据?

最怕先丢掉完整异常文本、OOM 前后日志、Heap Dump、GC 趋势、线程现场和容器事件。如果实例会自动重启,最好先把现场保住,再做恢复动作。

4. 什么时候该从本文切到容器 / 堆外分支?

如果 Pod 已经被 OOMKilled、Heap 没满、JVM 日志又解释不通,就不要继续只按 heap OOM 思路查,而该切到 容器 OOMKilled 和 Java heap OOM,先怎么区分?堆外内存上涨把容器逼死时,先留哪些证据?

5. 什么时候最值得继续看 Heap Dump 和 MAT?

当你已经确认是 Java heap spaceGC overhead limit exceeded,或者 Full GC 后内存回不下来时,Heap Dump 和 MAT 的价值最高,因为它们能回答“谁占了内存、为什么还活着”。

十三、FAQ:事故入口阶段最容易追问的几个问题

1. 已经 OOM 了,是不是先扩内存最稳?

不一定。扩内存可能是止血动作,但事故入口阶段更重要的是先分清类型:heap、metaspace、direct memory、native thread 还是容器边界,不然很容易止住表面、放过根因。

2. OOM 之后第一时间最怕丢什么?

最怕丢完整异常文本、OOM 前后日志、Heap Dump、GC 趋势、线程现场和容器事件。实例一旦自动重启,这些关键证据很可能立刻不完整。

3. 什么情况下不要继续按 heap OOM 思路查?

如果 Pod 先 OOMKilled、Heap 没满、JVM 日志又解释不通,就不要继续只按 heap OOM 走,而要切到 容器 OOMKilled 和 Java heap OOM,先怎么区分?堆外内存上涨把容器逼死时,先留哪些证据?

4. 什么情况下该从本文回到前兆页?

如果你回头发现现场其实还没有明确 OutOfMemoryError,只是内存上涨、Full GC 变密、业务还没彻底挂,那更适合先回 Java 内存持续上涨但没有 OOM,先怎么排查?,把趋势和边界先判断清楚。

5. OOM 事故入口阶段最重要的目标是什么?

不是立刻给根因下结论,而是先选对主路径:到底该继续看 Heap Dump、GC、线程、容器事件,还是堆外 / RSS / native 证据链。事故一开始先把方向分对,后面很多无效动作自然会少掉。

十四、OOM 分完类型后,各类现场分别接哪篇

如果你已经把 OOM 先分到 heap、容器边界或堆外方向,下面这些文章可以直接接住不同现场,不用再回到一个泛化的 OOM 入口里打转。

十五、回到事故入口

OOM 这个词看起来很统一,但它背后真正出问题的资源可能完全不同。你如果不先把类型、现场和时间形态分清楚,就很容易在错误方向上做很多无效动作。

所以更值得养成的,不是“看到 OOM 就先扩内存”的条件反射,而是这一条主线:

先分 OOM 类型,再看监控趋势和现场证据;确认是 heap、Metaspace、direct memory、native thread 还是容器边界问题之后,再决定 Heap Dump、线程、GC 或平台事件谁是主证据链。

只要这条顺序清楚了,大多数 OOM 问题都会从“JVM 出大事了”,慢慢变成一条可以分析、验证和修复的工程问题。