OOM(OutOfMemoryError)是生产环境最惊心动魄的故障之一:进程可能直接崩溃,也可能在频繁 Full GC 中「假死」。排查 OOM 不能靠猜,靠的是堆转储 + 引用链分析的完整证据链。本文从 OOM 类型讲起,覆盖转储获取、MAT 分析、泄漏定位与线上兜底,帮你把「崩溃现场」变成「可复现的结论」。
一、OOM 的类型与成因
1.1 五种常见 OOM
| 类型 | 抛错场景 | 典型成因 |
|---|---|---|
Java heap space | 堆内存不足 | 大对象、泄漏、堆太小 |
GC overhead limit exceeded | GC 超过阈值仍收不回内存 | 内存将尽,GC 空转 |
Metaspace | 元空间不足 | 类加载过多(反射/代理泄漏) |
Unable to create native thread | 无法创建系统线程 | 线程数达系统上限或内存不足 |
Direct buffer memory | 堆外内存不足 | DirectBuffer 未释放 |
关键词对比:
heap space → 堆内对象太多
native thread → 线程太多(每线程 1MB 栈)
direct buffer → 堆外内存泄漏
metaspace → 类/元数据泄漏(动态代理、反射)
1.2 泄漏 vs 内存不足
// 内存泄漏:对象不再使用,却被强引用链挂住,GC 收不走
public class LeakHolder {
static final List<byte[]> CACHE = new ArrayList<>();
void store(byte[] data) { CACHE.add(data); } // 只进不出 → 泄漏
}
// 内存不足:对象被正常使用,但总量超过堆上限(一次性大请求)
// 与泄漏的本质区别:内存不足清掉请求就恢复,泄漏清不掉
一句话总结: 先分清五种 OOM 的「报错位置」(堆/元空间/线程/堆外),再判断是泄漏(收不回)还是不足(总量超限)——方向对了,排查才不走弯路。
二、OOM 排查流程
2.1 观察指标定方向
排查第一步:看监控指标
1. 堆使用率曲线:持续上涨不回落 → 泄漏
2. GC 频率与停顿:Full GC 越来越频繁 → 内存将尽
3. 线程数曲线:持续上涨 → native thread OOM
4. 容器内存/堆外:进程 RSS 远超堆大小 → 堆外泄漏
第二步:确认报错堆栈
报错信息里的"谁在分配、分配多少"往往直接指向泄漏点
2.2 排查路线图
Java heap space / GC overhead
→ 收集堆转储 → MAT 找泄漏根因
Unable to create native thread
→ 看线程数 + 系统 ulimit → 线程泄漏或栈设置
Direct buffer memory
→ 查 Netty/自管理 DirectBuffer 的释放路径
Metaspace
→ 查动态代理/反射/类加载器泄漏
一句话总结: 排查不是从代码开始,而是先从监控曲线确认「泄漏型」还是「突发型」,再按 OOM 类型选择对应的转储和分析工具。
三、堆转储的获取
3.1 jmap 手动转储
# 方式一:jmap 直接 dump(会触发 Full GC,慎用于大堆)
jmap -dump:live,format=b,file=/tmp/heap.bin <pid>
# 方式二:HSDB / jcmd(推荐,命令一致)
jcmd <pid> GC.heap_dump /tmp/heap.bin
注意事项:
jmap 默认会执行一次 Full GC,生产大堆慎用
优先用 jcmd GC.heap_dump,行为更可控
转储文件可能很大(等于堆大小),留足磁盘
3.2 选择 dump 时机
| 时机 | 适用 | 说明 |
|---|---|---|
| OOM 瞬间自动 dump | 生产必备 | -XX:+HeapDumpOnOutOfMemoryError |
| 定时快照 | 缓慢泄漏 | 定时 dump,MAT 对比增长 |
| 告警触发 dump | 高水位预警 | 堆使用率超阈值时执行 |
| 压测复现后 dump | 测试环境 | 复现问题后手动转储 |
缓慢泄漏的采样技巧:
堆上涨初期 dump 一份(baseline)
上涨明显后再 dump 一份(current)
两次对比 → 新增存活对象 = 泄漏主体
3.3 自动转储:OOM 时自动留证
# 启动参数:OOM 时自动 dump + 自动退出,便于监控重启
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps/ \
-XX:OnOutOfMemoryError="kill -9 %p" \
-Xmx4g MyApp
// 代码级兜底:捕获 OOM 记录上下文,便于分析
try {
// 大对象分配
} catch (OutOfMemoryError e) {
log.error("OOM 上下文: requestId={}, userId={}", reqId, userId, e);
throw e; // 让 JVM 的 HeapDumpOnOutOfMemoryError 继续生效
}
一句话总结: 手动转储用
jcmd GC.heap_dump,生产一定要配-XX:+HeapDumpOnOutOfMemoryError自动留证——崩溃现场是最宝贵的排障资产。
四、MAT 分析实战
4.1 Leak Suspects:自动找泄漏嫌疑
MAT(Memory Analyzer)打开 heap.bin 后,Leak Suspects 报告直接列出最可能的泄漏点。
Leak Suspects 报告结构:
Suspect 1:某个对象占了多少堆,被谁全局持有
详情:Dominator Tree 中的路径 → 引用链 → 泄漏根因
解读要点:
报告给出的是"最大的堆占用者",不一定是业务泄漏
结合业务代码判断:这个大对象本该释放吗?
4.2 Dominator Tree:支配树看引用
Dominator Tree(支配树):
每个节点的"支配者"是持有它、且其被释放则它必然被释放的对象
顺着支配树看:谁独占了大头 → 谁持有不释放
常用视图:
Histogram:按类统计实例数与占用
Path to GC Roots:某个对象的完整引用链
Retained Heap:对象及其支配子树占用的内存
// 经典泄漏根因示例:静态集合 + 业务对象
public class CacheManager {
// static 引用 → GC Roots 可达 → 永不释放
private static final Map<String, Session> SESSIONS = new HashMap<>();
}
// MAT 中:SESSIONS → Path to GC Roots 显示 "static final"
// 说明缓存只进不出是泄漏源
4.3 其他 MAT 视图与技巧
| 视图/操作 | 用途 |
|---|---|
| Histogram | 按类统计实例数与 Shallow Heap |
| Thread Overview | 看线程各自持有的堆对象 |
| Top Consumers | 按包/类加载器聚合占用 |
| Find Leaks(报告) | 自动生成 Leak Suspects 报告 |
| Compare Snapshots | 两次 dump 对比,看增长对象 |
| Open Query Browser | OQL 自定义查询 |
OQL 示例:找所有超过 1MB 的 byte[] 实例
SELECT * FROM byte[] b WHERE b.length > 1048576
对比两个 dump(间隔一段时间):
MAT → Compare → 找出"新增且存活"的对象
往往就是正在泄漏的那批
一句话总结: MAT 的
Leak Suspects给出嫌疑名单,Dominator Tree给出引用链;把「大对象」和「Path to GC Roots」对上,泄漏根因就浮出水面了;两次 dump 对比是定位缓慢泄漏的利器。
五、泄漏定位案例
5.1 案例:缓存只进不出
现象:堆曲线持续上涨,Full GC 越来越频
Leak Suspects:一个 ConcurrentHashMap 占用 60% 堆
分析路径:
1. Histogram 找最大类 → ConcurrentHashMap
2. Path to GC Roots → 静态字段引用的本地缓存
3. 查代码 → 缓存 key 永不失效,value 越积越多
修复:
加 TTL 淘汰 / 改用带过期策略的缓存框架
5.2 案例:线程局部 ThreadLocal 泄漏
现象:thread 数不多,但堆上涨
根因:ThreadLocal 值被线程持有,线程长期存活不销毁
→ value 一直可达,GC 收不掉
识别:
Histogram 中 ThreadLocal 相关类实例数与线程数对得上
Thread 的 threadLocals 指向一个巨大 value
修复:用完 remove(),或用作用域内清理的封装
// 正确姿势:ThreadLocal 用完必须 remove
ThreadLocal<BigObject> tl = new ThreadLocal<>();
try {
tl.set(bigObject);
doWork();
} finally {
tl.remove(); // 关键:清掉,防止线程池复用串值
}
一句话总结: 泄漏案例分析的两个常见剧本是「缓存只进不出」和「ThreadLocal 挂大对象」;MAT 提供证据,修复要点是给缓存加过期、给 ThreadLocal 加 remove。
六、常见内存泄漏类型汇总
| 泄漏类型 | 特征 | MAT 证据 | 对策 |
|---|---|---|---|
| 静态集合 | 静态 Map/List 无限增长 | 静态字段 GC Root | 加淘汰策略 |
| ThreadLocal | 线程存活 + value 不清理 | Thread 的 threadLocals | 用完 remove |
| 监听器/回调 | 注册后未反注册 | 集合持有业务对象 | 配对 register/unregister |
| 类加载器泄漏 | 自定义 CL 反复加载 | Metaspace 增长 | 复用 CL,卸载可回收 |
| 连接未关闭 | IO/DB 连接泄漏 | 连接对象堆积 | try-with-resources |
| 缓存框架配置 | 无过期/无上限 | 缓存实例巨大 | 配置 TTL/容量 |
预防思路(前置):
1. 设置合理的堆上限,配合 GC 日志
2. 对缓存类对象做容量/过期约束
3. 统一资源关闭(连接、流)规范
4. 压测阶段引入泄漏检查(如 JFR + MAT 定期体检)
一句话总结: 泄漏的六个常见剧本都有固定的 MAT 证据形态;把「静态、ThreadLocal、监听器、类加载器、连接、缓存」六类检查点建成代码评审清单,能拦住大多数泄漏。
七、线上兜底与预防
7.1 OOM 时的兜底策略
崩溃恢复兜底:
1. 进程自动重启(systemd / k8s 重启策略)
2. 重试机制:上游调用方重试,规避一次性大请求
3. 限流降级:OOM 前先熔断,保护后续请求
4. 分片分页:大结果集分批处理,避免一次堆满
预防兜底:
1. 配 HeapDumpOnOutOfMemoryError,自动留证
2. 监控堆使用率与 GC,设阈值告警
3. 定期用 JFR/MAT 做内存健康检查
7.2 启动参数参考
# 一个兼顾"兜底 + 留证"的启动配置示例
java \
-Xmx4g -Xms4g \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/data/dumps/ \
-XX:+ExitOnOutOfMemoryError \
-Xlog:gc*:file=/data/logs/gc.log \
MyApp
一句话总结: 线上兜底三件事——自动转储留证、自动重启恢复、监控告警提前干预;OOM 不可能 100% 杜绝,但可以做到「崩溃能分析、恢复能自动」。
八、实战陷阱清单
| 陷阱 | 现象 | 对策 |
|---|---|---|
| jmap 触发 Full GC | 大堆抖动、停顿 | 用 jcmd,低峰期执行 |
| 磁盘不足 | dump 失败 | 预留堆大小 1.5 倍空间 |
| dump 后忘下载 | 崩溃现场丢失 | 转储目录自动归档 |
| 只看 Histogram | 定位不准 | 看 Path to GC Roots |
| 误把大对象当泄漏 | 冤案 | 结合业务判断是否本应释放 |
| ThreadLocal 不清理 | 泄漏缓慢 | 强制 remove 规范 |
| 只修内存不足不防泄漏 | 复发 | 建立泄漏检查清单 |
九、总结
| 阶段 | 工具 | 产出 |
|---|---|---|
| 定方向 | 监控曲线 + 报错堆栈 | 泄漏型 or 突发型 |
| 留证据 | HeapDumpOnOutOfMemoryError | 崩溃现场转储 |
| 找根因 | MAT Leak Suspects / Dominator Tree | 引用链与泄漏点 |
| 兜底 | 自动重启 + 限流降级 | 恢复可用 |
| 预防 | GC 日志 + 定期体检 | 早发现早处理 |
一句话记住:OOM 排查的本质是把「内存崩溃」变成「引用链证据」。先看曲线分类型,再自动留证,最后用 MAT 的支配树找到谁该释放却没释放——配好自动转储和监控告警,OOM 就不再是「看天意」的故障。
延伸阅读
- JVM 内存与 GC 调优实战 — 堆配置与 GC 参数决定 OOM 的边界
- JFR 与 JMC 性能分析实战 — 分配采样与 GC 事件,定位谁在疯狂分配
- Arthas 线上诊断实战 — 线上快速查看对象实例与 GC 状态
- Java 性能优化:从代码到 JVM 的全链路调优 — 内存与性能问题的系统方法论
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。