📌 本文档系统梳理 JVM 内存溢出(OutOfMemoryError)的排查方法论,并结合一个真实的电商生产事故案例,完整还原从紧急止损 → 日志分析 → 堆快照定位 → 代码修复的全流程。


目录


一、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 堆内存快照,并退出让容器编排工具自动拉起新实例:

# 发生 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 后立即执行(服务未重启前):

# 导出堆快照(会触发 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 exceededGC 效率极低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
② 排查命令
# 查看类加载数量统计
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 动态生成子类未缓存。
④ 解决方向
# 调大元空间
-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
② 排查命令
# 导出线程栈
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
② 排查
# 查看直接内存使用
jcmd <pid> VM.native_memory summary

# 代码中搜索 ByteBuffer.allocateDirect 的使用
③ 常见原因
  • Netty / NIO 的 ByteBuffer 未释放;
  • 直接内存默认与堆最大值一致,过度使用导致溢出。
④ 解决方向
# 限制直接内存大小
-XX:MaxDirectMemorySize=512m

五、第四步:在线诊断(Arthas 神器)

如果不方便 dump 堆(文件太大),或者想实时观察运行中的 JVM,可以使用 Arthas(阿里开源的 Java 诊断工具)。

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

5.1 安装与启动

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 实战示例

# 查看缓存 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 调优

# 堆内存(根据物理内存合理设置)
-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 关键片段:

[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 给出的类名,去源码中定位:

@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 辅助)

在预发环境验证我们的猜想:

# 连接 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 默认推荐的本地缓存库):

@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 RT2000ms+80ms
缓存命中率无法统计96.5%
② 架构优化

将本地缓存迁移至 Redis 集中式缓存

  • 多个服务实例共享缓存,避免各实例重复缓存;
  • 重启不丢失(本地缓存重启即失效,导致冷启动数据库被打爆);
  • 利用 Redis 的 LRU / LFU 淘汰策略;
  • 支持主动失效(商品更新时 DEL cache:key)。
③ 监控补充
# 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导出线程栈线程死锁 / 阻塞排查
jstatGC 统计(实时)观察 GC 频率和耗时
Prometheus + Grafana长期监控 + 告警全链路 JVM 指标可视化

💡 最后的建议:排查 OOM 就像破案,日志是目击者,堆快照是案发现场,MAT 是你的放大镜。保持冷静,按图索骥,真相只有一个。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐