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,但有时不是。先用
jps或ps 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 万条数据撑爆堆内存。
修复:
- 查询增加分页参数(LIMIT)
- 或改用 MyBatis Cursor/ResultHandler 流式读取
- 缩小事务范围,避免事务内持有大对象
案例 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。
修复
- queryList() 加充分过滤条件,避免全表扫描
- 大数据量改用 分页(PageHelper) 或 MyBatis Cursor 流式读取
- 分批处理,每批处理完释放内存
- 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 排查相关的问题,欢迎在评论区交流。
展开 Thread → ArrayList → Object\[\] 的层级结构,定位到 159 万条 ByteArrayRow
扁平化查看 Object\[\] 中每个元素,确认 159 万条 ByteArrayRow
internalRowData 解码后可看到列值:ID、日期、金额(97.98 万)、业务流水号等