Java OOM 排查完整指南:从告警到根因,MAT 堆分析全流程实战

Java OOM 排查完整指南:从告警到根因,MAT 堆分析全流程实战

线上服务突然 CPU 100%,jstat 一看 FGC 每分钟几十次------大概率 OOM 了。本文覆盖从收到告警到 MAT 定位根因的完整链路,含两个真实生产案例(全量查询 28 万条 + MQ 并发全表 159 万条),适合有 1-3 年经验的 Java 后端开发者。


全局流程图

css 复制代码
告警:CPU 100% / 服务卡死 / OOM 报错
  │
  ├─ 第一步:判断是不是 OOM(jstat 看 GC)
  │    ├─ FGC 暴增、FGCT 耗时长 → 大概率 OOM → 继续
  │    └─ GC 正常 → 不是 OOM,走 CPU 排查路径(本文不覆盖)
  │
  ├─ 第二步:确认 OOM 类型(看报错信息)
  │    ├─ Java heap space / GC overhead → 方向 A(堆问题)
  │    ├─ Metaspace                    → 方向 B(类元数据问题)
  │    ├─ Direct buffer memory          → 方向 C(堆外内存问题)
  │    └─ unable to create native thread → 方向 D(线程数问题)
  │
  ├─ 第三步:按方向选择工具和排查路径
  │
  └─ 第四步(方向A):MAT 分析 hprof,三步法定位根因

第一阶段:从现象发现 OOM

线上最常见的 OOM 场景不是"直接看到 OutOfMemoryError 日志",而是服务突然卡死或 CPU 飙到 100%。因为 OOM 发生后 JVM 会疯狂做 Full GC 试图回收内存,GC 线程吃满 CPU,业务线程几乎停滞。

1.1 第一个命令:jstat

bash 复制代码
jstat -gcutil <pid> 1000 5

Docker 环境 :PID 通常是 1,但有时不是。先用 jpsps aux | grep java 确认。

输出示例:

复制代码
  S0     S1     E      O      M     CCS    YGC   YGCT   FGC  FGCT   GCT
  0.00  98.72  45.61  99.87  95.23  92.11   127   1.234    5   2.567   3.801

看什么

含义 危险信号
O 老年代占用率 > 90% 且 Full GC 后不降 → 泄漏
FGC Full GC 次数 短时间内飙升 → 紧急信号
FGCT Full GC 总耗时 单次 > 1秒 / FGCT 占 GCT > 50% → OOM 导致
M Metaspace 占用率 > 95% → 可能是 Metaspace 问题

判断标准

指标 正常 OOM 嫌疑
FGC 频率 几分钟甚至几小时一次 每分钟 > 10 次
FGCT 占比 < 20% > 50%
O 区 GC 后能降到 70% 以下 GC 后仍 > 90%

如果确认是 OOM → 进入第二阶段。如果 GC 正常 → 不是 OOM,走 CPU 排查路径。

1.2 第二个命令:jmap -heap(看堆配置和使用率)

确认是 OOM 后,先看堆的整体配置和各代使用情况:

bash 复制代码
jmap -heap <pid>

输出示例(关键部分):

ini 复制代码
Heap Configuration:
   MaxHeapSize              = 4294967296 (4096.0 MB)    ← -Xmx
   NewSize                  = 872415232 (832.0 MB)      ← 年轻代
   MaxNewSize               = 872415232 (832.0 MB)
   OldSize                  = 3422552064 (3264.0 MB)    ← 老年代
   MaxMetaspaceSize         = 536870912 (512.0 MB)

Heap Usage:
PS Old Generation
   capacity = 3422552064 (3264.0 MB)
   used     = 3210752256 (3061.8 MB)
   93.80% used                                          ← 危险信号!

重点看什么

区域 看什么 危险信号
MaxHeapSize Xmx 配了多少 值是否合理
MaxNewSize 年轻代上限 太小 → 对象快速晋升老年代
MaxMetaspaceSize 元空间上限 太小 → 容易 Metaspace OOM
Old Generation % used 老年代使用率 > 90% 且持续增长 → 泄漏

常见配置问题:

配置 问题
-Xms/-Xmx 过小 堆不够,频繁 GC
-Xmn(年轻代)过小 对象直接进老年代,老年代很快满
-XX:MaxMetaspaceSize 过小 类加载多了就 Metaspace OOM
-Xss 过大 + 线程数多 栈内存吃光系统资源

如果配置明显不合理 → 先调配置,可能不需要分析代码。

⚠️ jmap -heap 在 JDK 9+ 中被 jcmd <pid> GC.heap_info 替代,功能等价。

1.3 第三个命令:jmap -histo(看堆里有什么对象)

确认堆配置合理、老年代确实满了之后,用 jmap -histo 看是什么对象占了内存:

bash 复制代码
jmap -histo <pid> | head -50

⚠️ jmap -histo:live 会触发一次 Full GC,CPU 已经 100% 时慎用。建议先不带 :live 看总数。

怎么看

  • 前几名有大量业务对象(某 VO、DTO、List) → 内存泄漏点
  • 全是 byte[]char[]String → 可能是大对象或字符串拼接问题
  • 前几名是 $Proxy$$EnhancerByCGLIB$$ → 可能是 Metaspace 问题

1.4 OOM 后还能执行 jmap 吗?

通常不能。OOM 后 JVM 可能已部分挂起,jmap 会报错或卡住。

正确做法:提前配置 JVM 参数,让 OOM 发生时自动生成 dump 文件:

bash 复制代码
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/app/dumps/java_pid<pid>.hprof

Docker 环境的坑

容器重启后 dump 文件会丢失!必须用 Volume 映射:

Docker

bash 复制代码
docker run -d \
  -v /data/app-dumps:/app/dumps \
  -e JAVA_OPTS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/app/dumps/" \
  your-image

K8s

yaml 复制代码
volumes:
  - name: dump-volume
    persistentVolumeClaim:
      claimName: app-dump-pvc
volumeMounts:
  - name: dump-volume
    mountPath: /app/dumps

部署后验证

bash 复制代码
jcmd <pid> GC.heap_dump /app/dumps/test.hprof
ls -lh /app/dumps/
# 宿主机上检查映射目录是否同步出现文件

⚠️ dump 文件接近 Xmx 大小,确保磁盘空间充足。


第二阶段:确认 OOM 类型

2.1 五种主要 OOM 类型速查

报错关键词 问题区域 需要 MAT? 排查方向
Java heap space Java 堆 MAT 找大对象
GC overhead limit exceeded Java 堆(晚期) 同 heap space
Metaspace 类元数据 jcmd 看类数量
Direct buffer memory 堆外内存 检查 Netty/NIO
unable to create native thread 线程栈 减线程 / 调 Xss

核心原则:先定类型再选工具。不是所有 OOM 都要开 MAT------Metaspace、Direct buffer、native thread 用 MAT 是浪费时间。

2.2 判断堆大小是否合理

开 MAT 前先回答三个问题:

Xmx 设了多少?

bash 复制代码
jcmd <pid> VM.flags | grep -i heap
Xmx 判断
< 1G 通常偏小,生产至少 2G
1G-4G 中小应用合理
4G-8G 确认是否真需要
> 8G 考虑 GC 停顿,可能需要 G1/ZGC

增长还是稳定?

arduino 复制代码
缓慢上升型(泄漏)           突发型(大查询)           锯齿型(边界不够)
╱╱╱╱╱╱╱╱╱╱╱╱ OOM         ────────╱╱ OOM          ╱╲╱╲╱╲╱╲╱ OOM
老年代持续增长               之前稳定,瞬间飙高          GC 能回收但峰值超 Xmx
找"谁在累积"                找"谁在爆发"              加内存 or 优化峰值

第三阶段:MAT 分析 hprof(堆问题专用)

以下仅适用于 Java heap space / GC overhead limit exceeded 两种类型。

3.1 MAT 三步法

第一步:Leak Suspects(看堆栈)

打开 .hprof → Overview → Leak Suspects → Thread Stack 区域

目的:确认哪个线程在 OOM 时执行什么,拿到完整调用链

css 复制代码
Thread: http-nio-8080-exec-34
调用链:
  Controller.billingQuery()                  ← 入口
    ServiceImpl.billingQuery()                ← 业务方法 (第 1755 行)
      XxxDAO.findByCondition()                ← 数据查询
        BaseDAO.convertMapsToBeans()           ← Map→Bean 转换(内存热点)

第二步:Top Consumers(看谁占大头)

菜单栏 → top_consumers_html 标签页

占比 结论
> 50% 绝对大头,OOM 主因
10%~50% 重要贡献者,需结合其他嫌疑点
< 10% 最后一根稻草,需找更大对象

第三步:Dominator Tree(两种方式选一种)

方式 A --- 直接展开(优先尝试)

点击最大对象的展开箭头,逐层展开:

scss 复制代码
TaskThread (2.7 GB)              ← 第 0 层
  └─ ArrayList (2.7 GB)          ← 第 1 层
      └─ Object[285824]          ← 第 2 层
          └─ BillingVo × 28万  ← 第 3 层:定位完成
  • ✅ 前 2~3 层看到大对象 → 方式 A 成功
  • ❌ 展开多层都是小对象 / <Java Local> → 切方式 B
方式 B --- Outgoing References(方式 A 失败时用)
sql 复制代码
右键 TaskThread
  → List objects → with outgoing references
    → 按 Retained Heap 列排序(降序)
      → 第一个/前几个就是大对象

适用场景:大对象在 ThreadLocal / 静态变量 / Runnable 字段中,支配树层级很深

3.2 Outgoing vs Incoming References

维度 Outgoing References Incoming References
方向 我 → 我引用的对象 引用我的对象 → 我
问的问题 "我里面有什么?" "谁在持有我?"
典型场景 确认大对象内容 / 找深层隐藏对象 反查引用链 / 确认为何对象无法 GC
排查阶段 定位阶段------找"什么东西占了内存" 确认阶段------找"为什么它没被回收"

一句话:Outgoing 往外看"我有什么",Incoming 往回看"谁抓着我"。


实战案例

案例 1:全量查询 OOM(浅层泄漏,方式 A 成功)

现象 :生产环境 java.lang.OutOfMemoryError: Java heap space

分析过程

ini 复制代码
Step1 - Leak Suspects:
  线程 http-nio-8080-exec-34 正在执行
  BillingServiceImpl.billingQuery() 第 1755 行

Step2 - Top Consumers:
  TaskThread @ 0x71fdc5d00 Retained Heap = 2,713,654,144 (77.47%)

Step3 - Dominator Tree (方式A):
  TaskThread (2.7 GB)
    └─ ArrayList @ 0x7250ca4a8 (2.7 GB)
        └─ Object[285824] @ 0x73442a228 (2.7 GB)  ← 28万条数据
            └─ BillingVo × 285,824

根因findByCondition() 无分页限制,一次性返回 28 万条数据撑爆堆内存。

修复

  1. 查询增加分页参数(LIMIT)
  2. 或改用 MyBatis Cursor/ResultHandler 流式读取
  3. 缩小事务范围,避免事务内持有大对象

案例 2:MQ 消费线程全表查询 OOM(并发叠加 + JDBC 半成品识别)

现象 :某业务服务 java.lang.OutOfMemoryError,通过 MAT 分析堆转储文件定位。

分析步骤

Step 1 --- Leak Suspects:MAT 识别出两个 Problem Suspect:

  • Problem Suspect 1 :Thread __2 持有 1,406,970,584 bytes(46.99%)
  • Problem Suspect 2 :Thread __1 同样持有大块内存

两个线程名均为 RocketMQ 消费线程,对应同一个业务入口 syncLoan

Step 2 --- Dominator Tree(方式 A)

ini 复制代码
java.lang.Thread @ 0x74ddcdf10 (1.4 GB)
  → java.util.ArrayList @ 0x74ddce258 (1.4 GB)
    → java.lang.Object[1823230] @ 0x705785ae8 (1.4 GB)  ← 容量 182 万
      → com.mysql.cj.protocol.a.result.ByteArrayRow × 1,593,238

📸 截图 1:Dominator Tree 视图 展开 Thread → ArrayList → Object\[\] 的层级结构,定位到 159 万条 ByteArrayRow

Step 3 --- Outgoing References + Inspector 确认数据内容

对 Object\[\] 右键 → List objects → with outgoing references → 按 Retained Heap 降序排列 → 确认大对象是 ByteArrayRow。

📸 截图 2:List Objects → with outgoing references 视图 扁平化查看 Object\[\] 中每个元素,确认 159 万条 ByteArrayRow

选中某个 ByteArrayRow → Inspector → Value 标签页 → 解码 internalRowData (byte[30][]),确认数据来自 某业务信息表

📸 截图 3:Inspector 面板查看 ByteArrayRow 列数据 internalRowData 解码后可看到列值:ID、日期、金额(97.98 万)、业务流水号等

Step 4 --- Thread Stack 确认代码路径

scss 复制代码
LoanService.syncLoan() (line 114)
  → LoanInfoDAO.queryList(LoanInfoEntity) (line 42)
    → Mapper.selectByExample(Object)
      → MyBatis → Druid → MySQL JDBC
        → TextResultsetReader.read() ← 1.4GB Object[] 在此产生

根因分析

层级 对象 Retained Heap 说明
java.lang.Thread 1,406,970,584 RocketMQ 消费线程
java.util.ArrayList 1,406,906,048 MyBatis selectList 返回容器
Object1823230 1,406,906,024 ArrayList 内部数组,容量 182 万
ByteArrayRow × 1,593,238 ~992 each MySQL 驱动层行数据,共 159 万条

并发叠加效应 :两个消费线程同时执行同一查询,内存翻倍至 ~2.8GB

对比项 Thread __1 Thread __2
内存占用 大块(与 __2 叠加) 1,406,970,584 bytes (46.99%)
栈顶状态 NativePacketPayload.readBytes() --- 正在读网络字节 TextRowFactory.createFromMessage() --- 已在构建 ResultRow
阶段 更早期 更后期

根因

LoanInfoDAO.queryList() 使用 selectByExample 无分页查询,返回全表 159 万条数据,单线程占用 1.4GB。两个 RocketMQ 消费线程并发执行,总内存 ~2.8GB。

修复

  1. queryList() 加充分过滤条件,避免全表扫描
  2. 大数据量改用 分页(PageHelper) 或 MyBatis Cursor 流式读取
  3. 分批处理,每批处理完释放内存
  4. RocketMQ 消费加 幂等保护(分布式锁/消费限流),避免并发叠加

案例特有知识点

知识点 说明
ByteArrayRow 半成品 看到的是 JDBC 驱动层原始行数据,MyBatis 映射还没完成。不要等看到业务实体------结合 Thread Stack 定位业务代码
Outgoing + Inspector 方式A找到大对象后,用 Outgoing References 展开,Inspector 解码 internalRowData 确认是哪张表
MQ 并发叠加 两个消费线程同时执行同一查询,内存翻倍。修复时除加查询条件外,必须检查消费幂等性

附录:OOM 排查 Checklist

css 复制代码
□ 1. jstat 看 GC → 确认是不是 OOM
□ 2. 看报错信息 → 确定 OOM 类型
□ 3. 看 JVM 参数 → 排除配置问题
□ 4. 根据类型选工具:
     heap space / GC overhead → MAT
     Metaspace               → jcmd
     Direct buffer           → JMX / Netty 监控
     native thread           → 线程数检查
□ 5. MAT 三步法:Leak Suspects → Top Consumers → Dominator Tree
□ 6. 方式 A 失败 → 切方式 B Outgoing References
□ 7. 定位根因后:修复 + 确认 JVM 已配 HeapDumpOnOutOfMemoryError
□ 8. Docker/K8s 环境:确认 dump 目录已 Volume 映射

五分钟快速决策图

perl 复制代码
收到告警:CPU 100% / 服务卡死 / OOM 报错
│
├─ 1分钟:jstat -gcutil <pid> 1000 5
│    ├─ FGC 暴增 + O 区 > 90% → 是 OOM → 继续
│    └─ GC 正常 → 不是 OOM → 转 CPU 排查
│
├─ 30秒:看日志确认 OOM 类型
│    ├─ "Java heap space" / "GC overhead" → MAT 分析
│    ├─ "Metaspace" → jcmd GC.class_histogram
│    ├─ "Direct buffer" → 检查 Netty ByteBuf release
│    └─ "native thread" → jcmd Thread.print | wc -l
│
├─ 30秒:检查 JVM 配置
│    ├─ Xmx 明显偏小 → 先调配置
│    └─ 配置正常 → 继续分析
│
├─ 2分钟:获取 dump 文件
│    ├─ dump 存在 → 下载到本地
│    └─ dump 不存在 → 配置 HeapDumpOnOutOfMemoryError,等下次
│
└─ 1分钟:MAT 三步法(离线分析)
     ├─ Leak Suspects → 看堆栈
     ├─ Top Consumers → 看谁占大头
     └─ Dominator Tree → 方式 A 或 B 定位根因

核心原则:先定类型、再选工具。不是所有 OOM 都要开 MAT------Metaspace、Direct buffer、native thread 用 MAT 是浪费时间。先判断,再行动。


如果这篇文章对你有帮助,欢迎点赞收藏。有任何 OOM 排查相关的问题,欢迎在评论区交流。

相关推荐
要开心吖ZSH2 小时前
AI医疗分诊与健康咨询助手agent开发——(0)项目背景与概要
java·ai·agent·健康医疗·rag
后青春期的诗go2 小时前
泛微OA-E9与第三方系统集成开发企业级实战记录(十五)
java·泛微·集成开发·e9
吃口巧乐兹3 小时前
理解 Agent 中的 Slash Command:从概念到自定义命令实践
java·github
夕除4 小时前
shizhan--10
java·开发语言
吴声子夜歌4 小时前
JVM——并发容器实现原理
java·jvm·并发容器
xier_ran4 小时前
【infra之路】PagedAttention
java·开发语言
糖果店的幽灵4 小时前
Spring AI 从入门到精通-结构化输出
java·人工智能·spring
zzz_23684 小时前
【Spring】面试突击系列(六):Spring 工程实践与面试综合
java·spring·面试
摇滚侠5 小时前
JavaWeb 全套教程 乱码问题 85-88
java·开发语言