容器 OOMKilled 和 Java heap OOM,区别到底在哪?
同样都是内存事故,Pod 被 OOMKilled 和 JVM 抛出 `Java heap space` 其实是两条完全不同的入口。先看是谁先把进程停掉,再决定去保 Heap Dump,还是去保 Pod 事件、RSS 和容器边界。
很多团队嘴里的“服务 OOM 了”,其实混着两种完全不同的事故。
我对这件事印象最深,不是因为概念讲清了,而是因为同一个订单服务在 24 小时里各来了一次:
- 一次是 JVM 自己抛了
Java heap space - 一次是 Pod 先被平台标成
OOMKilled
两次群里的第一句话都差不多:是不是 JVM 又炸了。
但真到排查时,路线几乎完全相反。
第一种场景里,最值钱的是:
- 异常文本
- GC 日志
- Heap Dump
- Full GC 前后对象到底谁没下来
第二种场景里,最值钱的却是:
kubectl describe pod里的OOMKilled- exit code 137
- 容器 limit
- RSS / working set
- 同 Pod 其他进程和堆外占用
所以这篇不打算做抽象概念对照,而是把那两个现场并排摆开:同样都像“内存把服务打挂了”,到底是谁先出的手,为什么这一点会决定后面整条排查线。
第一次:这是真正的 heap OOM
第一起事故发生在凌晨 01:12。营销导出任务叠到晚高峰补偿后,订单服务开始抖。
我们当时看到的第一现场是这样的:
- 应用日志直接出现
java.lang.OutOfMemoryError: Java heap space - 同时打出了
HeapDumpOnOutOfMemoryError的输出路径 - GC 日志里 Full GC 从几分钟一次缩到 20 秒一次
- Heap 逼近上限,Old 区几乎贴顶
- Pod 没有立刻显示
OOMKilled
那一轮里,最有价值的是 JVM 自己留下来的现场。日志片段大概长这样:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /data/dump/order-service-2026-03-22-0113.hprof ...
Heap dump file created [2847361991 bytes in 12.184 secs]
配合 GC 日志和监控,我们看到的链路很完整:
- 导出任务启动后,大量
OrderExportRow、ArrayList、byte[]进入堆 - Full GC 越做越勤,但 Old 几乎回不下来
- 最后某次分配直接失败,JVM 主动抛出
Java heap space
这时候,你手里拿到的是标准 JVM 现场。
也就是说,后面该做的是:
- 先保住 Heap Dump
- 用 MAT 看谁占堆、谁在持有
- 再去解释为什么对象回不下来
如果这时候还跑去先看 Pod 事件,不能说完全没意义,但优先级已经不对了。
第二次:这次不是 JVM 先报错,是容器先动手
第二起事故发生在第二天下午 16:07。服务刚上了一版 gRPC 聚合链路,几个 Pod 开始无规律重启。
群里第一反应还是“是不是 heap 又炸了”,但这次第一现场完全不一样:
- 应用日志里没有
OutOfMemoryError - 没有 Heap Dump 文件生成
- Pod
Last State里写的是Reason: OOMKilled Exit Code: 137- Heap 最高只到 8.2G / 12G,看上去远没到头
- RSS 却贴着 16Gi limit 往上跑
平台侧证据非常直白:
Last State: Terminated
Reason: OOMKilled
Exit Code: 137
Started: Tue, 22 Mar 2026 15:58:14 +0800
Finished: Tue, 22 Mar 2026 16:07:39 +0800
这时候再去等 JVM 打 Java heap space,就已经晚了。因为 JVM 根本没来得及自己说话,外层容器边界先把进程收走了。
更关键的是,Heap 指标还给了一个非常容易误导人的假象:堆没满。
如果把当时几组数据并在一起看,区别会非常明显:
| 证据 | 第一次 heap OOM | 第二次 OOMKilled |
|---|---|---|
| 应用日志 | 明确 Java heap space | 没有明确 OOM 文本 |
| Heap Dump | 成功生成 | 没生成 |
| Pod 事件 | 无 OOMKilled 主导信号 | 明确 OOMKilled, exit 137 |
| Heap 使用 | 接近上限 | 只到上限约 68% |
| RSS / working set | 随 Heap 抬高 | 明显高于 Heap,持续贴 limit |
这个对照几乎已经把路线分死了:
- 第一轮要看 Heap 和对象
- 第二轮要看容器边界、RSS、堆外、线程和 sidecar
真正决定路线的,不是都叫 OOM,而是谁先把进程停掉
很多文章会把 “OOMKilled vs heap OOM” 写成两个概念的对照表,但我更建议在事故里记这一句:
先问是谁先终止进程,再决定去找 JVM 现场,还是容器现场。
因为只有这一步分清了,后面动作顺序才不会乱。
JVM 先出手时,你通常还能拿到什么
像第一次那样,JVM 自己抛 OOM 时,常见特征是:
- 应用日志里有完整异常
- Full GC 和堆压力会提前恶化
- 可能生成 Heap Dump
- 线程、对象、引用链证据还能留住
这时候应该顺着 JVM 自己留下来的线继续下去,而不是先把锅扣给容器资源配额。
容器先出手时,你最怕错过什么
像第二次那样,Pod 先 OOMKilled 时,最怕的是你还在等 Java 自己报错。
因为一重启,下面这些证据会很快丢:
- OOM 前几分钟的 RSS / working set
- 同 Pod sidecar 的资源占用
- NMT 摘要
- 线程数和线程栈
- 直接内存、native 内存的增长轨迹
那次我们就是靠保住第二台还没死的 Pod,补了一轮:
jcmd <pid> VM.native_memory summary
jstack <pid>
kubectl top pod <pod> --containers
最后发现真正把容器顶死的,不是 Heap,而是:
- gRPC 直连链路引入的 Direct Buffer 增长
- sidecar 常驻吃掉的 400 多 MiB
- 再叠上线程栈与本身 Heap,最终把 16Gi limit 顶穿
如果当时还执着于“为什么 Heap 没满也会 OOM”,方向就已经偏了。
哪些证据能最快把两条线分开
那两次事故以后,我自己值班时先看的是这四件事:
1. 应用日志里有没有明确 OutOfMemoryError
有的话,先别急着切容器路线。至少 JVM 还来得及留下自己的现场。
2. Pod 事件里有没有 OOMKilled / exit 137
有的话,就先承认外层边界已经出手了,不要继续把所有希望压在 Heap Dump 上。
3. Heap 和 RSS 谁更像主角
- Heap 贴顶、Old 回不来:更像 heap OOM
- Heap 还远没满,RSS 贴 limit:更像容器 / 堆外 / 总占用问题
4. 有没有 Heap Dump
不是说 Heap Dump 生成失败就一定是 OOMKilled,但“没有 OOM 文本 + 没有 Dump + 有 OOMKilled 事件”这个组合,通常已经足够把主线切去容器侧。
最后,两次事故是怎么各自被验证闭环的
第一次 heap OOM,最终是把导出任务改成分页流式写出,第二晚同一时间窗口里:
- Heap 峰值从 97% 降到 68%
- Full GC 不再连着打
- 不再产生 Heap Dump
第二次 OOMKilled,最终是收紧 Direct Memory、修掉 gRPC 聚合链里的缓冲持有,并重新分配 sidecar 与应用的容器预算。验证它不是 heap OOM,看的是:
- RSS 不再贴着 limit 跑
- Pod 不再出现 exit 137
- Heap 曲线和事故前差不多,但总占用留出了将近 1Gi 的余量
这两个闭环说明的是同一件事:
“都是内存问题”这句话工程价值很低,真正有价值的是:谁先把进程停掉,证据留在 JVM 里,还是留在容器平台里。
如果你现在就站在现场里,只能先做一件事,我建议先把这一步定下来。它比任何 OOM 类型总表都更能决定后面的排查效率。