Java

容器 OOMKilled 和 Java heap OOM,区别到底在哪?

同样都是内存事故,Pod 被 OOMKilled 和 JVM 抛出 `Java heap space` 其实是两条完全不同的入口。先看是谁先把进程停掉,再决定去保 Heap Dump,还是去保 Pod 事件、RSS 和容器边界。

  • Java
  • JVM
  • OOM
  • 容器
  • Kubernetes
  • 故障排查
15 分钟阅读

很多团队嘴里的“服务 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 日志和监控,我们看到的链路很完整:

  • 导出任务启动后,大量 OrderExportRowArrayListbyte[] 进入堆
  • 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 类型总表都更能决定后面的排查效率。