📌 本文档系统梳理 JVM 内存溢出(OutOfMemoryError)的排查方法论,并结合一个真实的电商生产事故案例,完整还原从紧急止损 → 日志分析 → 堆快照定位 → 代码修复的全流程。
目录
- [一、OOM 排查总览](#一、OOM 排查总览)
- 二、第一步:紧急止损(生产环境第一优先级)
- [三、第二步:区分 OOM 类型(看错误日志)](#三、第二步:区分 OOM 类型(看错误日志))
- 四、第三步:深度分析(核心环节)
- [五、第四步:在线诊断(Arthas 神器)](#五、第四步:在线诊断(Arthas 神器))
- 六、第五步:修复与预防
- [七、真实案例复盘:缓存泄漏导致堆 OOM](#七、真实案例复盘:缓存泄漏导致堆 OOM)
- [7.1 案例背景](#7.1 案例背景)
- [7.2 Step 1:紧急止血与保留现场](#7.2 Step 1:紧急止血与保留现场)
- [7.3 Step 2:分析 GC 日志(寻找线索)](#7.3 Step 2:分析 GC 日志(寻找线索))
- [7.4 Step 3:MAT 分析堆快照(核心破案时刻)](#7.4 Step 3:MAT 分析堆快照(核心破案时刻))
- [7.5 Step 4:定位代码(Root Cause)](#7.5 Step 4:定位代码(Root Cause))
- [7.6 Step 5:在线验证(Arthas 辅助)](#7.6 Step 5:在线验证(Arthas 辅助))
- [7.7 Step 6:修复方案与预防](#7.7 Step 6:修复方案与预防)
- 八、排查口诀与总结
一、OOM 排查总览
JVM OOM(OutOfMemoryError)是线上事故的"头号杀手",排查的核心思路是:
保留现场 → 分析原因 → 定位代码 → 修复验证
整体流程如下:
线上告警
│
▼
紧急止损(摘流量、重启、保留快照)
│
▼
区分 OOM 类型(看错误日志关键字)
│
▼
深度分析(GC 日志 + MAT 堆快照)
│
├── Java heap space → 查 Dominator Tree + GC Roots 引用链
├── Metaspace → 查 Class 实例数 + 动态代理
├── Direct buffer → 查 Netty/ByteBuffer 分配
└── native thread → 查线程数 + 线程池配置
│
▼
定位代码 → 修复 → 验证 → 加监控
二、第一步:紧急止损(生产环境第一优先级)
当线上服务发生 OOM 时,应用通常会崩溃或假死。首先要做的是恢复服务,其次才是排查。
2.1 自动保存"案发现场"(事前配置)
在 JVM 启动参数中加入以下配置,让 JVM 在发生 OOM 时自动 dump 堆内存快照,并退出让容器编排工具自动拉起新实例:
bash
# 发生 OOM 时自动生成堆快照
-XX:+HeapDumpOnOutOfMemoryError
# 指定快照保存路径(确保目录存在且有写权限)
-XX:HeapDumpPath=/data/logs/heap_dump/${APP_NAME}_$(date +%Y%m%d_%H%M%S).hprof
# 可选:发生 OOM 后退出,配合 K8s 自动拉起新 Pod
-XX:OnOutOfMemoryError="kill -9 %p"
2.2 手动导出堆快照
如果没配置自动 dump,在 OOM 后立即执行(服务未重启前):
bash
# 导出堆快照(会触发 Full GC,服务会卡顿)
jmap -dump:format=b,file=emergency.hprof <pid>
# 查看堆内存概况
jmap -heap <pid>
# 查看对象实例统计(快速定位大对象类型)
jmap -histo <pid> | head -20
2.3 重启服务
如果是集群部署:
- 先从负载均衡(Nginx / K8s Service)中摘掉流量;
- 重启实例;
- 健康检查通过后重新挂载流量。
⚠️ 切记 :重启前一定要把
.hprof文件和gc.log拷贝到安全位置,重启后现场就没了!
三、第二步:区分 OOM 类型(看错误日志)
不同的 OOM 错误信息,指向的原因完全不同。先看日志里的 java.lang.OutOfMemoryError: 后面跟的内容:
| OOM 类型 | 含义 | 常见场景 |
|---|---|---|
| Java heap space | 堆内存不足 | 最常见。内存泄漏、大对象分配、堆大小设置过小 |
| Metaspace | 元空间不足 | 动态生成大量类(反射、CGLib、JSP 编译) |
| GC overhead limit exceeded | GC 效率极低 | 98% 以上时间在 GC,但回收效果甚微(<2% 堆空间),通常由内存泄漏引起 |
| Unable to create new native thread | 无法创建线程 | 线程数超限(OS 限制或 ulimit 限制),常因线程池滥用 |
| Direct buffer memory | 堆外内存不足 | NIO 使用不当,Netty 等框架 ByteBuffer 未释放 |
| Requested array size exceeds VM limit | 数组过大 | 尝试分配超过 JVM 限制的数组 |
四、第三步:深度分析(核心环节)
拿到堆快照(.hprof)后,使用工具进行分析。强烈推荐 MAT (Memory Analyzer Tool),它是 Eclipse 出品的专业内存分析工具,比 VisualVM 更强大。
MAT 下载地址:https://www.eclipse.org/mat/
4.1 场景 A:排查 Java heap space(最常见)
这是生产环境中最高频的 OOM 类型,排查步骤如下:
① 打开快照,查看泄漏疑点报告
用 MAT 打开 .hprof 文件,选择 Leak Suspects Report (泄漏疑点报告)。MAT 会自动列出疑似泄漏点,通常是一个占用内存极大的对象(如 HashMap、ArrayList)。
② 分析 Dominator Tree(支配树)
这是最关键的一步:
- 点击
Open Dominator Tree; - 按 Retained Heap (保留堆大小,即该对象被 GC 后能释放的总内存)降序排列;
- 找到排在第一位的对象。比如:
ConcurrentHashMap$Node[]占了 1.5GB。
Shallow Heap vs Retained Heap:
- Shallow Heap:对象自身占用的内存(不含引用的对象)。
- Retained Heap:对象自身 + 它直接/间接引用的所有对象的总内存。这是判断"谁最占内存"的核心指标。
③ 查看 GC Roots 引用链
右键该大对象 → Path To GC Roots → exclude weak references(排除弱引用)。
目的 :找出是谁还在引用这个大对象,导致它无法被回收。
典型结果:
ConcurrentHashMap$Node[]
→ productCache (field of ProductServiceImpl)
→ ProductServiceImpl (instance)
→ singletonObjects (field of DefaultSingletonBeanRegistry)
→ static reference (GC Root)
看到 static reference,说明这是一个静态引用链,GC 永远无法回收。
④ 查看代码
根据类名和方法名,去源码里确认逻辑(如缓存未设置过期时间、List 无限 add)。
4.2 场景 B:排查 Metaspace
① 现象
java.lang.OutOfMemoryError: Metaspace
② 排查命令
bash
# 查看类加载数量统计
jcmd <pid> GC.class_histogram | head -30
# 查看元空间使用情况
jstat -gcmetacapacity <pid>
③ 常见原因与定位
在 MAT 中查看 java.lang.Class 的实例数。如果某个类(如 $ProxyXXX、$$FastClass$$XXX)特别多,说明在频繁生成代理类。
常见坑:
- Spring AOP 切入点表达式写得过于宽泛,导致大量类被代理;
- Groovy / JavaScript 脚本引擎动态执行(每次执行生成新类);
- 热部署插件 Bug(如 JRebel、Spring DevTools);
- CGlib 动态生成子类未缓存。
④ 解决方向
bash
# 调大元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 排查类加载器泄漏
# 使用 MAT 查看 ClassLoader 的实例数和引用链
4.3 场景 C:排查 Unable to create new native thread
① 现象
java.lang.OutOfMemoryError: Unable to create new native thread
② 排查命令
bash
# 导出线程栈
jstack <pid> > stack.txt
# 统计线程数
cat stack.txt | grep "java.lang.Thread.State" | wc -l
# 查看 OS 线程限制
ulimit -u
# 查看进程的线程数
ps -eLf | grep <pid> | wc -l
③ 常见原因
- 线程池未复用(如每次请求
new Thread()); - 线程池队列无界,任务堆积导致线程数暴增;
- OS 的
max user processes限制过低; - 容器(Docker)的 PID 限制。
④ 解决方向
- 使用线程池并合理配置
corePoolSize、maximumPoolSize、workQueue; - 排查死锁和长时间阻塞的线程(
jstack看BLOCKED/WAITING状态); - 调大 OS 线程限制。
4.4 场景 D:排查 Direct buffer memory
① 现象
java.lang.OutOfMemoryError: Direct buffer memory
② 排查
bash
# 查看直接内存使用
jcmd <pid> VM.native_memory summary
# 代码中搜索 ByteBuffer.allocateDirect 的使用
③ 常见原因
- Netty / NIO 的
ByteBuffer未释放; - 直接内存默认与堆最大值一致,过度使用导致溢出。
④ 解决方向
bash
# 限制直接内存大小
-XX:MaxDirectMemorySize=512m
五、第四步:在线诊断(Arthas 神器)
如果不方便 dump 堆(文件太大),或者想实时观察 运行中的 JVM,可以使用 Arthas(阿里开源的 Java 诊断工具)。
5.1 安装与启动
bash
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar
5.2 常用排查命令
| 命令 | 作用 | 示例 |
|---|---|---|
dashboard |
实时仪表盘(内存、GC、线程) | dashboard |
heapdump |
在线 dump 堆快照 | heapdump /tmp/dump.hprof |
ognl |
查看/修改运行时变量 | ognl '@com.xxx.Cache@map.size()' |
trace |
追踪方法调用链和耗时 | trace com.xxx.Service * '#cost > 100' |
watch |
观察方法入参/返回值 | watch com.xxx.Service getProduct '{params, returnObj}' |
jvm |
查看 JVM 信息 | jvm |
thread |
查看线程信息 | thread -n 10(TOP 10 繁忙线程) |
5.3 实战示例
bash
# 查看缓存 Map 的大小
$ ognl '@com.xxx.product.service.ProductServiceImpl@productCache.size()'
@Integer[125023]
# 等待 10 分钟后再看
$ ognl '@com.xxx.product.service.ProductServiceImpl@productCache.size()'
@Integer[356890]
# 追踪哪个方法在创建大对象
$ trace com.xxx.service.* * '#cost > 50'
# 查看 JVM 内存分布
$ jvm | grep -A 5 "HEAP"
六、第五步:修复与预防
6.1 代码修复
| 问题 | 修复方案 |
|---|---|
| 本地缓存无淘汰 | 使用 Guava Cache / Caffeine,设置 maximumSize + expireAfterWrite |
| 集合无限 add | 预估初始容量,设置上限,定期清理 |
| 资源未关闭 | 使用 try-with-resources,确保 IO 流、DB 连接、Redis 连接在 finally 中关闭 |
| 线程池滥用 | 使用有界队列 + 拒绝策略,避免无限制创建线程 |
6.2 JVM 调优
bash
# 堆内存(根据物理内存合理设置)
-Xms4g -Xmx4g # 初始和最大堆一致,避免动态扩展开销
# 元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 直接内存
-XX:MaxDirectMemorySize=512m
# GC 日志(排查必备)
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/data/logs/gc.log
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# OOM 自动 dump(强烈建议所有服务都配上)
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heap_dump/
6.3 监控告警
推荐方案:Prometheus + Grafana + JMX Exporter
| 监控指标 | 告警阈值建议 |
|---|---|
| 堆使用率(Heap Usage) | > 85% 持续 5 分钟告警 |
| Full GC 频率 | > 1 次/分钟告警 |
| Full GC 停顿时间 | 单次 > 3s 告警 |
| 元空间使用率 | > 80% 告警 |
| 线程数 | > 2000 告警 |
| 缓存命中率 | < 90% 告警 |
七、真实案例复盘:缓存泄漏导致堆 OOM
7.1 案例背景:大促期间的"慢刀子"割血
| 项目 | 详情 |
|---|---|
| 业务场景 | 电商商品详情页服务(QPS 约 5000) |
| 技术栈 | Spring Boot 2.x + MyBatis + MySQL |
| JVM 配置 | -Xmx4g -Xms4g -XX:+UseG1GC |
| 部署方式 | K8s 集群,8 个 Pod |
现象:
- 每次大促开始后的 20~30 分钟,服务响应变慢;
- 紧接着频繁 Full GC,接口 RT 从 50ms 飙升至 2000ms+;
- 最终抛出
java.lang.OutOfMemoryError: Java heap space,Pod 崩溃重启; - 重启后 20 分钟再次复现,形成"死亡循环"。
7.2 Step 1:紧急止血与保留现场
操作时间线:
14:00 监控系统报警,接口 RT > 2000ms
14:01 查看 Grafana,老年代使用率 98%,Full GC 每分钟 5 次
14:02 K8s 自动重启了 2 个 Pod,但新 Pod 也迅速 OOM
14:03 运维手动将问题 Pod 从 Service 摘除
14:05 检查 /data/logs/heap_dump/,发现自动 dump 的 .hprof 文件(3.8GB)
14:06 保留 .hprof 和 gc.log,重启 Pod 恢复业务
✅ 幸亏提前配置了
-XX:+HeapDumpOnOutOfMemoryError,否则重启后现场丢失,只能等下次复现。
7.3 Step 2:分析 GC 日志(寻找线索)
查看 gc.log 关键片段:
log
[2024-11-11T14:02:15.123+0800] [Full GC (Allocation Failure)
[PSYoungGen: 0K->0K(1048576K)]
[ParOldGen: 4086784K->4080123K(4194304K)]
4086784K->4080123K(5242880K),
[Metaspace: 85632K->85632K(1107968K)]
[Times: user=12.34 sys=0.01, real=12.45 secs]
[2024-11-11T14:02:30.456+0800] [Full GC (Allocation Failure)
[PSYoungGen: 0K->0K(1048576K)]
[ParOldGen: 4080123K->4082345K(4194304K)]
4080123K->4082345K(5242880K),
[Times: user=13.12 sys=0.02, real=13.56 secs]
关键解读:
| 指标 | 数值 | 含义 |
|---|---|---|
| 回收前老年代 | 4086784K (~3.9GB) | 几乎满了 |
| 回收后老年代 | 4080123K (~3.88GB) | 只回收了 6MB |
| Full GC 耗时 | 12~13 秒 | 每次 STW 十几秒 |
| 趋势 | 回收后反而更大 | 对象在持续增长 |
结论 :这不是普通的内存抖动,而是典型的内存泄漏。对象有强引用,GC 根本清不掉,每次 Full GC 只是徒劳。
7.4 Step 3:MAT 分析堆快照(核心破案时刻)
① 打开 Leak Suspects Report
MAT 自动分析结果:
Problem Suspect 1:
The class com.xxx.product.service.ProductServiceImpl
occupies 3,200,000,000 (82.3%) bytes.
The memory is accumulated in one instance of
java.util.concurrent.ConcurrentHashMap$Node[]
82.3% 的堆内存被一个对象占用了!
② 深入 Dominator Tree
展开 ProductServiceImpl 的引用链:
ProductServiceImpl (Shallow: 64B, Retained: 3.2GB)
└── productCache: ConcurrentHashMap (Retained: 3.2GB)
└── table: Node[] (length = 4194304)
├── Node (key=100001, value=ProductInfo)
├── Node (key=100002, value=ProductInfo)
├── ...
└── Node (key=5847291, value=ProductInfo)
Map 中有 400 多万个 Entry! 而正常商品总数只有约 100 万。
③ 查看 Path To GC Roots
右键 productCache → Path To GC Roots → exclude weak/soft references:
productCache (ConcurrentHashMap)
← productCache (field) ← ProductServiceImpl (instance)
← singletonObjects (map value) ← DefaultSingletonBeanRegistry
← applicationContext (field) ← static reference (GC Root)
完整的引用链清晰地展示了为什么这个 Map 无法被回收:
它是 Spring 单例 Bean 的成员变量 → 被 Spring 容器持有 → 容器是 static 引用 → GC Roots 可达 → 永远无法回收
7.5 Step 4:定位代码(Root Cause)
拿着 MAT 给出的类名,去源码中定位:
java
@Service
public class ProductServiceImpl implements ProductService {
// ❌ 罪魁祸首:裸 ConcurrentHashMap 做本地缓存
private final Map<Long, ProductInfo> productCache = new ConcurrentHashMap<>();
@Override
public ProductInfo getProductDetail(Long productId) {
// 先查缓存
ProductInfo info = productCache.get(productId);
if (info == null) {
// 缓存未命中,查数据库
info = productDao.queryById(productId);
if (info != null) {
// 放入缓存 ------ 但没有任何淘汰机制!
productCache.put(productId, info);
}
}
return info;
}
}
Bug 分析:
| 问题 | 说明 |
|---|---|
| 没有淘汰机制 | 只 put,不 remove,也不设置 TTL |
| 大促流量冲击 | 大量长尾商品、爬虫请求、运营临时活动商品涌入 |
| Key 无限增长 | 正常商品 100 万,但长尾请求带来几百万不同的 productId |
| 单例生命周期 | ProductServiceImpl 是 Spring 单例,Map 随服务一生 |
7.6 Step 5:在线验证(Arthas 辅助)
在预发环境验证我们的猜想:
bash
# 连接 Arthas
$ java -jar arthas-boot.jar
# 查看缓存大小
$ ognl '@com.xxx.product.service.ProductServiceImpl@productCache.size()'
@Integer[125023] # 刚启动时是 12 万,符合预期
# 等待 10 分钟后再看
$ ognl '@com.xxx.product.service.ProductServiceImpl@productCache.size()'
@Integer[356890] # 10 分钟涨了 23 万!
# 再等 10 分钟
$ ognl '@com.xxx.product.service.ProductServiceImpl@productCache.size()'
@Integer[582341] # 增速没有减缓的趋势
结论确认:缓存确实在疯狂增长,且没有任何清理机制。
7.7 Step 6:修复方案与预防
① 紧急修复(Hotfix)
引入 Caffeine(Spring Boot 2.x 默认推荐的本地缓存库):
java
@Service
public class ProductServiceImpl implements ProductService {
// ✅ 使用 Caffeine 替代裸 Map
private final Cache<Long, ProductInfo> productCache = Caffeine.newBuilder()
.maximumSize(200_000) // 最多 20 万条记录
.expireAfterWrite(30, TimeUnit.MINUTES) // 写入后 30 分钟过期
.recordStats() // 开启统计
.build();
@Override
public ProductInfo getProductDetail(Long productId) {
return productCache.get(productId, id -> productDao.queryById(id));
}
}
效果对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 堆内存占用 | 持续增长至 OOM | 稳定在 1.2GB 左右 |
| Full GC 频率 | 每分钟 5 次 | 每小时 < 1 次 |
| 接口 P99 RT | 2000ms+ | 80ms |
| 缓存命中率 | 无法统计 | 96.5% |
② 架构优化
将本地缓存迁移至 Redis 集中式缓存:
- 多个服务实例共享缓存,避免各实例重复缓存;
- 重启不丢失(本地缓存重启即失效,导致冷启动数据库被打爆);
- 利用 Redis 的 LRU / LFU 淘汰策略;
- 支持主动失效(商品更新时
DEL cache:key)。
③ 监控补充
yaml
# Prometheus 监控指标
- name: jvm_memory_used_bytes
labels: { area: "heap", id: "G1 Old Gen" }
alert: > 85%
- name: jvm_gc_pause_seconds_count
labels: { action: "end of major GC" }
alert: rate > 1/min
- name: cache_size
labels: { cache: "productCache" }
alert: size > 200000
八、排查口诀与总结
8.1 排查口诀
一看日志定类型,二开 MAT 查支配;
三追 GC Roots 链,四查代码修逻辑;
五配监控防未然,Arthas 助实时。
8.2 核心经验
- 永远不要手写缓存 :千万不要用
HashMap或ConcurrentHashMap做本地缓存,除非你能 100% 保证 Key 是有限的且有清理机制。请用 Caffeine / Guava Cache / Redis。 - OOM 自动 dump 是标配 :
-XX:+HeapDumpOnOutOfMemoryError应该配在所有生产环境 JVM 参数中,这是事后排查的唯一救命稻草。 - GC 日志是"黑匣子":GC 日志体积小、信息量大,能告诉你"发生了什么",比直接看堆快照更高效。
- 单例陷阱 :Spring 的
@Service默认是单例的,里面的成员变量生命周期极长,放大数据结构一定要小心。 - MAT 三板斧:Leak Suspects → Dominator Tree → Path To GC Roots,90% 的堆 OOM 都能通过这三步定位。
8.3 工具箱速查
| 工具 | 用途 | 适用场景 |
|---|---|---|
| MAT | 离线分析堆快照 | 内存泄漏深度分析(最强大) |
| VisualVM | 可视化监控 + 快照分析 | 本地开发 / 小规模服务 |
| Arthas | 在线诊断(无需重启) | 生产环境实时排查 |
| jmap | 导出堆快照 / 对象统计 | 紧急时刻手动 dump |
| jstack | 导出线程栈 | 线程死锁 / 阻塞排查 |
| jstat | GC 统计(实时) | 观察 GC 频率和耗时 |
| Prometheus + Grafana | 长期监控 + 告警 | 全链路 JVM 指标可视化 |
💡 最后的建议:排查 OOM 就像破案,日志是目击者,堆快照是案发现场,MAT 是你的放大镜。保持冷静,按图索骥,真相只有一个。