Skip to content

OOM 及性能优化案例与解决方案

常见问题的根源与解法

这一节通过真实案例,展示常见 OOM 和性能问题的诊断与解决思路。每个案例都遵循「症状 → 诊断 → 根因 → 方案」的结构。

案例一:堆内存溢出(Heap Space)

症状

java.lang.OutOfMemoryError: Java heap space
    at com.example.Cache.put(Cache.java:45)
    ...

诊断

bash
# 1. 查看 GC 日志
# 如果 Full GC 频繁且内存不下降 → 内存泄漏
# 如果内存持续增长直到 OOM → 对象分配过快

# 2. 生成堆转储
jmap -dump:live,format=b,file=heap.hprof <pid>

# 3. 用 MAT 分析
# Dominator Tree → 找出 Retained Heap 最大的对象
# Histogram → 按内存排序找出最多的类

根因

常见原因:

1. 内存泄漏
   - 静态集合无限增长(HashMap 作为缓存无清理)
   - Listener/Callback 未移除
   - ThreadLocal 未清理
   - 数据库连接池泄漏

2. 分配过快
   - 一次性加载过多数据到内存
   - 大对象(GB 级别的文件、XML)
   - 循环创建大量对象

解决方案

内存泄漏修复:
1. 定位泄漏对象(MAT Dominator Tree)
2. 追溯 GC Root(Path to GC Roots)
3. 修复代码

分配过快修复:
1. 分批加载数据(Stream/Lazy)
2. 使用堆外内存处理大文件
3. 增加堆大小(如果确实需要)
java
// 修复示例:静态 Map 缓存无清理
// Before
public class Cache {
    private static Map<String, Object> cache = new HashMap<>();

    public void put(String key, Object value) {
        cache.put(key, value);  // 无限增长!
    }
}

// After:使用 WeakHashMap 或限制大小
public class Cache {
    // 方案一:WeakHashMap(无引用时自动回收)
    private static Map<String, Object> cache = new WeakHashMap<>();

    // 方案二:限制大小的 LRU 缓存
    private static Map<String, Object> cache =
        new LinkedHashMap&lt;String, Object&gt;(1000, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > 10000;  // 超过 10000 条时淘汰最老的
            }
        };
}

案例二:元空间溢出(Metaspace)

症状

java.lang.OutOfMemoryError: Metaspace
    at java.lang.ClassLoader.defineClass1(Native Method)
    ...

诊断

bash
# 1. 查看元空间使用
jstat -gc <pid>
# MC(元空间容量)和 MU(元空间使用)列

# 2. 查看类加载情况
jstat -class <pid>
# Loaded:已加载类数量
# Unloaded:已卸载类数量
# 如果 Unloaded = 0 → 类加载器泄漏

# 3. 生成堆转储分析类加载器
jmap -dump:live,format=b,file=heap.hprof <pid>
# MAT → Histogram → Group by ClassLoader

根因

元空间溢出原因:
1. 类加载器泄漏(Tomcat/插件框架常见)
   - 每次部署创建新的类加载器,但旧的不卸载
   - 旧类加载器被其他对象引用无法回收

2. 动态类生成过多
   - ORM 框架(Hibernate/JPA)
   - 动态代理
   - 脚本引擎(Groovy/BeanShell)

3. 参数设置过小
   - JDK 8 永久代替代方案,但 MetaspaceSize 默认很小

解决方案

bash
# 增加元空间
java -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m -jar myapp.jar

# 如果是类加载器泄漏:
# 1. 排查类加载器引用链
# MAT → Path to GC Roots → 查看什么引用了类加载器
# 2. 修复引用链
# 3. 确保使用 WeakReference 持有类加载器
java
// 修复示例:类加载器泄漏
// Before:类加载器被静态集合持有
public class PluginManager {
    private static Map<String, ClassLoader> loaders = new HashMap<>();
    // ↑ loaders 持有类加载器引用,永不卸载

    public static void loadPlugin(String name, ClassLoader loader) {
        loaders.put(name, loader);  // 泄漏!
    }
}

// After:使用 WeakReference
public class PluginManager {
    private static Map<String, WeakReference<ClassLoader>> loaders = new HashMap<>();

    public static void loadPlugin(String name, ClassLoader loader) {
        loaders.put(name, new WeakReference<>(loader));
    }

    public static ClassLoader getLoader(String name) {
        WeakReference<ClassLoader> ref = loaders.get(name);
        return ref == null ? null : ref.get();
    }
}

案例三:直接内存溢出(Direct Memory)

症状

java.lang.OutOfMemoryError: Direct buffer memory

诊断

bash
# 1. 查看直接内存使用
jinfo -flag MaxDirectMemorySize <pid>

# 2. 确认是 NIO 使用
# 如果使用了 ByteBuffer.allocateDirect()

根因

直接内存溢出原因:
1. NIO 操作使用直接内存
   - ByteBuffer.allocateDirect()
   - Netty 等 NIO 框架

2. 堆外内存未释放
   - 主动释放 ByteBuffer:DirectByteBuffer = null
   - 依赖 GC 回收(需要 Full GC 才回收)

3. 直接内存设置过小

解决方案

bash
# 增加直接内存
java -XX:MaxDirectMemorySize=1g -jar myapp.jar

# 或不限制
java -XX:MaxDirectMemorySize=0 -jar myapp.jar
java
// 主动释放直接内存
public class DirectBufferDemo {
    private static final Map<Long, ByteBuffer> buffers = new ConcurrentHashMap<>();

    public void allocateBuffer(long id, int size) {
        ByteBuffer buffer = ByteBuffer.allocateDirect(size);
        buffers.put(id, buffer);
    }

    // 主动释放
    public void releaseBuffer(long id) {
        buffers.remove(id);  // 让 GC 回收
    }

    // 或者手动触发 Cleaner(不推荐)
    public void forceRelease(ByteBuffer buffer) throws Exception {
        if (buffer.isDirect()) {
            Cleaner cleaner = ((DirectByteBuffer) buffer).cleaner();
            if (cleaner != null) {
                cleaner.clean();  // 强制释放
            }
        }
    }
}

案例四:线程栈溢出(Stack Overflow)

症状

java.lang.StackOverflowError
    at com.example.Recursive.calculate(Recursive.java:25)
    ...

诊断

bash
# 1. 查看线程栈大小
jinfo -flag ThreadStackSize <pid>

# 2. 定位溢出方法
# 堆栈信息中会显示调用链
# 找到最深的方法

根因

线程栈溢出原因:
1. 无限递归(最常见)
   - 方法 A 调用方法 B,方法 B 调用方法 A
   - 没有正确终止的递归

2. 线程栈设置过小
   - -Xss256k 设置过小

3. 递归调用中创建大量对象
   - 每个栈帧都消耗栈空间

解决方案

bash
# 增加线程栈大小
java -Xss1m -jar myapp.jar
java
// 修复无限递归
// Before
public int sum(int n) {
    return n + sum(n - 1);  // 没有终止条件!
}

// After:尾递归优化或循环
public int sum(int n) {
    // 循环替代递归
    int result = 0;
    for (int i = 1; i <= n; i++) {
        result += i;
    }
    return result;
}

// 或者尾递归(需要编译器支持)
public int sum(int n, int acc) {
    if (n <= 0) return acc;
    return sum(n - 1, acc + n);  // 尾递归形式
}

案例五:GC 开销限制(GC Overhead Limit)

症状

java.lang.OutOfMemoryError: GC overhead limit exceeded

# 或日志中出现:
# GC overhead limit exceeded

诊断

bash
# 1. 查看 GC 日志
# 大量 GC,CPU 消耗在 GC 上

# 2. 查看 GC 频率
jstat -gcutil <pid> 1000 10
# 如果 YGC/FGC 频繁且耗时很长

根因

GC 开销限制原因:
1. 堆太小
   - 分配速率 > 回收速率
   - GC 不断运行但释放不了多少内存

2. 内存泄漏
   - 同案例一

3. 大量小对象
   - 分配速率过高

解决方案

bash
# 增加堆大小
java -Xms4g -Xmx4g -jar myapp.jar

# 或关闭开销限制检查(不推荐)
java -XX:-UseGCOverheadLimit -jar myapp.jar

案例六:请求超时 GC 停顿

症状

监控系统报警:P99 延迟 > 500ms
用户反馈:接口偶发性卡顿

# GC 日志:
# 2026-03-22T10:00:00.123+0800: [Full GC (Allocation Failure) ... 500ms]
# 2026-03-22T10:00:05.456+0800: [Full GC (Allocation Failure) ... 450ms]

诊断

bash
# 1. 分析 GC 日志
# 查看停顿时间和频率

# 2. 用 GCViewer/GCEasy 分析

# 3. 查看 GC 原因
# Allocation Failure → 分配失败(正常)
# Ergonomics → 自适应调整
# System.gc() → 代码调用了 System.gc()
# Allocation Failure + CMS → CMS 老年代空间不足

解决方案

java
// 调优方案

// 方案一:切换到 G1 GC
java -XX:+UseG1GC \
     -XX:MaxGCPauseMillis=100 \
     -jar myapp.jar

// 方案二:减少对象分配
// - 使用对象池
// - 减少中间对象
// - 使用基本类型

// 方案三:增加堆大小
java -Xms4g -Xmx4g -jar myapp.jar

// 方案四:减少 System.gc() 调用
// 搜索代码中的 System.gc()
grep -r "System.gc()" src/

// 方案五:使用 ZGC(极低停顿)
java -XX:+UseZGC \
     -XX:+ZGenerational \
     -jar myapp.jar

性能优化 Checklist

性能优化清单:

内存优化:
□ 堆大小是否合理(-Xms = -Xmx)
□ 对象分配速率是否过高
□ 是否有内存泄漏
□ 元空间是否充足
□ 是否使用合适的数据结构

GC 优化:
□ GC 选择是否合适(场景匹配)
□ GC 参数是否调优
□ System.gc() 调用是否过多
□ Full GC 频率是否过高

代码优化:
□ 循环中是否创建过多对象
□ 是否使用对象池复用对象
□ 是否正确关闭资源
□ 是否使用流式处理代替一次性加载

监控:
□ GC 日志是否开启
□ OOM 时是否生成堆转储
□ 是否有健康检查接口
□ 性能基线是否建立

本节小结

OOM 和性能问题速查:

问题类型症状根因解决方案
堆溢出OutOfMemoryError: heap泄漏/分配过快MAT 分析 + 修复代码
元空间溢出OutOfMemoryError: Metaspace类加载器泄漏增加 MetaspaceSize
直接内存溢出OutOfMemoryError: Direct bufferNIO 使用过多增加 MaxDirectMemorySize
线程栈溢出StackOverflowError无限递归修复递归/增加栈
GC 开销GC overhead limit堆太小/泄漏增加堆 + 修复泄漏
GC 停顿P99 延迟高GC 参数不当调优 GC 参数

排查 OOM 的标准流程

1. 查看错误信息(堆/元空间/直接内存)
2. 开启 GC 日志和堆转储
3. 生成堆转储(-XX:+HeapDumpOnOutOfMemoryError)
4. MAT 分析堆转储
5. 追溯 GC Root
6. 修复代码

到这里,「JVM 性能监控与调优」部分全部完成。你可以继续深入 字节码指令集,或者回顾 类加载机制 进一步巩固基础。

基于 VitePress 构建