JVM OOM 排查与真实案例复盘

📌 本文档系统梳理 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 重启服务

如果是集群部署:

  1. 先从负载均衡(Nginx / K8s Service)中摘掉流量
  2. 重启实例;
  3. 健康检查通过后重新挂载流量。

⚠️ 切记 :重启前一定要把 .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 会自动列出疑似泄漏点,通常是一个占用内存极大的对象(如 HashMapArrayList)。

② 分析 Dominator Tree(支配树)

这是最关键的一步

  1. 点击 Open Dominator Tree
  2. Retained Heap (保留堆大小,即该对象被 GC 后能释放的总内存)降序排列
  3. 找到排在第一位的对象。比如:ConcurrentHashMap$Node[] 占了 1.5GB。

Shallow Heap vs Retained Heap

  • Shallow Heap:对象自身占用的内存(不含引用的对象)。
  • Retained Heap:对象自身 + 它直接/间接引用的所有对象的总内存。这是判断"谁最占内存"的核心指标。
③ 查看 GC Roots 引用链

右键该大对象 → Path To GC Rootsexclude 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 限制。
④ 解决方向
  • 使用线程池并合理配置 corePoolSizemaximumPoolSizeworkQueue
  • 排查死锁和长时间阻塞的线程(jstackBLOCKED / 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 诊断工具)。

官网:https://arthas.aliyun.com/

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

右键 productCachePath To GC Rootsexclude 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 核心经验

  1. 永远不要手写缓存 :千万不要用 HashMapConcurrentHashMap 做本地缓存,除非你能 100% 保证 Key 是有限的且有清理机制。请用 Caffeine / Guava Cache / Redis。
  2. OOM 自动 dump 是标配-XX:+HeapDumpOnOutOfMemoryError 应该配在所有生产环境 JVM 参数中,这是事后排查的唯一救命稻草。
  3. GC 日志是"黑匣子":GC 日志体积小、信息量大,能告诉你"发生了什么",比直接看堆快照更高效。
  4. 单例陷阱 :Spring 的 @Service 默认是单例的,里面的成员变量生命周期极长,放大数据结构一定要小心。
  5. 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 是你的放大镜。保持冷静,按图索骥,真相只有一个。

相关推荐
音符犹如代码9 小时前
Arthas Profiler 火焰图实战:CPU 热点在哪一目了然
java·jvm·spring boot
麻瓜code12 小时前
JVM 运行时数据区——把内存五块讲清楚
jvm
Mark_ZP1 天前
CMS 垃圾回收器配置详解
jvm
北风toto1 天前
LangGraph 深度使用教程与总结
jvm
井川廊咏1 天前
初探性能优化——2个月到4小时的性能提升
jvm·数据库·性能优化
程序员天天困1 天前
Arthas Profiler 火焰图实战:CPU 热点在哪一目了然
java·jvm·后端
Co_Hui2 天前
JVM vs. DVM vs. ART
jvm
笨蛋不要掉眼泪2 天前
Java虚拟机:对象复活、引用强度与Stop-The-World
java·开发语言·jvm
吃饱了得干活2 天前
JVM垃圾回收:从新生代到ZGC,从理论到调优
java·jvm·后端