OOM 排查
问题
Java 中有哪些常见的 OOM 类型?如何排查和解决 OutOfMemoryError?
答案
OOM 类型全览
| OOM 错误信息 | 发生区域 | 常见原因 |
|---|---|---|
Java heap space | 堆 | 对象过多、内存泄漏、堆太小 |
GC overhead limit exceeded | 堆 | GC 占 98% 时间但只回收 2% 内存 |
Metaspace | 元空间 | 动态生成类过多(CGLIB、反射) |
Direct buffer memory | 堆外内存 | NIO DirectByteBuffer 未释放 |
unable to create new native thread | OS | 线程数超过系统限制 |
Requested array size exceeds VM limit | 堆 | 申请了超大数组 |
Out of swap space | OS | 物理内存和交换空间都耗尽 |
Kill process or sacrifice child | OS | Linux OOM Killer 杀死进程 |
排查总流程
配置自动 Heap Dump
生产环境必须配置,OOM 时自动导出堆转储文件:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/logs/heapdump.hprof
1. Java heap space
最常见的 OOM,堆空间不足以分配新对象。
常见原因:
HeapOOMExamples.java
// 原因1:大量对象无法回收(内存泄漏)
public class MemoryLeak {
// 静态集合不断增长,对象无法被 GC
private static List<byte[]> cache = new ArrayList<>();
public void addData() {
// 每次调用都往静态集合中添加数据,从不清理
cache.add(new byte[1024 * 1024]); // 1MB
}
}
// 原因2:一次性加载大量数据
public void loadAllData() {
// 一次性查询数据库全表,数据量巨大
List<Record> allRecords = db.selectAll(); // 可能几百万条
}
// 原因3:大对象创建
public void createHugeArray() {
byte[] huge = new byte[Integer.MAX_VALUE]; // 请求 2GB 连续空间
}
排查步骤:
# 1. 查看 GC 日志,确认老年代使用情况
jstat -gcutil <pid> 1000
# 2. 导出 Heap Dump(如果没有自动导出)
jmap -dump:live,format=b,file=heap.hprof <pid>
# 3. 用 MAT 分析
# 重点关注:
# - Leak Suspects Report(泄漏嫌疑报告)
# - Dominator Tree(支配树,按占用内存排序)
# - Top Consumers(内存消耗最大的对象)
解决方案:
- 修复内存泄漏(释放不再需要的引用)
- 增大堆内存(
-Xmx) - 分批处理大数据量(分页查询)
- 使用流式处理代替全量加载
2. GC overhead limit exceeded
JVM 检测到 GC 花费 98% 以上的时间,但只回收了 2% 不到的堆内存,认为 GC 已经无效。
本质上也是堆内存不足,只是 JVM 提前报警:
# 如果想禁用这个检测(不推荐,只是推迟问题)
-XX:-UseGCOverheadLimit
# 正确做法:和 heap space OOM 一样排查
# 增大堆或修复内存泄漏
3. Metaspace OOM
元空间存储类的元信息,类加载过多时触发。
常见原因:
MetaspaceOOM.java
// 原因1:动态代理/CGLIB 生成大量类
// Spring AOP 默认使用 CGLIB,每个代理生成一个新类
@Aspect
public class LogAspect {
// 大量 Bean 都被代理时,元空间可能不足
}
// 原因2:热部署/类加载器泄漏
// Tomcat 热部署时旧的 WebAppClassLoader 未被回收
// 导致旧类无法卸载,元空间不断增长
// 原因3:大量 JSP 编译(每个 JSP 编译为一个 Servlet 类)
// 原因4:大量使用 Lambda 表达式(每个 Lambda 生成一个匿名类)
排查步骤:
# 查看元空间使用情况
jstat -gcmetacapacity <pid>
# 查看加载的类数量
jcmd <pid> VM.classloader_stats
# 或通过 JMX
ManagementFactory.getClassLoadingMXBean().getLoadedClassCount()
解决方案:
# 增大元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m
# 同时检查并修复类加载器泄漏
# Tomcat 热部署问题:升级 Tomcat 或使用 JRebel
# 过多动态代理:检查 AOP 粒度
4. Direct buffer memory
NIO 的 ByteBuffer.allocateDirect() 分配的堆外内存超过限制。
DirectBufferOOM.java
// 堆外内存没有被手动释放
public void leakDirectBuffer() {
while (true) {
// 分配堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
// buffer 变成垃圾后,堆外内存依赖 Cleaner(虚引用)回收
// 如果 GC 不及时,堆外内存可能累积
}
}
解决方案:
# 增大直接内存限制
-XX:MaxDirectMemorySize=512m
# 使用 Netty 时,开启内存泄漏检测
-Dio.netty.leakDetection.level=PARANOID
代码层面:使用完 DirectByteBuffer 后,主动调用 ((DirectBuffer) buffer).cleaner().clean() 释放,或使用 Netty 的引用计数管理。
5. unable to create new native thread
无法创建更多线程。
# Linux 查看系统线程限制
ulimit -u # 单用户最大进程/线程数
cat /proc/sys/kernel/threads-max # 系统最大线程数
# 查看当前进程线程数
ls /proc/<pid>/task | wc -l
# 或
jstack <pid> | grep 'tid' | wc -l
常见原因与解决:
| 原因 | 解决方案 |
|---|---|
| 线程池配置不合理(核心线程数太大) | 合理设置线程池大小 |
| 线程泄漏(创建了不销毁) | 使用线程池,避免 new Thread() |
| 系统 ulimit 限制太小 | ulimit -u 65535 |
| 每个线程栈占用过大 | 减小 -Xss(如 256k) |
| 堆过大导致留给线程栈的内存不足 | 适当减小 -Xmx |
线程数计算公式
可创建线程数 ≈ (系统可用内存 - 堆内存 - 元空间 - 其他) / 线程栈大小
如果堆设为 4GB,每个线程栈 1MB,那剩余内存决定了最多能创建多少线程。
常见内存泄漏场景
| 场景 | 原因 | 解决方案 |
|---|---|---|
| 静态集合 | 静态 List/Map 只增不删 | 使用缓存框架(Caffeine),设置上限和过期 |
| 未关闭的资源 | Connection/Stream/Cursor 未关闭 | try-with-resources |
| 监听器/回调 | 注册后未注销 | 及时 removeListener |
| ThreadLocal | 线程池中 ThreadLocal 未 remove | 用完在 finally 中 remove() |
| 内部类持有外部类引用 | 非静态内部类隐式持有外部类引用 | 使用静态内部类 + WeakReference |
| HashMap 的 key 未正确实现 hashCode/equals | 同一对象每次 put 都创建新 entry | 正确实现 hashCode/equals |
| 缓存未设上限 | 本地缓存(HashMap)无限增长 | 使用 Caffeine/Guava Cache |
CommonLeaks.java
// ❌ ThreadLocal 泄漏
public class UserContext {
private static final ThreadLocal<User> currentUser = new ThreadLocal<>();
public void setUser(User user) {
currentUser.set(user);
}
// 线程池中线程被复用,ThreadLocal 的值不会被清理
}
// ✅ 正确用法
public void handleRequest() {
try {
UserContext.setUser(getUser());
// 业务处理
} finally {
UserContext.currentUser.remove(); // 必须清理
}
}
MAT 分析 Heap Dump
Eclipse MAT 是分析 Heap Dump 最常用的工具:
核心功能:
| 功能 | 说明 |
|---|---|
| Leak Suspects | 自动分析泄漏嫌疑对象,生成报告 |
| Dominator Tree | 按 Retained Heap 排序,找出占用内存最大的对象 |
| Histogram | 按类统计对象数量和大小 |
| GC Roots | 查看对象的 GC Root 引用链,定位为什么不能被回收 |
| OQL | 类似 SQL 的查询语言,灵活查找对象 |
关键概念:
| 概念 | 含义 |
|---|---|
| Shallow Heap | 对象自身占用的内存(不含引用的对象) |
| Retained Heap | 对象被回收后能释放的总内存(含只被它引用的对象) |
| Dominator | 如果回收对象 A 就能回收对象 B,则 A 是 B 的支配者 |
分析流程:
1. 打开 Heap Dump → 查看 Leak Suspects Report
2. 查看 Dominator Tree → 找到 Retained Heap 最大的对象
3. 右键 → "Path to GC Roots" → "exclude weak/soft references"
4. 查看引用链 → 定位到业务代码
5. 修复代码 → 重新压测验证
常见面试问题
Q1: 线上出现 OOM 怎么排查?
答案:
- 确认 OOM 类型:查看错误日志中的具体错误信息
- 获取 Heap Dump:如果配置了
-XX:+HeapDumpOnOutOfMemoryError直接取;否则用jmap导出 - MAT 分析:
- 查看 Leak Suspects 报告
- 查看 Dominator Tree 找大对象
- 通过 GC Roots 引用链定位代码
- 结合 GC 日志:确认 GC 频率、老年代增长趋势
- 修复并验证:修改代码后压测验证
Q2: Java heap space 和 GC overhead limit exceeded 有什么区别?
答案:
两者都是堆内存不足,但触发时机不同:
- Java heap space:分配对象时,堆空间不足以容纳新对象
- GC overhead limit exceeded:GC 花费 98% 以上的时间,但只回收了不到 2% 的内存
后者是 JVM 的提前预警机制,意味着 GC 已经无效,很快就会出现 heap space OOM。两者的排查和解决方法相同。
Q3: 如何避免内存泄漏?
答案:
- 资源关闭:使用
try-with-resources关闭 Connection/Stream/Socket - ThreadLocal:使用后在 finally 中
remove() - 缓存控制:使用 Caffeine/Guava Cache 设置最大容量和过期时间,不要用 HashMap 做缓存
- 监听器管理:注册的监听器在不需要时注销
- 静态集合:避免静态 List/Map 无限增长
- 内部类:使用静态内部类 + WeakReference 代替非静态内部类
- 定期检查:代码审查 + 定期分析 Heap Dump
Q4: 元空间 OOM 怎么排查?
答案:
- 通过
jstat -gcmetacapacity <pid>查看元空间使用情况 - 通过
jcmd <pid> VM.classloader_stats查看类加载器信息 - 常见原因:
- 动态代理/CGLIB 生成过多类:Spring AOP 代理、MyBatis Mapper
- 热部署导致类加载器泄漏:Tomcat 重载 WAR 包
- 大量 Lambda 表达式:每个 Lambda 生成一个匿名内部类
- 解决:增大
-XX:MaxMetaspaceSize,同时修复根因
Q5: Shallow Heap 和 Retained Heap 有什么区别?
答案:
- Shallow Heap:对象自身占用的内存大小(对象头 + 实例变量),不包含引用的其他对象
- Retained Heap:如果该对象被 GC 回收,总共能释放多少内存(包含只被该对象引用的所有对象)
Retained Heap 更重要,它反映了一个对象"真正占用"的内存。MAT 中按 Retained Heap 排序可以快速找到内存大户。
Q6: 如何监控 JVM 内存使用?
答案:
| 工具 | 用途 |
|---|---|
jstat -gcutil <pid> 1000 | 命令行,实时查看 GC 统计 |
| VisualVM / JConsole | GUI,实时监控堆、线程、类加载 |
| Arthas(阿里) | 在线诊断,dashboard 命令查看实时数据 |
| Prometheus + Grafana | 生产环境监控,配合 Micrometer/JMX Exporter |
| Spring Boot Actuator | /actuator/metrics 暴露 JVM 指标 |
生产环境推荐 Prometheus + Grafana 方案,设置告警规则(如堆使用率 > 80%、Full GC 次数 > 0)。