一次 JVM OOM,资深工程师应该如何完整复盘?

直接结论

JVM OOM 不是把堆调大、重启服务、补一个监控阈值就结束了。

一次合格的 OOM 复盘,至少要回答四个问题:

  1. 到底是哪一种内存耗尽?
  2. 哪个业务动作或系统变化触发了它?
  3. 为什么现有机制没有提前拦住?
  4. 后续如何降低同类问题再次发生的概率?

资深工程师和普通工程师的差别,往往不在于能不能看懂 heap dump,而在于能不能把一次故障从"单点技术问题"还原成"容量、代码、流量、发布、监控、应急机制"的系统性问题。

问题背景

很多团队处理 OOM 的流程是这样的:

报警出现,服务不可用,先重启。

如果恢复了,就把堆从 4G 调到 6G;如果又出现,就继续调到 8G。等业务不再报警,问题就被认为解决了。

这种处理方式短期有效,但风险很大。因为 OOM 只是结果,不是原因。堆内存不够可能是正常增长后的容量不足,也可能是对象泄漏、批量任务失控、大查询返回过多、缓存无边界、线程堆积、直接内存耗尽,甚至是一次看似无关的流量策略变化。

如果不把原因拆清楚,下一次故障通常会以更高峰值、更大影响面、更短反应时间再次出现。

第一步:先确认 OOM 类型,不要默认是堆

看到 OutOfMemoryError,很多人第一反应是 Java 堆不够。但 JVM 里的 OOM 不只有一种。

常见类型包括:

  1. java.lang.OutOfMemoryError: Java heap space

这是最容易想到的堆内存不足。常见原因是大对象、集合无限增长、缓存无淘汰、批处理一次性加载过多数据、对象无法被 GC 回收。

  1. java.lang.OutOfMemoryError: GC overhead limit exceeded

这通常说明 JVM 大部分时间都在 GC,但回收效果很差。它不一定是一个独立原因,很多时候是堆空间不足或对象存活过高的表现。

  1. java.lang.OutOfMemoryError: Metaspace

常见于类加载过多、动态代理生成类失控、热部署或插件化机制释放不完整。

  1. java.lang.OutOfMemoryError: Direct buffer memory

这类问题经常出现在 Netty、NIO、压缩解压、文件传输、消息中间件客户端等场景。只看堆监控可能看不出明显异常。

  1. unable to create new native thread

这不是 Java 堆里的对象太多,而是线程数、系统资源或容器限制出了问题。根因可能是线程池配置错误、阻塞调用堆积、连接超时设置不合理。

所以复盘第一步不是"为什么堆不够",而是"这次耗尽的是哪块资源"。

第二步:保留现场,而不是只保留重启记录

OOM 发生时,最有价值的是现场证据。没有现场,后续复盘就只能靠猜。

建议至少保留这些材料:

  • OOM 前后的应用日志;
  • GC 日志;
  • heap dump 或相关内存转储;
  • 线程栈
  • 容器或机器层面的内存、CPU、线程、文件句柄指标;
  • 事故窗口内的发布、配置变更、定时任务、流量变化记录;
  • 业务维度指标,例如订单量、查询量、导入文件大小、消息积压量。

有些团队会在服务启动参数里配置 -XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=...,这很好,但还不够。因为不是所有 OOM 都能只靠 heap dump 解释,尤其是直接内存和线程类问题。

更稳妥的做法,是把故障现场采集纳入标准应急流程:什么时候抓线程栈,什么时候保留 GC 日志,什么时候复制 dump,什么时候可以重启,谁负责保存证据。

重启是止血动作,不是复盘动作。止血前后要尽量保住能解释问题的证据。

第三步:用时间线把技术现象和业务行为对齐

OOM 复盘里最容易漏掉的一步,是把技术指标和业务行为放到同一条时间线上。

比如一个接口在 14:05 开始 RT 上升,14:08 Full GC 频率增加,14:10 老年代占用接近上限,14:12 服务开始大量超时,14:15 出现 OOM。

如果只看 JVM,会得到"对象太多"的结论。

但如果把业务行为放进来,可能会发现:

  • 14:00 开始了一个全量导出任务
  • 14:03 上游把分页大小从 500 调到 5000;
  • 14:04 某个活动入口放量;
  • 14:06 消息消费失败后开始重试;
  • 14:07 新版本上线后缓存 key 维度变细。

这时问题就不再是"JVM 内存不够",而是"某个业务动作改变了对象产生速度或存活时间"。

资深工程师做复盘,一定要把 OOM 还原成一条链路:

业务动作 -> 流量或数据变化 -> 代码路径 -> 对象分配 -> GC 压力 -> 服务退化 -> 故障结果。

只有链路完整,后面的改进才不会只停留在 JVM 参数层面。

第四步:定位对象来源,而不是只看最大对象

分析 heap dump 时,很多人会盯着占用最大的对象。这个方向没错,但不够。

真正要看的不是"哪个对象大",而是:

  • 这些对象为什么还活着?
  • 谁引用了它们?
  • 它们是一次性产生,还是持续增长?
  • 它们和哪个业务请求、任务、缓存、队列有关?
  • 是否存在本该释放但没有释放的引用链?

举一个说明性的例子:某系统 OOM 后发现 ArrayList 占用很高。如果只看到这里,很容易得出"集合太大"的结论。

但继续追引用链,可能会发现它是一次 Excel 导出任务把几十万条数据一次性查出,再转换成 DTO,再生成中间行对象,最后才写文件。这里的问题不是 ArrayList 本身,而是导出模型没有流式处理,也没有限制单次导出规模。

同样,缓存占用大也不一定说明"缓存错了"。要进一步看是否缺少最大容量、是否 key 维度异常、是否淘汰策略失效、是否把用户级临时数据放进了进程缓存。

OOM 复盘要避免把现象当根因。最大对象只是入口,引用关系和业务语义才是重点。

第五步:区分修复、缓解和治理

一次 OOM 后,团队通常会做一些动作:

  • 扩大堆内存;
  • 增加实例数;
  • 调整 GC 参数;
  • 给接口加限流;
  • 修复代码里的缓存或集合问题;
  • 增加监控报警。

这些动作不能混在一起看。它们属于不同层级。

扩堆、扩容、重启、摘流量,通常是缓解动作。它们能降低当前风险,但不一定消除根因。

修代码、改 SQL、改批处理模型、限制导出规模、修复引用泄漏,是修复动作。它们针对具体问题。

建立容量评估、压测基线、发布观察、任务准入、内存水位报警、故障演练,是治理动作。它们降低同类问题再次发生的概率。

资深工程师的复盘报告里,不能只有"已修复某个 bug"。还要明确哪些动作是临时止血,哪些动作是真正修复,哪些动作进入长期治理。

一个可落地的 OOM 复盘模板

可以按下面结构写复盘:

  1. 故障概述

说明发生时间、影响范围、受影响接口或任务、用户感知、恢复时间。

  1. OOM 类型

明确是堆、元空间、直接内存、线程,还是容器内存限制触发。不要模糊写"内存溢出"。

  1. 时间线

列出发布、流量、任务、报警、GC、OOM、重启、恢复等关键时间点。

  1. 现场证据

列出日志、dump、线程栈、监控图、业务指标,并说明证据支持了什么判断。

  1. 根因分析

从触发条件、代码路径、对象来源、资源限制、防护缺失几个层面说明。

  1. 处置过程

区分应急止血和根因修复,说明每一步为什么这么做。

  1. 改进项

每个改进项要有负责人、截止时间和验证方式。比如"导出任务改为流式处理"比"优化导出"更可执行;"缓存增加最大容量和命中率监控"比"优化缓存"更具体。

  1. 复发验证

说明如何验证不会再次出现:压测、回放、灰度观察、内存曲线对比、报警演练等。

常见错误做法

第一,只调大堆内存。

堆变大可能让 OOM 延后,但如果对象持续增长,最后仍然会出问题,而且 Full GC 的代价可能更高。

第二,只贴几张监控图。

监控图能说明现象,但不能自动解释根因。复盘要把指标和业务行为连接起来。

第三,把责任停在某个开发人员身上。

如果一个导出接口可以无限拉取数据、一个缓存可以无限增长、一次发布没有内存观察窗口,那问题就不只是某个人写错了一行代码。

第四,改进项写得太虚。

"加强监控""提升代码质量""完善流程"都不是合格改进项。合格改进项应该能被检查、被验收、被复盘。

总结

一次 JVM OOM 的完整复盘,不是为了证明谁写错了代码,而是为了回答系统为什么允许问题发展到故障级别。

普通处理方式关注"怎么恢复"。

更成熟的处理方式关注"为什么发生、为什么没提前发现、为什么影响扩大、下次如何更早拦住"。

如果团队能围绕 OOM 建立现场采集、时间线分析、对象来源定位、容量评估和改进闭环,那么这次故障就不只是一次事故,而会变成系统稳定性能力的一次升级。

相关推荐
孟陬10 小时前
一个小小 alias,提升开发幸福感
前端·后端·命令行
JunLa10 小时前
OpenClaw Agent
后端
AskHarries10 小时前
为什么大多数人创业第一步就错了
人工智能·后端
tyung10 小时前
Go 手写二叉堆优先队列:避开 container/heap 的性能陷阱
数据结构·后端·go
Nirvana在掘金10 小时前
MySQL 事务隔离级别 锁 高并发场景优化经验
后端·mysql
李小狼lee11 小时前
《spring如此简单》第二节--IOC思想的实现,容器是什么
后端·面试
GetcharZp11 小时前
深入浅出 etcd:从 K8s 灵魂到 Golang 实战,分布式系统的“定海神针”!
后端
hikktn11 小时前
企业级Spring Boot应用管理:从零打造生产级启动脚本
java·spring boot·后端
SimonKing11 小时前
别再死磕 Elasticsearch 了,这个轻量级搜索引擎更香
java·后端·程序员