一次 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 建立现场采集、时间线分析、对象来源定位、容量评估和改进闭环,那么这次故障就不只是一次事故,而会变成系统稳定性能力的一次升级。

相关推荐
止语Lab2 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活2 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
程序员天天困2 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn2 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
用户298698530142 小时前
Python 实现 Excel 与 Markdown 互转的实用指南
后端·python·excel
用户77283104908402 小时前
krono-job:零侵入、单二进制交付的分布式任务调度平台(开源)
后端
Conan在掘金2 小时前
鸿蒙报错速查:Function lacks ending return statement,返回路径漏 return 就炸,根因 + 真解法
后端
人间凡尔赛3 小时前
AI-Native 云原生架构:2026 年从容器编排到智能体编排的范式革命
后端·云原生·架构
IT_陈寒3 小时前
Vue的响应式让我加班到凌晨3点,原来问题出在这
前端·人工智能·后端
卷无止境3 小时前
提升 Python 代码健壮性的方法大盘点
后端·python