内存溢出排查

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 区迅速填满

1.2 Metaspace 溢出

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

四、Metaspace 溢出排查 🟡

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,然后回到代码层面修复问题。参数调优是最后的手段,代码优化才是根本。