Java 服务 OOM 了怎么排查?先分类型,再决定往哪条线查
OOM 一发生,最容易做的事就是先扩内存,或者直接认定成堆泄漏。但 OOM 这个词底下其实是好几类完全不同的事故,先分类型,后面的止血和取证才不会跑偏。
线上服务一旦 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 spaceGC overhead limit exceededMetaspaceDirect buffer memoryUnable 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 spaceGC 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[]、String、ArrayList占用惊人
但这时不要急着下结论。真正下一步要继续问的是:
- 它们为什么还活着
- 是谁在持有它们
- 这是业务上合理的缓存,还是不该存在的持有关系
六、如果没有堆转储,也不是完全没法查
线上并不总是理想状态,有时:
- 没开 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 spaceMetaspaceDirect buffer memoryUnable 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 space、Metaspace、Direct buffer memory、unable 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 space、GC 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,只是内存持续上涨、Full GC 变密:
Java 内存持续上涨但没有 OOM,先怎么排查? - 错误就是
GC overhead limit exceeded,想先判断是不是 GC 风暴中间态:GC overhead limit exceeded怎么排查?它常常不是普通 OOM - Pod 被
OOMKilled,但你怀疑不是典型 heap OOM:容器 OOMKilled 和 Java heap OOM,先怎么区分? - 已经需要先保现场,避免重启把证据洗掉:
线上 OOM 之后,第一时间该保留哪些现场? - 你怀疑真正的问题在堆外、线程或 native 占用:
堆外内存上涨把容器逼死时,先留哪些证据? - 已经准备看对象和引用链,想继续下钻 Heap Dump:
Java 内存泄漏怎么定位?从 Heap Dump 到 MAT 实战
十五、回到事故入口
OOM 这个词看起来很统一,但它背后真正出问题的资源可能完全不同。你如果不先把类型、现场和时间形态分清楚,就很容易在错误方向上做很多无效动作。
所以更值得养成的,不是“看到 OOM 就先扩内存”的条件反射,而是这一条主线:
先分 OOM 类型,再看监控趋势和现场证据;确认是 heap、Metaspace、direct memory、native thread 还是容器边界问题之后,再决定 Heap Dump、线程、GC 或平台事件谁是主证据链。
只要这条顺序清楚了,大多数 OOM 问题都会从“JVM 出大事了”,慢慢变成一条可以分析、验证和修复的工程问题。