首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >从崩溃到根治:深度解析工作中最常遇到的 OOM 问题及解决方案

从崩溃到根治:深度解析工作中最常遇到的 OOM 问题及解决方案

作者头像
果酱带你啃java
发布2026-04-14 12:55:19
发布2026-04-14 12:55:19
570
举报
在 Java 开发生涯中,OutOfMemoryError(OOM)无疑是最令人头疼的问题之一。它像一颗定时炸弹,往往在生产环境高负载时突然爆发,给系统稳定性和业务连续性带来严重威胁。本文将系统剖析工作中最常见的 6 种 OOM 问题,从底层原理到复现方式,从排查思路到解决方案,全方位带您攻克 OOM 难题,让您在遇到内存溢出时不再手足无措。

一、堆内存 OOM(java.lang.OutOfMemoryError: Java heap space)

堆内存 OOM 是所有 OOM 错误中最常见的一种,几乎每个 Java 开发者都或多或少遇到过。理解堆内存 OOM 的产生原因和解决方法,是 Java 工程师的必备技能。

1.1 底层原理

Java 堆是 JVM 管理的内存中最大的一块区域,主要用于存储对象实例。当我们创建对象时,JVM 会在堆中为其分配内存。如果堆中没有足够的空间分配给新创建的对象,并且垃圾收集器也无法回收出足够的内存时,就会抛出java.lang.OutOfMemoryError: Java heap space异常。

堆内存的大小可以通过 JVM 参数-Xms(初始堆大小)和-Xmx(最大堆大小)来设置。默认情况下,JVM 会根据物理内存自动计算这些值,但在生产环境中,我们通常需要手动配置以满足应用需求。

堆内存结构可以用以下架构图表示:

  • 年轻代(Young Generation):用于存放新创建的对象,分为 Eden 区和两个 Survivor 区(From 和 To)
  • 老年代(Old Generation):用于存放经过多次垃圾回收仍然存活的对象
  • 当年轻代空间不足时,会触发 Minor GC;当老年代空间不足时,会触发 Major GC(Full GC)

堆内存 OOM 的产生流程如下:

1.2 常见场景

堆内存 OOM 通常发生在以下场景:

  1. 一次性加载大量数据到内存(如批量处理大量数据、加载大型文件)
  2. 内存泄漏导致对象无法被回收,逐渐耗尽堆空间
  3. 应用所需内存超过了 JVM 配置的最大堆内存
  4. 不当的缓存策略,缓存对象过多且未设置过期时间

1.3 代码示例

下面的代码将模拟堆内存 OOM 的场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.List;

/**
 * 堆内存OOM演示
 * 运行时需要设置JVM参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
 *
 * @author ken
 */
@Slf4j
public class HeapOomDemo {
    /**
     * 用于存储大量对象的列表
     */
    private static final List<byte[]> DATA_LIST = new ArrayList<>();

    /**
     * 模拟堆内存溢出
     * 不断向列表中添加大字节数组,直到堆内存耗尽
     */
    public static void simulateHeapOom() {
        int count = 0;
        try {
            // 每次添加1MB大小的字节数组
            while (true) {
                byte[] data = new byte[1024 * 1024];
                DATA_LIST.add(data);
                count++;
                log.info("已添加{}个1MB数组,当前列表大小: {}MB", count, count);
            }
        } catch (OutOfMemoryError e) {
            log.error("发生堆内存OOM,共添加了{}个1MB数组", count, e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateHeapOom();
    }
}
代码语言:javascript
复制

运行上述代码时,需要设置 JVM 参数:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof。这些参数的含义是:

  • -Xms20m:初始堆大小为 20MB
  • -Xmx20m:最大堆大小为 20MB
  • -XX:+HeapDumpOnOutOfMemoryError:发生 OOM 时自动生成堆转储文件
  • -XX:HeapDumpPath=./heapdump.hprof:堆转储文件的保存路径

运行后,程序会不断创建 1MB 大小的字节数组并添加到列表中,直到堆内存耗尽,此时会抛出java.lang.OutOfMemoryError: Java heap space异常,并在指定路径生成堆转储文件。

1.4 排查与解决方法

1.4.1 排查工具

排查堆内存 OOM 常用的工具包括:

  1. JDK 自带工具:
    • jmap:生成堆转储文件、查看堆内存使用情况
    • jhat:分析堆转储文件(较老,推荐使用 VisualVM)
    • jconsole:实时监控 JVM 内存使用情况
    • jvisualvm:可视化监控和分析工具
  2. 第三方工具:
    • Eclipse MAT(Memory Analyzer Tool):强大的堆转储分析工具
    • YourKit Java Profiler:商业级性能分析工具
    • Arthas:阿里巴巴开源的 Java 诊断工具
1.4.2 排查步骤
  1. 生成堆转储文件:
    • 可以通过 JVM 参数-XX:+HeapDumpOnOutOfMemoryError在 OOM 时自动生成
    • 也可以使用jmap -dump:format=b,file=heapdump.hprof <pid>手动生成(<pid>为进程 ID)
  2. 分析堆转储文件:
    • 使用 MAT 打开堆转储文件,查看 "Leak Suspects"(内存泄漏嫌疑)报告
    • 分析占用内存最多的对象类型和引用链
    • 确定哪些对象没有被正确回收及其原因
  3. 定位问题代码:
    • 根据堆转储分析结果,找到创建大量对象或导致内存泄漏的代码位置
    • 检查对象的生命周期和引用关系
1.4.3 解决方案

针对堆内存 OOM,常见的解决方案包括:

  1. 调整堆内存大小:
    • 根据应用需求和服务器内存情况,适当增大堆内存(调整-Xms-Xmx参数)
    • 注意:单纯增大堆内存只是缓解措施,不能解决内存泄漏问题
  2. 优化对象创建和使用:
    • 避免创建不必要的对象,特别是大对象
    • 使用对象池复用频繁创建和销毁的对象
    • 及时释放不再使用的对象引用(将引用置为 null)
  3. 解决内存泄漏问题:
    • 检查静态集合是否无限增长
    • 排查长生命周期对象持有短生命周期对象的引用
    • 注意监听器、回调函数等的注册和注销
    • 检查资源是否正确关闭(如数据库连接、文件流等)
  4. 优化数据处理方式:
    • 对于大量数据,采用分批处理而非一次性加载
    • 使用流式处理(Stream API)处理大数据集
    • 考虑使用磁盘存储代替内存存储

二、栈内存 OOM(java.lang.OutOfMemoryError: unable to create new native thread)

栈内存 OOM(无法创建新的本地线程)是另一种常见的 OOM 错误,尤其在高并发场景下容易出现。与堆内存 OOM 不同,它的产生原因和解决方案都有其特殊性。

2.1 底层原理

在 Java 中,每个线程都有自己的虚拟机栈(JVM Stack),用于存储方法调用的栈帧。虚拟机栈的大小可以通过-Xss参数设置,默认值因 JVM 版本和操作系统而异(通常为 1MB)。

当创建一个新线程时,JVM 需要向操作系统申请内存来创建线程的栈空间。如果操作系统无法为新线程分配足够的内存,就会抛出java.lang.OutOfMemoryError: unable to create new native thread异常。

导致这种情况的原因主要有两个:

  1. 系统资源限制:操作系统对进程可创建的线程数量有限制,超过这个限制就无法创建新线程
  2. 内存资源不足:系统剩余内存不足,无法为新线程分配栈空间

线程创建与内存分配的关系可以用以下流程图表示:

2.2 常见场景

栈内存 OOM(无法创建新线程)通常发生在以下场景:

  1. 高并发场景下,短时间内创建大量线程
  2. 线程池配置不当,核心线程数和最大线程数设置过高
  3. 线程没有正确终止,导致线程数量不断累积
  4. 服务器内存不足,或栈内存设置过大导致可创建的线程总数减少

2.3 代码示例

下面的代码将模拟无法创建新线程的 OOM 场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;

/**
 * 无法创建新线程的OOM演示
 * 运行时可以设置JVM参数: -Xss1m (增大栈内存,减少可创建的线程总数)
 *
 * @author ken
 */
@Slf4j
public class ThreadCreationOomDemo {
    /**
     * 记录已创建的线程数量
     */
    private static int threadCount = 0;

    /**
     * 模拟不断创建新线程,直到无法创建
     */
    public static void simulateThreadCreationOom() {
        try {
            while (true) {
                threadCount++;
                // 创建并启动新线程
                new Thread(() -> {
                    try {
                        // 让线程一直运行,不终止
                        Thread.sleep(Integer.MAX_VALUE);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                        log.error("线程被中断", e);
                    }
                }).start();

                if (threadCount % 100 == 0) {
                    log.info("已创建{}个线程", threadCount);
                }
            }
        } catch (OutOfMemoryError e) {
            log.error("发生无法创建新线程的OOM,共创建了{}个线程", threadCount, e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateThreadCreationOom();
    }
}
代码语言:javascript
复制

运行上述代码时,可以通过-Xss参数调整栈内存大小。栈内存设置越大(如-Xss1m),每个线程占用的内存就越多,系统可创建的线程总数就越少,从而更快地触发 OOM。

程序会不断创建新线程,每个线程都会进入无限休眠状态(不释放资源),直到达到系统线程数限制或内存不足,此时会抛出java.lang.OutOfMemoryError: unable to create new native thread异常。

2.4 排查与解决方法

2.4.1 排查工具

排查无法创建新线程的问题常用的工具包括:

  1. 操作系统工具:
    • ps:查看进程的线程数量(ps -T -p <pid>
    • top:查看系统整体资源使用情况(top -H -p <pid>查看特定进程的线程)
    • ulimit:查看和修改系统资源限制(ulimit -a
  2. JDK 工具:
    • jstack:查看 Java 线程栈信息
    • jconsole/jvisualvm:监控线程数量变化
2.4.2 排查步骤
  1. 确认线程数量:
    • 使用ps -T -p <pid>查看进程的线程总数
    • 对比系统允许的最大线程数(可以通过ulimit -u查看)
  2. 分析线程来源:
    • 使用jstack <pid>生成线程栈信息
    • 分析线程的类型和来源,确定哪些组件在创建大量线程
  3. 检查系统资源:
    • 查看系统内存使用情况(free -m
    • 查看系统文件描述符使用情况(lsof -p <pid> | wc -l
2.4.3 解决方案

针对无法创建新线程的 OOM,常见的解决方案包括:

优化线程池配置:

代码语言:javascript
复制
// 合理的线程池配置示例
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    5, // 核心线程数
    20, // 最大线程数
    60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000), // 较大的队列
    new ThreadPoolExecutor.CallerRunsPolicy() // 当队列满时,让提交任务的线程执行任务
);
代码语言:javascript
复制

  • 合理设置线程池的核心线程数、最大线程数和队列大小
  • 根据任务类型和系统资源调整参数,避免线程过多
  • 示例:

减少单个线程的栈内存:

  • 通过-Xss参数适当减小栈内存(如-Xss256k
  • 注意:栈内存过小时可能导致栈溢出(StackOverflowError)

解决线程泄漏问题:

  • 确保线程能够正确终止,避免创建 "僵尸线程"
  • 检查线程池是否被正确关闭
  • 避免在循环中创建新线程,应使用线程池复用线程

调整系统资源限制:

  • 适当提高系统对进程的线程数限制(ulimit -u <number>
  • 提高系统文件描述符限制(ulimit -n <number>
  • 注意:这种方法只是缓解措施,根本解决还需优化应用

采用异步非阻塞架构:

  • 使用 Netty、Spring WebFlux 等异步框架,减少对线程的依赖
  • 单线程可以处理大量并发请求,显著减少线程数量

三、栈内存溢出(java.lang.StackOverflowError)

栈内存溢出(StackOverflowError)虽然名称中没有 "OutOfMemoryError",但它也是一种常见的内存相关错误,容易与栈内存 OOM 混淆。实际上,它们的产生原因和表现形式都有很大差异。

3.1 底层原理

Java 虚拟机栈是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被调用时,都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

当一个方法被调用时,对应的栈帧会被压入虚拟机栈;当方法执行完成时,栈帧会被弹出并销毁。如果方法调用层级过深,导致虚拟机栈中堆积的栈帧过多,超过了虚拟机栈的最大容量,就会抛出java.lang.StackOverflowError

栈帧的创建与销毁流程如下:

栈内存溢出与栈内存 OOM 的主要区别:

  • 栈内存溢出(StackOverflowError):线程调用栈过深,单个线程的栈帧数量超过限制
  • 栈内存 OOM(unable to create new native thread):无法创建新线程,通常是因为线程总数过多

3.2 常见场景

栈内存溢出通常发生在以下场景:

  1. 递归调用没有正确的终止条件,导致无限递归
  2. 方法调用层级过深(如深层次的调用链)
  3. 栈内存设置过小(-Xss参数值太小)
  4. 方法中定义了大量的局部变量,导致单个栈帧过大

3.3 代码示例

下面的代码将模拟栈内存溢出的场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;

/**
 * 栈内存溢出(StackOverflowError)演示
 * 运行时可以设置JVM参数: -Xss128k (减小栈内存,更快触发溢出)
 *
 * @author ken
 */
@Slf4j
public class StackOverflowDemo {
    /**
     * 记录递归调用次数
     */
    private static int recursionCount = 0;

    /**
     * 递归方法,没有终止条件,会导致栈溢出
     */
    public static void recursiveMethod() {
        recursionCount++;
        // 每1000次调用打印一次日志
        if (recursionCount % 1000 == 0) {
            log.info("递归调用次数: {}", recursionCount);
        }
        // 递归调用自身,没有终止条件
        recursiveMethod();
    }

    /**
     * 演示栈溢出
     */
    public static void simulateStackOverflow() {
        try {
            recursiveMethod();
        } catch (StackOverflowError e) {
            log.error("发生栈内存溢出,递归调用次数: {}", recursionCount, e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateStackOverflow();
    }
}
代码语言:javascript
复制

运行上述代码时,可以通过-Xss参数调整栈内存大小。栈内存设置越小(如-Xss128k),递归调用次数越少就会触发溢出;栈内存设置越大(如-Xss1m),可以支持的递归调用次数就越多。

程序会不断递归调用recursiveMethod()方法,每次调用都会在虚拟机栈中创建一个新的栈帧,直到栈深度超过限制,此时会抛出java.lang.StackOverflowError

3.4 排查与解决方法

3.4.1 排查工具

排查栈内存溢出常用的工具包括:

  1. JDK 自带工具:
    • jstack:获取线程栈信息,查看方法调用链
    • jconsole/jvisualvm:监控线程状态
  2. 日志分析工具:
    • 分析包含 StackOverflowError 的日志,查看异常堆栈信息
3.4.2 排查步骤
  1. 查看异常堆栈:
    • StackOverflowError 的异常信息中包含完整的方法调用链
    • 分析调用链,确定递归调用的起点和循环调用的方法
  2. 确定溢出原因:
    • 检查是否存在无限递归(没有终止条件或终止条件无法触发)
    • 检查方法调用链是否过长
    • 检查单个方法是否有过多的局部变量
3.4.3 解决方案

针对栈内存溢出,常见的解决方案包括:

修复递归逻辑:

代码语言:javascript
复制
// 修复后的递归方法,包含正确的终止条件
public static int factorial(int n) {
    // 终止条件
    if (n <= 1) {
        return 1;
    }
    // 递归调用
    return n * factorial(n - 1);
}
代码语言:javascript
复制

  • 为递归调用添加正确的终止条件
  • 检查递归条件是否能够正常触发,避免无限递归
  • 示例:

增加栈内存大小:

  • 通过-Xss参数适当增大栈内存(如-Xss512k-Xss1m
  • 注意:栈内存过大会减少可创建的线程总数,可能导致 "unable to create new native thread"

优化方法调用链:

代码语言:javascript
复制
// 迭代实现替代递归
public static int factorialIterative(int n) {
    int result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}
代码语言:javascript
复制

  • 减少方法调用层级,避免过深的调用链
  • 将深层次的递归调用改为迭代实现
  • 示例:将递归改为迭代

优化方法内部实现:

  • 减少方法中的局部变量数量,减小单个栈帧的大小
  • 将复杂方法拆分为多个简单方法,分散局部变量

四、直接内存 OOM(java.lang.OutOfMemoryError: Direct buffer memory)

直接内存(Direct Memory)是 Java NIO 引入的一种内存分配方式,它不属于 JVM 堆内存,而是直接从操作系统分配的内存。直接内存 OOM 在使用 NIO 的应用中比较常见,且排查起来相对困难。

4.1 底层原理

直接内存的分配不会受到 Java 堆大小的限制,但会受到系统总内存大小的限制。在 Java 中,通过java.nio.ByteBufferallocateDirect(int capacity)方法可以分配直接内存。

直接内存与 Java 堆内存的关系如下:

直接内存的回收机制比较特殊:

  1. 直接内存不受 JVM 垃圾收集的直接管理,但会受到引用机制的影响
  2. 当直接 ByteBuffer 对象被回收时,会触发其关联的直接内存的回收
  3. 直接内存的回收需要通过sun.misc.Cleaner(JDK 9 + 中为java.lang.ref.Cleaner)机制实现

直接内存 OOM 的产生原因:

  1. 分配的直接内存总量超过了系统可用内存
  2. 直接内存没有被及时回收,导致内存泄漏
  3. 通过-XX:MaxDirectMemorySize参数限制了直接内存大小,且实际使用超过了这个限制

4.2 常见场景

直接内存 OOM 通常发生在以下场景:

  1. 使用 NIO 进行大量数据传输(如网络通信、文件读写)时,分配了过多的直接内存
  2. 直接 ByteBuffer 对象被长期引用,导致其关联的直接内存无法回收
  3. 未设置-XX:MaxDirectMemorySize参数,或设置过小,而应用需要大量直接内存
  4. 直接内存泄漏(如缓存直接 ByteBuffer 但未设置过期清理机制)

4.3 代码示例

下面的代码将模拟直接内存 OOM 的场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

/**
 * 直接内存OOM演示
 * 运行时需要设置JVM参数: -Xms20m -Xmx20m -XX:MaxDirectMemorySize=20m
 *
 * @author ken
 */
@Slf4j
public class DirectMemoryOomDemo {
    /**
     * 存储直接ByteBuffer的列表
     */
    private static final List<ByteBuffer> BUFFER_LIST = new ArrayList<>();

    /**
     * 模拟直接内存OOM
     * 不断分配直接内存,直到无法分配
     */
    public static void simulateDirectMemoryOom() {
        int count = 0;
        try {
            // 每次分配1MB的直接内存
            while (true) {
                ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024);
                BUFFER_LIST.add(buffer);
                count++;
                log.info("已分配{}个1MB直接内存缓冲区", count);
            }
        } catch (OutOfMemoryError e) {
            log.error("发生直接内存OOM,共分配了{}个1MB直接内存缓冲区", count, e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateDirectMemoryOom();
    }
}
代码语言:javascript
复制

运行上述代码时,需要设置 JVM 参数:-Xms20m -Xmx20m -XX:MaxDirectMemorySize=20m。这些参数的含义是:

  • -Xms20m -Xmx20m:将堆内存限制为 20MB,避免堆内存 OOM 干扰
  • -XX:MaxDirectMemorySize=20m:将直接内存限制为 20MB

程序会不断分配 1MB 大小的直接内存缓冲区并添加到列表中,直到超过直接内存限制,此时会抛出java.lang.OutOfMemoryError: Direct buffer memory异常。

4.4 排查与解决方法

4.4.1 排查工具

排查直接内存 OOM 常用的工具包括:

  1. 操作系统工具:
    • top/htop:查看进程的总内存使用情况
    • free:查看系统整体内存使用情况
  2. JDK 工具:
    • jconsole/jvisualvm:监控 JVM 内存使用情况(直接内存会显示在 "非堆" 内存中)
    • jmap:查看堆内存中的直接 ByteBuffer 对象
    • jcmd:查看 JVM 内存统计信息(jcmd <pid> VM.native_memory
  3. 第三方工具:
    • Arthas:使用vmdirectmemory命令查看直接内存使用情况
4.4.2 排查步骤
  1. 确认直接内存使用情况:
    • 使用jcmd <pid> VM.native_memory summary查看直接内存分配情况
    • 检查是否超过-XX:MaxDirectMemorySize设置的值
  2. 分析直接内存分配来源:
    • 使用jmap -histo:live <pid>查看直接 ByteBuffer 对象的数量和大小
    • 分析哪些组件在大量分配直接内存
  3. 检查直接内存回收情况:
    • 检查直接 ByteBuffer 对象是否被正确回收
    • 查看是否存在直接 ByteBuffer 对象的内存泄漏
4.4.3 解决方案

针对直接内存 OOM,常见的解决方案包括:

调整直接内存大小:

  • 通过-XX:MaxDirectMemorySize参数适当增大直接内存限制
  • 示例:-XX:MaxDirectMemorySize=1G(设置直接内存最大为 1GB)

优化直接内存使用:

代码语言:javascript
复制
// 直接ByteBuffer对象池示例
public class DirectBufferPool {
    private final Queue<ByteBuffer> pool;
    private final int bufferSize;
    private final int maxPoolSize;

    public DirectBufferPool(int bufferSize, int maxPoolSize) {
        this.bufferSize = bufferSize;
        this.maxPoolSize = maxPoolSize;
        this.pool = new ArrayDeque<>(maxPoolSize);
    }

    // 获取缓冲区
    public ByteBuffer acquire() {
        ByteBuffer buffer = pool.poll();
        return buffer != null ? buffer : ByteBuffer.allocateDirect(bufferSize);
    }

    // 释放缓冲区到池
    public void release(ByteBuffer buffer) {
        if (buffer.capacity() != bufferSize) {
            return; // 不回收非标准大小的缓冲区
        }
        buffer.clear();
        if (pool.size() < maxPoolSize) {
            pool.offer(buffer);
        }
    }
}
代码语言:javascript
复制

  • 复用直接 ByteBuffer 对象,避免频繁创建和销毁
  • 使用对象池管理直接 ByteBuffer,控制总数量
  • 示例:

主动释放直接内存:

代码语言:javascript
复制
import sun.misc.Cleaner;
import java.nio.ByteBuffer;
import java.lang.reflect.Field;

public class DirectMemoryUtils {
    /**
     * 主动释放直接内存
     */
    @SuppressWarnings("unchecked")
    public static void cleanDirectBuffer(ByteBuffer buffer) {
        if (buffer == null || !buffer.isDirect()) {
            return;
        }

        try {
            // 通过反射获取Cleaner对象
            Field cleanerField = buffer.getClass().getDeclaredField("cleaner");
            cleanerField.setAccessible(true);
            Cleaner cleaner = (Cleaner) cleanerField.get(buffer);
            if (cleaner != null) {
                cleaner.clean();
            }
        } catch (Exception e) {
            log.error("释放直接内存失败", e);
        }
    }
}
代码语言:javascript
复制

  • 对于不再使用的直接 ByteBuffer,调用Cleaner.clean()主动释放
  • 示例:

解决直接内存泄漏:

  • 检查是否有长期持有直接 ByteBuffer 引用的情况
  • 对于缓存直接 ByteBuffer 的场景,设置合理的过期和淘汰机制
  • 避免在静态集合中无限存储直接 ByteBuffer

五、GC OOM(java.lang.OutOfMemoryError: GC overhead limit exceeded)

GC OOM(GC overhead limit exceeded)是一种特殊的内存错误,它不是因为内存绝对不足,而是因为垃圾收集花费了过多的时间但回收效果不佳,JVM 为了避免系统不可用而主动抛出的异常。

5.1 底层原理

JVM 会监控垃圾收集的时间和效率,如果满足以下条件,就会抛出java.lang.OutOfMemoryError: GC overhead limit exceeded异常:

  1. 垃圾收集器花费了超过 98% 的时间用于垃圾收集
  2. 垃圾收集只回收了不到 2% 的内存
  3. 上述情况连续发生了 5 次

这种机制的设计初衷是:当 GC 效率极低时,系统已经接近不可用状态(大部分 CPU 时间都用于 GC,而不是执行实际业务),此时不如抛出 OOM 异常让应用终止,以便于快速恢复。

GC OOM 的触发流程如下:

GC OOM 通常表明应用存在严重的内存问题,可能是内存泄漏,也可能是内存不足。

5.2 常见场景

GC OOM 通常发生在以下场景:

  1. 内存泄漏导致老年代对象不断累积,GC 无法有效回收
  2. 应用内存不足,对象刚刚被回收就需要再次创建,导致 GC 频繁触发
  3. 大对象分配过多,导致老年代频繁触发 Full GC
  4. 不当的 GC 参数配置,导致 GC 效率低下

5.3 代码示例

下面的代码将模拟 GC OOM 的场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;

/**
 * GC OOM演示
 * 运行时需要设置JVM参数: -Xms5m -Xmx5m -XX:+PrintGCDetails
 *
 * @author ken
 */
@Slf4j
public class GcOverheadOomDemo {
    /**
     * 存储部分生命周期较长的对象
     */
    private static final List<Object> LONG_LIFE_OBJECTS = new ArrayList<>();

    /**
     * 模拟GC OOM
     * 创建大量对象,其中一部分被长期引用,一部分被短期引用
     * 导致GC频繁执行但回收效果不佳
     */
    public static void simulateGcOverheadOom() {
        int count = 0;
        try {
            while (true) {
                // 添加一些长期存在的对象(占用部分内存)
                if (count % 10 == 0) {
                    LONG_LIFE_OBJECTS.add(new byte[1024 * 10]); // 10KB
                }
                // 创建大量短期对象(很快会成为垃圾)
                byte[] shortLifeObject = new byte[1024 * 5]; // 5KB
                count++;
                if (count % 100 == 0) {
                    log.info("已创建{}个对象,长期对象数量: {}", count, LONG_LIFE_OBJECTS.size());
                }
            }
        } catch (OutOfMemoryError e) {
            log.error("发生GC OOM,已创建{}个对象,长期对象数量: {}", count, LONG_LIFE_OBJECTS.size(), e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateGcOverheadOom();
    }
}
代码语言:javascript
复制

运行上述代码时,需要设置 JVM 参数:-Xms5m -Xmx5m -XX:+PrintGCDetails。这些参数的含义是:

  • -Xms5m -Xmx5m:将堆内存限制为 5MB,制造内存紧张的环境
  • -XX:+PrintGCDetails:打印 GC 详细日志,便于观察 GC 情况

程序会不断创建对象,其中一部分对象被添加到LONG_LIFE_OBJECTS列表中长期持有,另一部分则是短期对象。随着时间推移,长期对象会占用大部分内存,导致 GC 频繁执行但只能回收少量内存(短期对象),最终触发java.lang.OutOfMemoryError: GC overhead limit exceeded异常。

5.4 排查与解决方法

5.4.1 排查工具

排查 GC OOM 常用的工具包括:

  1. JDK 自带工具:
    • jstat:监控 GC 统计信息(jstat -gc <pid> 1000每秒输出一次 GC 信息)
    • jmap:生成堆转储文件,分析对象分布
    • jconsole/jvisualvm:监控 GC 次数、时间和内存变化
  2. 日志分析工具:
    • 分析 GC 日志,查看 GC 次数、耗时和回收效果
    • 分析 OOM 时的堆转储文件
5.4.2 排查步骤
  1. 分析 GC 日志:
    • 查看 GC 次数是否异常频繁(尤其是 Full GC)
    • 计算 GC 时间占总时间的比例,确认是否超过 98%
    • 检查每次 GC 回收的内存比例,确认是否低于 2%
  2. 分析堆内存使用情况:
    • 生成堆转储文件,分析对象分布
    • 确定哪些对象占用了大量内存
    • 检查这些对象是否应该被回收而没有被回收
  3. 确定问题类型:
    • 是内存泄漏(对象无法被回收)还是内存不足(对象确实需要那么多内存)
    • 是对象创建速度过快,还是对象生命周期过长
5.4.3 解决方案

针对 GC OOM,常见的解决方案包括:

解决内存泄漏问题:

  • 找出并修复导致对象无法被回收的内存泄漏问题
  • 检查静态集合、缓存、监听器等是否正确管理对象生命周期

调整堆内存大小:

  • 适当增大堆内存(调整-Xms-Xmx参数)
  • 确保堆内存能够容纳应用正常运行所需的对象

优化对象创建和使用:

  • 减少不必要的对象创建,尤其是大对象
  • 复用对象,使用对象池减少对象创建频率
  • 及时释放不再使用的对象引用

调整 GC 参数:

代码语言:javascript
复制
-XX:+UseG1GC -XX:G1HeapRegionSize=16m -XX:MaxGCPauseMillis=200
代码语言:javascript
复制

  • 调整新生代和老年代的比例(-XX:NewRatio-XX:SurvivorRatio
  • 选择合适的 GC 收集器(如 G1、ZGC 等)
  • 调整 GC 触发阈值和收集频率
  • 示例:使用 G1 收集器并调整参数

禁用 GC overhead limit 检查(不推荐):

  • 通过-XX:-UseGCOverheadLimit参数禁用 GC overhead limit 检查
  • 注意:这只是掩盖问题,不会解决根本问题,可能导致应用长时间无响应

六、元空间 OOM(java.lang.OutOfMemoryError: Metaspace)

元空间(Metaspace)是 JDK 8 及以上版本中用于存储类元数据的区域,它替代了 JDK 7 及之前的永久代(PermGen)。元空间 OOM 在使用大量动态生成类的应用中比较常见。

6.1 底层原理

元空间用于存储以下信息:

  • 类的结构信息(类名、父类、接口等)
  • 字段信息(字段名、类型、访问修饰符等)
  • 方法信息(方法名、参数、返回值、访问修饰符等)
  • 常量池
  • 方法字节码
  • 符号引用

与永久代不同,元空间并不在 JVM 堆内存中,而是使用本地内存(Native Memory)。元空间的大小默认不受限制,只受限于系统可用内存,但也可以通过 JVM 参数进行限制。

元空间的结构可以用以下架构图表示:

元空间 OOM 的产生原因:

  1. 加载的类数量过多,导致元空间占用的本地内存超过限制
  2. 通过-XX:MaxMetaspaceSize参数限制了元空间大小,且实际使用超过了这个限制
  3. 类加载器泄漏,导致已加载的类无法被卸载,元空间不断增长

6.2 常见场景

元空间 OOM 通常发生在以下场景:

  1. 使用动态代理(如 CGLIB、JDK 动态代理)生成大量动态类
  2. 使用反射频繁创建类或修改类结构
  3. 应用中使用了大量的类库,导致加载的类数量过多
  4. 类加载器泄漏(如 Web 容器热部署不当导致的类加载器无法回收)
  5. 元空间大小被限制得过小(-XX:MaxMetaspaceSize参数值太小)

6.3 代码示例

下面的代码将模拟元空间 OOM 的场景:

代码语言:javascript
复制
import lombok.extern.slf4j.Slf4j;
import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;

/**
 * 元空间OOM演示
 * 需要添加cglib依赖
 * 运行时需要设置JVM参数: -XX:MaxMetaspaceSize=20m
 *
 * @author ken
 */
@Slf4j
public class MetaspaceOomDemo {
    /**
     * 用于动态代理的目标类
     */
    static class TargetClass {}

    /**
     * 方法拦截器,用于cglib动态代理
     */
    static class TargetInterceptor implements MethodInterceptor {
        @Override
        public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
            return proxy.invokeSuper(obj, args);
        }
    }

    /**
     * 模拟元空间OOM
     * 使用cglib动态生成大量类,直到元空间耗尽
     */
    public static void simulateMetaspaceOom() {
        int count = 0;
        try {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(TargetClass.class);
            enhancer.setCallback(new TargetInterceptor());

            // 不断生成TargetClass的子类
            while (true) {
                Class<?> proxyClass = enhancer.createClass();
                count++;
                if (count % 100 == 0) {
                    log.info("已生成{}个动态类", count);
                }
                // 为了避免堆内存OOM,不保存生成的类实例
                // 只让类元数据保存在元空间中
            }
        } catch (OutOfMemoryError e) {
            log.error("发生元空间OOM,共生成{}个动态类", count, e);
            throw e;
        }
    }

    public static void main(String[] args) {
        simulateMetaspaceOom();
    }
}
代码语言:javascript
复制

运行上述代码需要添加 CGLIB 依赖:

代码语言:javascript
复制
<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.3.0</version>
</dependency>
代码语言:javascript
复制

运行时需要设置 JVM 参数:-XX:MaxMetaspaceSize=20m,该参数将元空间大小限制为 20MB。

程序会使用 CGLIB 不断动态生成TargetClass的子类,每个类的元数据都会存储在元空间中,直到元空间耗尽,此时会抛出java.lang.OutOfMemoryError: Metaspace异常。

6.4 排查与解决方法

6.4.1 排查工具

排查元空间 OOM 常用的工具包括:

  1. JDK 自带工具:
    • jstat:监控元空间使用情况(jstat -class <pid> 1000
    • jmap:查看类加载统计(jmap -clstats <pid>
    • jconsole/jvisualvm:监控元空间大小变化
  2. 第三方工具:
    • Arthas:使用classloader命令查看类加载器和加载的类数量
    • MAT:分析堆转储中的类加载器和类信息
6.4.2 排查步骤
  1. 确认元空间使用情况:
    • 使用jstat -class <pid>查看类加载数量和元空间大小
    • 检查是否超过-XX:MaxMetaspaceSize设置的值
  2. 分析类加载情况:
    • 使用jmap -clstats <pid>查看类加载器统计信息
    • 确定哪些类加载器加载了大量类
    • 检查是否有异常的类加载器数量增长
  3. 确定类来源:
    • 分析加载的类的包名和名称,确定这些类的来源
    • 检查是否有大量动态生成的类
    • 检查是否存在类加载器泄漏
6.4.3 解决方案

针对元空间 OOM,常见的解决方案包括:

调整元空间大小:

  • 通过-XX:MaxMetaspaceSize参数适当增大元空间限制
  • 示例:-XX:MaxMetaspaceSize=256m(设置元空间最大为 256MB)
  • 也可以调整元空间初始大小:-XX:MetaspaceSize=128m

优化动态类生成:

代码语言:javascript
复制
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import net.sf.cglib.proxy.Enhancer;
import java.util.concurrent.TimeUnit;

public class ProxyClassCache {
    // 缓存代理类,过期时间10分钟
    private static final Cache<Class<?>, Class<?>> PROXY_CLASS_CACHE = CacheBuilder.newBuilder()
        .expireAfterAccess(10, TimeUnit.MINUTES)
        .maximumSize(1000)
        .build();

    /**
     * 获取或创建代理类
     */
    public static Class<?> getProxyClass(Class<?> targetClass, MethodInterceptor interceptor) {
        try {
            // 从缓存获取
            Class<?> proxyClass = PROXY_CLASS_CACHE.getIfPresent(targetClass);
            if (proxyClass == null) {
                // 缓存中没有,创建新的代理类
                Enhancer enhancer = new Enhancer();
                enhancer.setSuperclass(targetClass);
                enhancer.setCallback(interceptor);
                proxyClass = enhancer.createClass();
                // 放入缓存
                PROXY_CLASS_CACHE.put(targetClass, proxyClass);
            }
            return proxyClass;
        } catch (Exception e) {
            log.error("获取代理类失败", e);
            throw new RuntimeException(e);
        }
    }
}
代码语言:javascript
复制

  • 减少动态生成类的数量,避免不必要的动态代理
  • 复用动态生成的类,避免重复生成相同功能的类
  • 示例:缓存 CGLIB 生成的代理类

解决类加载器泄漏:

  • 确保类加载器能够被正确回收
  • 在 Web 应用热部署时,确保旧的类加载器被完全释放
  • 避免在静态变量中持有类加载器或其加载的类的引用

减少类数量:

  • 避免引入不必要的依赖库,减少加载的类数量
  • 对代码进行重构,合并功能相似的类
  • 使用接口和泛型减少类的数量

七、OOM 问题排查方法论与工具总结

面对各种 OOM 问题,我们需要一套系统的排查方法和工具使用技巧,以便快速定位和解决问题。

7.1 OOM 问题排查流程

7.2 常用工具总结

工具

用途

常用命令

jps

查看 Java 进程 ID

jps -l

jstat

监控 JVM 统计信息

jstat -gc <pid> 1000

jmap

生成堆转储和查看内存信息

jmap -dump:format=b,file=heap.hprof <pid>jmap -histo:live <pid>

jstack

查看线程栈信息

jstack <pid>

jconsole

可视化监控工具

jconsole

jvisualvm

功能强大的可视化分析工具

jvisualvm

jcmd

多功能诊断命令

jcmd <pid> VM.native_memoryjcmd <pid> GC.heap_info

MAT

堆转储分析工具

图形界面操作

Arthas

阿里巴巴开源诊断工具

arthas-boot.jardashboard, thread, heapdump 等命令

7.3 预防 OOM 的最佳实践

合理配置 JVM 参数

代码语言:javascript
复制
-Xms2g -Xmx2g -Xss256k -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=512m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:./gc.log
代码语言:javascript
复制

  • 根据应用特性和服务器资源,合理设置堆内存、元空间、直接内存等参数
  • 配置 OOM 时自动生成堆转储:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=./heapdump.hprof
  • 示例配置:

实施内存监控

  • 在生产环境部署内存监控工具(如 Prometheus + Grafana)
  • 设置内存使用阈值告警,及时发现内存异常增长
  • 定期分析内存使用趋势,提前发现潜在问题

代码层面预防

  • 避免创建不必要的对象,尤其是大对象
  • 正确管理对象生命周期,及时释放不再使用的对象
  • 合理使用缓存,并设置适当的过期和淘汰策略
  • 避免无限递归和过深的方法调用链
  • 谨慎使用动态类生成技术,避免生成过多类
  • 使用 try-with-resources 确保资源正确关闭

测试层面预防

  • 进行压力测试和内存泄漏测试
  • 使用内存分析工具在测试环境提前发现问题
  • 模拟低内存环境,验证应用的稳定性

八、总结

OOM 问题是 Java 开发中不可避免的挑战,但通过深入理解各种 OOM 的底层原理,掌握系统的排查方法和工具使用技巧,我们可以有效地定位和解决这些问题。需要强调的是,解决 OOM 问题不能只停留在表面,简单地通过增大内存来 "掩盖" 问题,而应该深入分析根本原因,从代码和架构层面进行优化,才能真正避免问题再次发生。最后,预防胜于治疗。通过合理的 JVM 配置、完善的监控告警和良好的编码实践,我们可以在很大程度上避免 OOM 问题的发生,确保应用的稳定运行。

希望本文能帮助您更好地理解和应对 OOM 问题,让您的 Java 应用更加健壮和可靠。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-09-29,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 果酱带你啃java 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、堆内存 OOM(java.lang.OutOfMemoryError: Java heap space)
    • 1.1 底层原理
    • 1.2 常见场景
    • 1.3 代码示例
    • 1.4 排查与解决方法
      • 1.4.1 排查工具
      • 1.4.2 排查步骤
      • 1.4.3 解决方案
  • 二、栈内存 OOM(java.lang.OutOfMemoryError: unable to create new native thread)
    • 2.1 底层原理
    • 2.2 常见场景
    • 2.3 代码示例
    • 2.4 排查与解决方法
      • 2.4.1 排查工具
      • 2.4.2 排查步骤
      • 2.4.3 解决方案
  • 三、栈内存溢出(java.lang.StackOverflowError)
    • 3.1 底层原理
    • 3.2 常见场景
    • 3.3 代码示例
    • 3.4 排查与解决方法
      • 3.4.1 排查工具
      • 3.4.2 排查步骤
      • 3.4.3 解决方案
  • 四、直接内存 OOM(java.lang.OutOfMemoryError: Direct buffer memory)
    • 4.1 底层原理
    • 4.2 常见场景
    • 4.3 代码示例
    • 4.4 排查与解决方法
      • 4.4.1 排查工具
      • 4.4.2 排查步骤
      • 4.4.3 解决方案
  • 五、GC OOM(java.lang.OutOfMemoryError: GC overhead limit exceeded)
    • 5.1 底层原理
    • 5.2 常见场景
    • 5.3 代码示例
    • 5.4 排查与解决方法
      • 5.4.1 排查工具
      • 5.4.2 排查步骤
      • 5.4.3 解决方案
  • 六、元空间 OOM(java.lang.OutOfMemoryError: Metaspace)
    • 6.1 底层原理
    • 6.2 常见场景
    • 6.3 代码示例
    • 6.4 排查与解决方法
      • 6.4.1 排查工具
      • 6.4.2 排查步骤
      • 6.4.3 解决方案
  • 七、OOM 问题排查方法论与工具总结
    • 7.1 OOM 问题排查流程
    • 7.2 常用工具总结
    • 7.3 预防 OOM 的最佳实践
  • 八、总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档