内存溢出排查
2019年双十一凌晨 1 点,我们的订单服务突然开始疯狂 Full GC,每次 Full GC 后 Old 区几乎没释放任何空间。
凌晨 2 点,服务开始拒绝请求。凌晨 3 点,OOM 被触发,JVM 直接崩溃。
事后复盘,我们发现罪魁祸首是一个用 ConcurrentHashMap 做的本地缓存——没有设置过期时间,缓存持续膨胀,直到把 Old 区撑满。
整整三个月的订单数据,全部缓存在了内存里。
OOM 是每一个 Java 工程师的噩梦,但它不是玄学。每一个 OOM 都有根因,关键是你能不能找到。
一、OOM 的七种类型 🔴
1.1 Java 堆溢出(Heap Space)
最常见的 OOM 类型。
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
常见原因:
// 原因一:一次性加载过多数据到内存
List<Order> orders = queryAllOrders(); // 1000万条订单,一次性加载
// 原因二:内存泄漏
Map<String, User> cache = new HashMap<>();
// 缓存没有过期机制,持续增长
// 原因三:JVM 堆设置过小
java -Xmx256m MyApp // 在数据量增长后不够用
// 原因四: Eden/Survivor/Old 比例不当
// 导致大量对象直接进入 Old 区,Old 区迅速填满
JDK 8 用 Metaspace 替代了 PermGen。
Exception: java.lang.OutOfMemoryError: Metaspace
常见原因:
// 原因一:动态生成大量类
// 如 CGLIB 代理、字节码生成框架
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(MyClass.class);
enhancer.setCallback((MethodInterceptor) (o, method, args, methodProxy) -> {
return methodProxy.invokeSuper(o, args);
});
// 每次增强都生成新的子类字节码
for (int i = 0; i < 100000; i++) {
enhancer.create(); // 动态生成类 → 撑爆 Metaspace
}
// 原因二:类加载器泄漏(Tomcat 热部署场景)
// 每个新的 WebAppClassLoader 加载的类不会被卸载
// 原因三:Metaspace 设置过小
-XX:MaxMetaspaceSize=128m // 默认无上限,但显式设置后容易触发
1.3 栈溢出(StackOverflow)
Exception in thread "main" java.lang.StackOverflowError
常见原因:
// 递归调用没有正确的退出条件
public int factorial(int n) {
return n * factorial(n - 1); // 如果 n < 0,StackOverflow!
}
// 大数组分配在栈上
public void largeStack() {
int[] arr = new int[Integer.MAX_VALUE]; // 栈空间不足
}
// 线程过多
for (int i = 0; i < 10000; i++) {
new Thread(() -> {
// 每个线程都有独立的栈空间
// 线程数过多 → 栈内存耗尽
}).start();
}
⚠️
面试陷阱:有人说"StackOverflowError 只在递归调用时发生"。准确答案是:栈溢出还包括线程过多导致栈内存耗尽(Unable to create new native thread),以及栈帧过大(大量局部变量导致)。JVM 默认栈大小 1MB,每个线程独享,1000 个线程就占 1GB。
1.4 GC Overhead Limit Exceeded
java.lang.OutOfMemoryError: GC overhead limit exceeded
触发条件:JVM 花费超过 98% 的时间做 GC,但只回收了不到 2% 的堆空间。
这通常意味着:堆里大部分都是"垃圾"(存活对象),GC 无事可做但仍然要尝试。
典型场景:
- 内存泄漏(HashMap 持续增长)
- 对象之间形成循环引用但没有被 GC Roots 引用(实际上不会导致 OOM)
- 数据量远超堆大小
1.5 Direct Buffer Memory
java.lang.OutOfMemoryError: Direct buffer memory
NIO 的直接内存溢出:
// NIO 使用直接内存(堆外内存)
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024); // 1GB 直接内存
// 如果 JVM 配置了 -XX:MaxDirectMemorySize=1g
// 再申请 1GB 就会 OOM
1.6 无法分配本地线程
java.lang.OutOfMemoryError: Unable to create new native thread
原因:系统进程数达到上限,或者内存不足以为新线程分配栈空间。
计算公式:
最大线程数 ≈ (最大地址空间 - JVM 堆 - Metaspace) / 栈大小
示例(8GB 机器,堆 4GB):
最大线程数 ≈ (8GB - 4GB - 1GB) / 1MB = 3000 个线程
1.7 数组超限
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
尝试创建一个超过 JVM 限制的数组。
// 数组最大长度:Integer.MAX_VALUE
// 但实际限制取决于可用连续内存
int[] arr = new int[Integer.MAX_VALUE]; // 可能失败
// 另一个例子:超过 -XX:MaxArraySize
ArrayList.ensureCapacity(Integer.MAX_VALUE - 8);
二、OOM 排查工具与方法 🔴
2.1 排查命令速查
# 查看进程 ID
jps -l | grep MyApp
# 查看堆内存使用
jstat -gc <pid> 1000
# 输出解读:
# S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
# Survivor0容量 Survivor0已用 Eden容量 Eden已用 Old容量 Old已用 Metaspace容量 已用 ...
# 查看详细内存信息
jstat -gccapacity <pid>
# 生成堆转储
jmap -dump:format=b,file=heap.hprof <pid>
# 或设置自动生成
# -XX:+HeapDumpOnOutOfMemoryError
# -XX:HeapDumpPath=/var/log/java/
2.2 诊断参数配置
# 生产必配的 OOM 诊断参数
java -Xmx4g -Xms4g \
-XX:+HeapDumpOnOutOfMemoryError \ # OOM 时自动 dump 堆
-XX:HeapDumpPath=/var/log/java/ \ # dump 文件路径
-XX:+PrintGCDetails \ # 详细 GC 日志
-XX:+PrintGCDateStamps \
-Xloggc:/var/log/java/gc.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=50m \
-XX:NativeMemoryTracking=summary \ # NMT 追踪 native 内存
-XX:MaxMetaspaceSize=256m # 限制 Metaspace
2.3 Arthas 排查 OOM
# 使用 Arthas 实时诊断
# 1. 启动 Arthas
java -jar arthas-boot.jar <pid>
# 2. 查看内存使用
dashboard
# 3. 实时查看对象分配
jobs
# 4. 使用 OOM 原因分析器
profiler start --event alloc
profiler stop --format=html
# 生成火焰图,分析内存分配热点
# 5. 查看类加载器
classloader -l
classloader -c <hash> # 查看特定类加载器加载的类
三、实战案例:HashMap 内存泄漏 🟡
3.1 问题背景
生产事故:用户画像服务在运行 48 小时后开始 Full GC 频繁,最终 OOM。
// 问题代码
@Service
public class UserProfileService {
// 本地缓存,无过期机制
private Map<String, UserProfile> profileCache = new HashMap<>();
public UserProfile getProfile(String userId) {
if (!profileCache.containsKey(userId)) {
UserProfile profile = loadFromDB(userId);
profileCache.put(userId, profile);
}
return profileCache.get(userId);
}
}
3.2 排查过程
# Step 1:查看 GC 日志
grep "Full GC" gc.log | tail -20
# 发现 Old 区从 2GB 持续增长到 5.5GB
# Step 2:生成堆转储(OOM 后自动生成了)
# 使用 MAT 分析
# Step 3:MAT 分析步骤
# 1. Open Heap Dump (.hprof)
# 2. 选择 "Leak Suspects Report"
# 3. 查看 " Biggest Objects by Retained Size"
# 4. 发现 HashMap 持有 3.2GB 内存
# Step 4:进一步分析
# Path To GC Roots → exclude all phantom/weak/soft refs
# 发现 profileCache 被 UserProfileService 的静态字段持有
3.3 根因分析
profileCache(HashMap)
→ 被 UserProfileService 单例持有
→ UserProfileService 被 Spring 容器持有(作为 Bean)
→ Spring 容器是静态的(通过 ClassLoader 引用)
→ 生命周期 = JVM 进程生命周期
→ HashMap 持续增长,永不释放
3.4 修复方案
方案一:接入缓存框架(推荐)
// 使用 Caffeine Cache(高性能缓存,支持过期和淘汰)
@Service
public class UserProfileService {
private LoadingCache<String, UserProfile> profileCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存 10000 个用户
.expireAfterWrite(30, TimeUnit.MINUTES) // 写后 30 分钟过期
.recordStats() // 开启统计
.build(this::loadFromDB); // 缓存未命中时自动加载
public UserProfile getProfile(String userId) {
return profileCache.get(userId);
}
}
方案二:限制缓存大小
// 使用 LRU 缓存限制大小
private Map<String, UserProfile> profileCache =
Collections.synchronizedMap(
new LinkedHashMap<>(10000, 0.75f, true) {
@Override
protected boolean removeEldestEntry(
Map.Entry<String, UserProfile> eldest) {
return size() > 10000; // 超过 10000 条时淘汰最老的
}
});
3.5 效果验证
# 调优后的 GC 日志
Old 区使用量:稳定在 1.5GB
Full GC 频率:从每小时 6 次降为每 3 天 0 次
P99 延迟:从 850ms 降为 15ms
4.1 问题场景
Spring Boot 项目在运行时,通过 CGLIB 动态生成大量代理类,导致 Metaspace 膨胀。
# 现象
[Full GC (Last Resort Collection) before OOM]
[Metaspace: 128MB->127MB(128MB)] ← Metaspace 满了
java.lang.OutOfMemoryError: Metaspace
4.2 排查命令
# 查看 Metaspace 使用
jstat -gc <pid> 2000
# 对比 MC(Metaspace 容量)和 MU(已用)
# 如果 MU 持续增长接近 MC,说明类加载器泄漏
# 查看类的数量
jcmd <pid> VM.classloader_stats
# 使用 Arthas 查看类加载器
arthas> classloader -t
# 查看类加载器层级
arthas> classloader -l
4.3 解决方案
// 方案一:限制 CGLIB 生成的代理类数量
@Configuration
public class CglibConfig {
@Bean
public Advisor advisor() {
AspectJExpressionPointcut pointcut =
new AspectJExpressionPointcut();
pointcut.setExpression("execution(* com.example..*.*(..))");
DefaultBeanFactoryPointcutAdvisor advisor =
new DefaultBeanFactoryPointcutAdvisor();
advisor.setAdvice(new MyInterceptor());
advisor.setPointcut(pointcut);
return advisor;
}
}
// 方案二:使用 JDK 动态代理替代 CGLIB
// JDK 动态代理不需要生成新类,减少 Metaspace 压力
// 方案三:设置 Metaspace 上限并监控
-XX:MaxMetaspaceSize=256m \
-XX:MetaspaceSize=128m \
-XX:+UseGCOverheadLimit // 提前触发 GC Overhead 警告
五、OOM 预防 Checklist 🟢
□ 合理设置堆大小(-Xmx/-Xms),避免过大或过小
□ 开启 -XX:+HeapDumpOnOutOfMemoryError,自动生成 dump 文件
□ 设置合理的 Metaspace 上限 -XX:MaxMetaspaceSize
□ 本地缓存必须设置过期机制(推荐 Caffeine/Guava Cache)
□ 避免用静态集合持有大量数据
□ 监控 GC 日志,关注 GC 频率和内存使用趋势
□ 使用 APM 工具(Skywalking/Pinpoint)监控内存分配
□ 定期检查线程数量和 Direct Memory 使用
□ 使用 Arthas 的 profiler 工具分析内存分配热点
□ 上线前做压力测试,验证内存不会持续增长
【架构权衡】
OOM 的根因 90% 都是代码问题:没有过期机制的缓存、没有限制大小的集合、泄漏的类加载器、不合理的数据结构。JVM 参数只能延缓问题,不能解决问题。
真正的高手在排查 OOM 时,会先把堆转储文件拿到本地,用 MAT 或 JProfiler 分析对象的引用链,找到泄漏的 GC Root,然后回到代码层面修复问题。参数调优是最后的手段,代码优化才是根本。