线上服务一 OOM,最怕的不是机器挂掉,而是整个团队站在工位前,盯着监控面板不知道下一秒该点哪里。干过几年后端的人基本都有这种体会:翻日志、查监控、猜代码,三件事来回折腾,运气好半小时找出凶手,运气不好一整天过去,服务重启了三四轮,问题还在那儿。这篇文章就是把线上 OOM 定位这件事彻底聊透。我会从 OOM 的类型判断、事前埋点、dump 分析、实战复盘到延伸场景全部过一遍,全程基于真实可行的经验,不绕弯子,保证你下次再遇到线上 OOM,不是靠感觉,而是靠流程。
先把我自己的习惯放前面:OOM 定位这个事,七分在事前,三分在事中。真正跑线上的人都知道,现场转瞬即逝,没有提前准备好的埋点和监控,等事故发生了再上去抓现场,基本等于大海捞针。
1. 线上 OOM 的常见形态:先想清楚到底炸的是哪块内存
很多人一看到 OutOfMemoryError 就默认是堆内存不够,实际上 OOM 是一个家族,Java 虚拟机在运行时不同区域都有各自的溢出形态。定位的第一步不是拿工具分析 dump,而是先回答一个问题:这次 OOM 炸的是哪个区?
1.1 三代 OOM:堆溢出、元空间溢出、直接内存溢出
第一类,java.lang.OutOfMemoryError: Java heap space,这是最典型、最常见的一种。堆内存分为新生代和老年代,新生代存短命对象,老年代存熬过多次 GC 的长命对象。堆溢出的本质是:对象大量产生且无法被回收,GC 反复尝试后仍然腾不出空间。出现这种错误时,服务通常会频繁 Full GC,然后在某一次分配对象时直接抛异常退出。
第二类,java.lang.OutOfMemoryError: Metaspace,这是元空间的内存不够了。元空间存的是类的元数据、方法信息、常量池这些 JVM 层面的东西。线上遇到这种 OOM,多半是动态生成类导致的,比如 CGLIB 代理类、反射生成的大量代理对象、热部署场景下的类加载器泄漏。
第三类,java.lang.OutOfMemoryError: Direct buffer memory,直接内存溢出。这个最坑爹,因为它不在堆内,默认情况下也不受 -Xmx 限制,NIO 的 ByteBuffer、Netty 的堆外内存都会从这块走。很多团队只盯着堆大小调参,忽略了 -XX:MaxDirectMemorySize,结果堆只有 2G,堆外却塞了 10G,最后被操作系统杀掉。
另外还有两种容易混淆的情况:一种是 java.lang.OutOfMemoryError: GC overhead limit exceeded,这实际上是 GC 濒死挣扎的表现——JVM 花超过 98% 的时间在 GC 上,但回收回来的内存不足 2%;另一种是创建线程时出现的 Unable to create new native thread,本质是操作系统的线程数和进程内存上限被耗尽了,严格说也属于 native memory 的问题。
注意:判断 OOM 类型不是靠猜的。第一步永远是看日志,找到你服务里
OutOfMemoryError后面的完整错误文案。不同文案对应完全不同的排查路径,搞错了方向就会白忙活半天。
1.2 为什么线上 OOM 比本地复现要难十倍
本地环境复现 OOM 相对容易,因为你可以控制并发量、控制数据规模、反复试错。但线上环境完全不一样,几个因素叠加在一起,把定位难度拉满了:
第一,流量是不可预测的。平时每天几十万请求的服务,某天突然来了一波活动流量,热点商品、秒杀接口、大促链路,每一层的对象分配速率都可能飙升。这种突发流量导致的 OOM,你在本地用 JMeter 压测都很难精准模拟出同样的内存分配曲线。
第二,数据特征不同。本地测试用的都是脱敏的、抽样的小数据,但线上真实数据往往存在大量边界情况。举个例子,一个接口本来正常返回 100 条记录,某天上游产线数据异常,一条记录里嵌套了几万条子记录,JSON 反序列化直接撑爆了内存。这种问题是数据内容触发的,代码层面的逻辑在本地怎么测都是好的。
第三,线程数和请求堆积的问题本地基本测不出来。Tomcat 默认的线程池是 200,当上游接口变慢、数据库连接耗尽时,请求全部堆积在队列里,每个请求占着部分内存不释放,量一起来直接 OOM。这种“慢服务拖垮内存”的连锁反应,本地环境根本模拟不出来。
所以,线上 OOM 定位必须有一套标准化的打法,不能靠灵感和运气。接下来的内容就是把这套打法一步步拆开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 事前准备做足三分:三件套必不可少
线上 OOM 定位最大的痛点是现场消失。进程一挂,内存里的一切都没了,再牛的分析师也只能对着重启后的空状态发呆。所以真正高效的团队,都会提前在 JVM 启动参数和监控体系上做足功夫,保证 OOM 发生的那一刻,证据能留下来。
2.1 启动参数:JVM 崩溃前必须留下 dump 文件
所有 Java 服务,无论用什么框架跑,启动参数里都应该加上 this 组配置。我自己的标配是这样的:
bash复制-Xms4g -Xmx4g
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/dump/
-XX:+ExitOnOutOfMemoryError
-Xms 和 -Xmx 设置成一样的值,避免运行期堆容量动态伸缩带来的不确定性。-XX:+HeapDumpOnOutOfMemoryError 是核心中的核心,它让 JVM 在抛出 OutOfMemoryError 之前自动导出堆 dump 文件。-XX:HeapDumpPath 指定 dump 文件的输出目录,建议和普通应用日志分开存放,单独挂一块磁盘,避免 dump 文件把日志目录撑爆。
-XX:+ExitOnOutOfMemoryError 这条可能有人不熟悉,它的作用是:一旦发生 OOM,JVM 直接退出进程,不做任何挣扎。这样做有几个好处。第一,避免服务在 OOM 边缘反复 Full GC 却继续接受新请求,导致整个集群的雪崩。第二,进程退出后,容器编排系统(Kubernetes、Docker 自愈策略)会自动拉起一个新实例,让服务迅速恢复。第三,dump 文件是 JVM 在抛异常之前生成的,进程退出不影响 dump 分析。
经验之谈:我一直不建议在生产环境使用
-XX:+HeapDumpOnOutOfMemoryError之外再加什么“自动重启 JVM”的脚本。让容器编排层负责重启更干净,脚本处理不好会造成启动冲突、多次重启、端口占用等一系列衍生问题。重启策略交给上层,JVM 只负责留证据和干净退出。
2.2 监控体系:memory 指标和 GC 日志缺一不可
dump 文件解决的是“事后怎么分析”的问题,但定位 OOM 还有一个关键环节:还原“内存是怎么涨上去的”。这需要监控数据来支撑。
先说内存指标监控。JVM 层面的堆内存使用量、非堆内存使用量、GC 次数、GC 耗时、线程数,这些指标必须接入你的监控平台。现在主流的 Prometheus + Grafana 方案里,jvm_memory_used_bytes 和 jvm_gc_pause_seconds 是必看的指标。我见过太多团队只监控 CPU 和容器内存、完全忽视 JVM 堆内存曲线的,结果 OOM 发生前堆内存明明已经画出一道陡峭的上涨曲线,监控上却完全没有告警。
GC 日志也要开。JDK 8 之后的版本建议直接启用统一日志:
bash复制-Xlog:gc*:/data/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
GC 日志能在没有 dump 的情况下帮你判断内存增长的模式。比如观察到日志里 Full GC 频率从每小时一次变成每十分钟一次,说明老年代有东西在堆积;如果 Young GC 后内存回收率极低,说明大部分对象都是存活的,可能存在泄漏。
监控层面还需要关注一个容易忽略的点:容器内存和堆内存是两回事。很多团队在 Kubernetes 里给 Pod 设置了 limits.memory=2Gi,但 JVM 的 -Xmx 却设置的 4G,这种情况 JVM 还认为自己有 4G 可用,结果容器直接被杀掉,日志里根本不是 OOM,而是 Pod 被 OOMKilled。这种问题的定位已经脱离了 JVM 层面,需要去看容器事件。
2.3 一个容易被忽略的动作:上线前压测摸底
线上 OOM 定位做得再快,也不如压根不发生。我强烈建议:任何涉及内存敏感逻辑的项目,在上线前做一次简单的压测摸底。不需要搞多专业的压测平台,用 JMeter 或 Gatling 模拟预期峰值的 1.5 倍流量跑 20 分钟,观察堆内存曲线是否稳定。如果在压测阶段就已经能看到堆内存只涨不降的曲线,那就不用等线上出事,直接进入排查流程了。
这一步的作用不是给你“没有 OOM”的安全感,而是给你一条“内存正常波动”的基准线。没有这条基线,线上 OOM 发生的时候你根本不知道当前的内存曲线是不是异常,排查效率会大打折扣。
3. 拿到 dump 之后,MAT 才是真正的破案工具
前面做了足够的准备工作,现在进入核心环节:当 OOM 真的发生了,dump 文件也导出来了,接下来该怎么分析?我用得最多的工具是 Eclipse MAT,配合 JDK 自带的 jmap 和 jstat。MAT 的功能非常强大,但很多人只会看一个 Histogram,那就太浪费了。
3.1 打开 dump 的前 5 分钟:先看 Overview 和 Leak Suspects
拿到 dump 文件后,第一件事不是去翻对象列表,而是看 MAT 的 Overview 页面和 Leak Suspects 报告。
Leak Suspects 是 MAT 自动分析的结论,它会用红色标注出最可能的内存泄漏链,告诉你是哪个类的实例被哪个线程、哪个对象引用撑爆了。虽然它的自动分析有时候会误报,但作为第一参考是完全够用的。大多数情况下,点开 Leak Suspects 就能定位到可疑的代码位置。
Overview 页面里有一项非常关键的数据:Total heap size 和 Class loader count。如果 class loader 数量异常大(比如一个几十类加载器的服务却有上万个 class loader),基本可以判断是类加载器泄漏,配合 Metaspace OOM 一起看,就容易定位了。
3.2 核心分析动作:Dominator Tree 和 Path to GC Roots
Leak Suspects 只能给你一个方向,真正要确认根因,必须在 Dominator Tree 里往下钻。
Dominator Tree(支配树)回答的问题很直接:如果我要释放某块内存,释放谁最有效?它会按照“对象及其所有引用子树”的大小从大到小排序。所以你只需要从大到小看一遍,特别注意那些预期外的大对象。
举个例子。有一次我定位一个 OOM,Leak Suspects 里指向的是 java.util.HashMap。光看这个类名根本没用,因为 HashMap 是太通用的结构,哪里都有。但在 Dominator Tree 里可以看到这个 HashMap 的引用链:一个叫做 ProductInfoCache 的对象引用了这个 HashMap,HashMap 里有 300 多万个 key-value。继续追到 Path to GC Roots,发现 ProductInfoCache 是一个静态字段,本来设计是“数量有限的热点数据缓存”,实际上运行了一段时间后塞进了全部商品数据,而且没有淘汰机制。根因一目了然。
Path to GC Roots 这个功能是在选中某个可疑对象后,右键选择 “Merge Shortest Paths to GC Roots” 来查看的。它会展示出“谁在引用谁”的完整链条,直到到达 GC Root。这个链条非常关键,因为很多时候你看到的对象是“结果”,而引用这个对象的业务代码才是“原因”。
实操心得:分析 dump 的时候,不要一上来就开 Histogram。Histogram 只告诉你哪个类的实例最多,但类的实例太多不一定就是泄漏点,可能只是正常的业务数据。Dominator Tree 加 Path to GC Roots 才是完整的证据链。
3.3 线上 dump 太大打不开怎么办
线上环境的堆内存经常是 4G 甚至 8G,dump 文件可能达到 8G 以上。MAT 默认配置在处理大文件时容易 OOM(讽刺的是,分析 OOM 的工具自己也 OOM 了)。这个问题有解:
第一,调大 MAT 的 MemoryAnalyzer.ini 文件里的 -Xmx 参数,默认是 1024m,建议调到 4g 或更高,看你分析机的内存配置。
第二,用 MAT 的批处理模式导出报告,不让 GUI 界面参与文件加载。命令行代码如下:
bash复制./ParseHeapDump.sh /data/dump/java_pid12345.hprof \
org.eclipse.mat.api:suspects \
org.eclipse.mat.api:overview \
org.eclipse.mat.api:top_components
这条命令会在 dump 文件同目录生成压缩的分析报告。生成的 zip 包打开后就是 HTML 格式的 Leak Suspects 和 Overview,轻量、快,适合远程获取报告后发给团队成员一起看。
第三,如果连批处理模式都扛不住,那就直接从 dump 文件里挑关键信息看。用 jmap -histo:live <pid> 对正在运行的实例做一次轻量打印,或者用 Eclipse MAT 的 OQL 执行特定查询,只查自己关心的类。OQL 类似 SQL,比如想查某个包下的所有对象占用总量,可以这样写:
sql复制SELECT t, SUM(t.retainedHeapSize) AS total
FROM INSTANCEOF com.yourcompany.yourmodule.* t
GROUP BY t
这样可以快速过滤掉不关心的内容,只聚焦自己代码里的对象。
4. 从 dump 到根因:完整排查路径的实战复盘
工具会用了,但整个排查流程如果没有章法,还是容易绕圈子。我梳理了一条经过验证的完整路径,每一步做什么、看什么、怎么判断,都写清楚。
4.1 一条从日志到代码的标准追踪链
标准追踪链分为六步,每一步都有明确的输入和输出:
第一步,先看日志确认 OOM 的类型和发生时间。登录监控平台,找到 OOM 发生前后那一段的应用日志和 GC 日志,在时间线上标记出异常起点。
第二步,对比监控曲线确认内存增长模式。打开 Prometheus 里的堆内存使用曲线,重点关注三个时间段:OOM 前 24 小时、前 1 小时、前 5 分钟。如果曲线是缓慢阶梯式上升,可能存在泄漏;如果是突然陡峭拉升,可能是大对象分配或流量突增。
第三步,导出 dump 并用 MAT 的 Leak Suspects 做初步判断。这一步只需要 5 分钟,你就能拿到一个候选的“嫌疑对象集合”。
第四步,使用 Dominator Tree 找到最大的支配树节点,看看这些大对象是业务对象还是框架对象。如果全是框架对象,比如 byte[]、char[]、String,说明底层有大批量数据在流转,要去网络传输层或序列化层找问题。
第五步,通过 Path to GC Roots 追踪引用链,定位到具体代码位置。找到 GC Roots 后,结合代码提交记录和业务理解判断:这个对象为什么该被回收而没有回收?它被谁持有?
第六步,回到代码修复,并在测试环境复现验证。修复后跑压测,对比修复前后的堆内存曲线,确认内存水位明显下降或保持平稳,才能算真正闭环。
4.2 实战案例:一次典型的 OOM 定位全过程
用一个真实案例把上面的流程串起来。有一段时间,我们一个订单查询服务的堆内存曲线在每天的 14:00-15:00 准时出现一次尖峰,持续时间大约 10 分钟,最后直接 OOM 重启。
当时团队的第一反应是“是不是定时任务在做全量数据加载”。查了 cron 配置,没有这个时间点的任务。第二反应是“是不是外部调用方在刷量”。查了 Nginx 日志,这个时间段的流量确实比其他时段高,但不至于到把 4G 堆内存打爆的程度。
后来在 MAT 里打开 dump,Leak Suspects 指向 java.util.ArrayList,Retained Heap 高达 2GB 多。沿着 Path to GC Roots 追踪,发现引用链最终指向的是一个电商营销活动页面的查询接口。代码逻辑大致是:从促销服务查询当前所有处于活动状态的商品 ID,然后再逐个查商品详情组装成页面数据。
看起来再正常不过的接口,为什么内存会爆?继续往底层看,发现营销服务返回的商品 ID 数量没有做上限控制,正常情况下有几百个,但当天某个运营配置了一个“全量商品参与活动”的规则,导致商品 ID 变成几十万个。接口拿到几十万个 ID 后,循环查详情、做聚合、拼装 VO,全在内存里操作,直接打爆堆。
这个案例的教训是:OOM 的根因往往不在你直接调用的那一层,而在上游数据层。所以排查时不要只盯自己服务里的代码,还要关注一次请求链路里数据量的变化。后来在网关层给这个接口加了一个商品数量的硬性限制,从源头挡住了。
4.3 没有 dump 文件时如何缩小范围
现实很骨感,不是每次 OOM 都恰好开着 HeapDump 参数。如果服务没有提前埋点,进程已经被容器杀了,dump 文件没有生成,这时候还可以抢救一下。
第一,去查容器被杀之前 5 分钟内的 GC 日志。GC 日志如果没开,那就查监控平台上的堆内存使用曲线。只要有监控数据,就能判断出是“突然暴涨型”还是“缓慢走高型”。
第二,查 OOM 之前有没有大量异常日志。比如数据库连接获取失败、外部接口超时、线程池拒绝任务的告警。这些异常往往和 OOM 有因果关系,可以辅助缩小范围。
第三,打开服务的 access log,看 OOM 前有哪些请求在处理。把当时正在执行的线程栈导出来看看(如果进程还没完全死掉,可以用 jstack <pid>),能看到是哪些业务逻辑占用着资源。
第四,实在什么都没有,就只能靠代码审查和经验猜了。这种情况下,我的建议是先保守地加内存、加副本,让服务撑住,再在测试环境模拟流量复盘,争取下一次 OOM 时能留下现场。
注意:没有 dump 的 OOM 排查,本质上是“带伤作战”,效率和成功率都会大打折扣。吃一堑长一智,事后必须把 HeapDump 参数补上,把监控补齐,保证下一次有完整的现场。
5. 常见问题速查:能别踩的坑尽量别踩
这节把线上 OOM 排查和预防中我高频遇到、以及团队反馈最多的问题整理出来,按场景分类,方便你照着查。
5.1 OOM 排查高频问题一览
| 问题现象 | 优先排查方向 | 参考方案 |
|---|---|---|
| 频繁 Full GC 后堆内存仍然上涨 | 大对象持续创建、集合类未清理 | 用 MAT 查 Dominator Tree 中 retained heap 最大的对象 |
| Metaspace OOM | 动态代理类、反射生成类、热部署 | 查 class loader 数量和类加载来源,增加 -XX:MaxMetaspaceSize 观察 |
| Direct buffer memory OOM | NIO/Netty 堆外内存泄漏 | 调整 -XX:MaxDirectMemorySize,结合 Netty 的堆外内存使用分析 |
| 容器被 OOMKilled 但 JVM 日志无异常 | 容器限制和 JVM 参数不匹配 | 核对 Pod 的 limits 和 JVM 的 -Xmx,保证堆内存预留足够余量 |
| 线程无法创建的 native thread OOM | 线程数超过系统 ulimit | 降低线程池上限、排查线程泄漏 |
| 大接口流量突增导致 OOM | 批量查询接口数据量失控 | 增加分页和数量限制,重点关注上游数据量波动 |
5.2 新手最容易犯的三个错误
第一个错误是只顾调大 -Xmx 而忽略了对内存增长模式的分析。把 4G 改成 8G,06 可能暂时不出现了,但下个月流量再涨一波,8G 也会被打爆。调参是治标,分析增长模式才是治本。
第二个错误是看 Histogram 只看类名,不看引用链。比如看到 byte[] 占内存第一,直接断定是 IO 缓存问题,实际上 byte[] 可能是某个业务对象内部的一个字段。正确的做法是选中这个 byte[],用 Path to GC Roots 找到真正持有它的业务对象。
第三个错误是利用 System.gc() 来“解决”问题。线上开着这个参数,等于每次调用都会触发一次全量 GC,GC 停顿能把服务卡死,同时它还会掩盖内存泄漏问题,让你误以为内存还能“清理”。正确的做法是找到引起泄漏的代码并修复。
5.3 一个容易遗漏的隐患:JDK 版本差异
不同的 JDK 版本对内存的管理策略有差异,尤其是 G1 和 ZGC 在不同版本里默认行为不同。比如 JDK 8 默认是 Parallel GC,JDK 11 默认是 G1,JDK 17 之后 G1 成为标配,ZGC 在部分版本里可以做实验性使用。如果你从 JDK 8 升级到 JDK 11 之后出现 OOM,先别怀疑内存泄漏,优先检查 GC 参数的兼容性和默认值的变化。我遇到过一次:升级 JDK 后,G1 的 region size 从原来的 1MB 变成了 2MB,某些大对象分配策略出现变化,诱导了老年代的空间分配异常。这种问题靠 MAT 很难直接看出来,需要对比新旧 JDK 的 GC 日志。
6. 特殊场景延伸:Spark OOM 的定位思路
前面主要围绕通用 Java 服务,但“OOM”这个词在数据计算领域同样高频出现。相关热词里就有好几个 Spark OOM 相关的话题。跑过数据任务的人都懂,Spark OOM 的定位逻辑和在线 Java 服务有一定差异,值得单独说几句。
6.1 Spark OOM 的本质区别
Spark 应用本身就是跑在 JVM 上的,所以前面讲的 MAT 分析方法在 executor 的 dump 文件上同样有效。但 Spark OOM 的根因通常不在某个对象的引用链上,而在数据分布上。
典型例子:某个 Spark SQL 任务做 join 操作,左表有 1 亿条数据,右表也不小,但 join 的 key 严重倾斜——80% 的数据都落在同一个 key 上。这个 key 对应的 reduce task 会拉取所有相关数据到同一个 executor 的内存里,其他 executor 却很空闲。这种数据倾斜导致的 OOM,你用 MAT 看 dump,看到的只是超大集合对象,真正的根因是 SQL 的执行计划和数据分布。
定位手段上,Spark 有专门的事件日志(Event Log)和 UI 界面。Spark UI 可以看到每个 stage 的 task 数、shuffle 读取量、executor 内存使用情况。如果发现某个 task 的 shuffle read 数据量远超其他 task,那就是倾斜了,OOM 的锅大概率落在数据分布上。
6.2 应对方向而不是死磕内存调参
应对 Spark OOM,应对方向有几种:一是调参,加大 executor 内存,但这种应对只适合短期缓解;二是改 SQL 或数据逻辑,比如对倾斜 key 加盐打散,让数据分配到不同 task 上;三是优化 shuffle 策略,比如使用 Broadcast Join 把小表广播到大表里去,避免大的 shuffle。有一个典型场景是做大表 join 小表时,小表明明只有 100MB,但因为 Spark 没有识别出来,仍然走了 SortMergeJoin,结果每个 executor 都拉了一大波 shuffle 数据。用 BroadcastJoin 之后,内存占用直接降了一半以上。
所以,Spark OOM 的排查思路要从“这个对象怎么泄漏了”转向“这批数据是怎么分布的”。分析对象从 dump 扩大到了执行计划和数据画像,用的工具也从 MAT 变成了 Spark UI 加 SQL 血缘。
7. 写在最后的一点个人体会
排查线上 OOM 越久,越有一个感受:整个团队最需要练的不是分析工具的使用技巧,而是面对故障时的判断力和纪律性。不用慌,按流程走,先留现场,再分析证据,最后修根因。压测发现内存异常就尽早处理,别等到线上告警了才去救火。工具和参数都是死的,但那套排查思路是活的。
最后再分享一个实操习惯:每次处理完一个 OOM,写一段简短的事故复盘,记录 OOM 类型、触发原因、分析路径、修复手段。下次再遇到类似问题,翻出历史记录对照,定位速度会快非常非常多。线上稳定运行从来不是靠运气,是靠每一次故障处理后的积累。
