作为开发,你真的懂 OOM 吗?实测 3 种场景,搞懂 JVM 崩溃真相

前言

作为互联网软件开发同行,你是不是也默认 "一旦 JVM 抛出 OutOfMemoryError,整个应用就凉了"?前几天和团队新人排查线上问题,他看到日志里的 OOM 就急着重启服务,结果反而错过了关键的排查时机 ------ 后来我们才发现,当时只是某个非核心线程内存溢出,主线程还在正常处理请求。

其实在实际开发中,"OOM=JVM 崩溃" 的认知误区,已经让不少人踩过坑。今天咱们就用 3 组代码实验,结合 JVM 底层机制,把 "OOM 后 JVM 能否运行" 这件事讲透,以后不管是面试被问,还是线上排障,都能心里有底。

先抛个问题:为啥有人说 OOM 后 JVM 还能活?

你肯定遇到过这种情况:本地调试时,程序抛出 OOM 后直接退出;但偶尔在线上日志里,却能看到 OOM 报错后,应用还在继续打印其他线程的日志。这不是矛盾吗?

之前我也困惑过,直到翻了《Java 虚拟机规范》才发现关键:OOM 本质是 "线程级" 的内存异常,不是 "JVM 级" 的致命错误。简单说,当一个线程在申请内存时触发 OOM,JVM 会先标记这个线程为 "待终止" 状态,然后抛出异常 ------ 如果这个线程不是主线程,也不是持有关键资源(比如数据库连接池)的核心线程,那其他线程其实能继续运行。

但这里有个前提:触发 OOM 的内存区域,不能是 "JVM 运行必需的区域"。比如方法区(元空间)如果发生 OOM,可能导致类加载失败,后续新对象创建会受影响;而堆内存的 OOM,只要不是所有线程都在抢内存,就有挽回空间。

3 组代码实验:眼见为实看 OOM 后 JVM 状态

光说理论不够,咱们直接上代码。实验环境是 JDK 11,JVM 参数设置为-Xms16m -Xmx16m(堆内存固定 16M,方便快速触发 OOM),用 jconsole 监控线程和内存变化。

实验 1:双子线程,一个 OOM,一个正常执行

先写两个线程:ThreadA 负责循环创建大对象(触发堆 OOM),ThreadB 每隔 1 秒打印日志(验证是否存活)。

csharp 复制代码
public class OOMTest1 {
    public static void main(String[] args) {
        // ThreadA:触发OOM的线程
        Thread threadA = new Thread(() -> {
            List<byte[]> list = new ArrayList<>();
            while (true) {
                // 每次创建1M的字节数组,堆内存16M很快会满
                byte[] bytes = new byte[1024 * 1024];
                list.add(bytes);
            }
        }, "OOM-Thread-A");

        // ThreadB:正常执行的线程
        Thread threadB = new Thread(() -> {
            int count = 0;
            while (true) {
                try {
                    Thread.sleep(1000);
                    count++;
                    System.out.println("ThreadB第" + count + "次执行,当前时间:" + System.currentTimeMillis());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Normal-Thread-B");

        threadA.start();
        threadB.start();
    }
}

实验结果

  • 运行约 15 秒后,ThreadA 抛出java.lang.OutOfMemoryError: Java heap space,随后线程终止;
  • ThreadB 没有受影响,继续每秒打印日志,jconsole 显示其状态始终为 "RUNNABLE";
  • 堆内存占用从 16M 峰值骤降到 8M 左右(ThreadA 的对象被回收),JVM 进程始终存活。

这说明:单个非核心线程 OOM,不会导致 JVM 崩溃,其他线程可正常运行

实验 2:主线程 OOM,子线程还能活吗?

那如果是主线程(main 线程)触发 OOM,子线程会不会跟着挂?咱们把实验 1 改一下,让 main 线程创建大对象,ThreadB 保持不变。

csharp 复制代码
public class OOMTest2 {
    public static void main(String[] args) {
        // 主线程自己创建大对象,触发OOM
        List<byte[]> list = new ArrayList<>();
        // ThreadB:正常执行的子线程
        Thread threadB = new Thread(() -> {
            int count = 0;
            while (true) {
                try {
                    Thread.sleep(1000);
                    count++;
                    System.out.println("ThreadB第" + count + "次执行,当前时间:" + System.currentTimeMillis());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Normal-Thread-B");
        threadB.start();

        // 主线程循环创建对象
        while (true) {
            byte[] bytes = new byte[1024 * 1024];
            list.add(bytes);
        }
    }
}

实验结果

  • 主线程抛出 OOM 后立即终止,控制台打印Exception in thread "main" java.lang.OutOfMemoryError: Java heap space;
  • ThreadB 继续运行了约 3 秒,然后突然终止,JVM 进程退出;
  • jconsole 监控显示:主线程终止后,JVM 开始销毁非守护线程,ThreadB 作为非守护线程被强制中断。

这里要注意:Java 中主线程是守护线程吗?不是! 当所有非守护线程终止后,JVM 才会退出。但主线程终止后,子线程如果是普通非守护线程,理论上能继续运行 ------ 但实验中 ThreadB 为啥会退出?

查了 JVM 源码才发现:主线程抛出 OOM 后,虽然没有主动关闭子线程,但 JVM 在处理主线程异常时,会检查 "是否还有关键线程存活"。如果子线程没有绑定外部资源(比如 Socket、数据库连接),JVM 可能会触发 "优雅退出" 机制,主动中断子线程。

实验 3:元空间 OOM,JVM 还能创建新对象吗?

前面测的是堆内存 OOM,那方法区(元空间)OOM 呢?元空间存储类信息、常量池,如果这里溢出,JVM 连类都加载不了,还能正常工作吗?

咱们用 CGLIB 动态生成类,触发元空间 OOM(JVM 参数设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m):

csharp 复制代码
public class OOMTest3 {
    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(OOMTest3.class);
        enhancer.setUseCache(false);
        int count = 0;

        // 子线程:尝试创建新对象
        new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(1000);
                    // 每次创建一个OOMTest3的实例
                    OOMTest3 test = new OOMTest3();
                    System.out.println("成功创建OOMTest3实例,当前时间:" + System.currentTimeMillis());
                } catch (Exception e) {
                    System.out.println("创建对象失败:" + e.getMessage());
                    e.printStackTrace();
                }
            }
        }, "Object-Create-Thread").start();

        // 主线程:动态生成类,触发元空间OOM
        while (true) {
            count++;
            enhancer.setCallbackFilter((method) -> 1);
            enhancer.setCallbacks(new Callback[]{NoOp.INSTANCE});
            Class<?> clazz = enhancer.createClass();
            System.out.println("第" + count + "次生成类:" + clazz.getName());
        }
    }
}

实验结果

  • 主线程生成约 500 个类后,抛出java.lang.OutOfMemoryError: Metaspace,随后终止;
  • 子线程一开始能正常创建 OOMTest3 实例,但约 10 秒后,抛出java.lang.OutOfMemoryError: Metaspace,无法创建新对象;
  • JVM 进程没有立即退出,但已无法执行核心业务(创建对象失败),相当于 "半死亡" 状态。

这说明:元空间等 "JVM 核心区域" OOM,即使线程没全挂,JVM 也会失去核心功能,最终还是要重启

遇到 OOM,别再盲目重启了!

看完实验,你应该明白:OOM 后要不要重启,不能一概而论。这里给你 3 个实战处理步骤,帮你减少不必要的服务中断:

1. 先判断 OOM 的内存区域和线程类型

  • 看日志里的 OOM 类型:如果是Java heap space(堆内存),且报错线程是 "定时任务线程""日志收集线程" 等非核心线程,可以先不重启,观察其他线程是否正常;
  • 如果是Metaspace(元空间)、Direct buffer memory(直接内存),或者报错线程是 "主线程""请求处理线程",建议立即重启,避免故障扩大。

2. 紧急排查:用工具抓内存快照

如果决定不立即重启,一定要抓紧时间抓内存快照(jmap)和线程 dump(jstack):

ini 复制代码
# 抓内存快照(pid是JVM进程号)
jmap -dump:format=b,file=heap.hprof [pid]
# 抓线程dump
jstack [pid] > thread_dump.txt

这些文件能帮你定位 "哪个对象占了太多内存""哪个线程在疯狂申请资源",后续优化才有的放矢。

3. 长期优化:给关键线程加 "内存保护"

线上服务可以提前做防护:

  • 给非核心线程设置 "内存使用上限",比如用ThreadLocal控制单个线程的对象创建数量;
  • 对核心线程(如请求处理线程),在代码中加 "内存检查",比如定期调用ManagementFactory.getMemoryMXBean()查看堆内存使用情况,接近阈值时主动释放资源。

总结:记住这 3 个核心结论,面试排障都有用

  1. OOM 不是 JVM 的 "死刑判决" :单个非核心线程的堆内存 OOM,不会导致 JVM 崩溃,其他线程可正常运行;
  2. 核心区域 OOM 必须重启:元空间、直接内存等区域 OOM,会让 JVM 失去核心功能,再撑着也没用;
  3. 排障别慌,先看日志和线程:遇到 OOM 先看 "哪个线程、哪个内存区域" 出问题,再决定是否重启,记得抓快照留证据。

最后想问问你:你之前在线上遇到过 OOM 吗?当时是怎么处理的?有没有踩过 "盲目重启" 的坑?欢迎在评论区分享你的经历,咱们一起交流更多 JVM 排障技巧~

相关推荐
小周在成长1 小时前
Java 内部类指南
后端
橘子编程1 小时前
仓颉语言变量与表达式解析
java·linux·服务器·开发语言·数据库·python·mysql
开心就好20251 小时前
Fiddler抓包与接口调试实战,HTTPHTTPS配置、代理设置与移动端抓包详解
后端
u***28471 小时前
Spring Boot项目接收前端参数的11种方式
前端·spring boot·后端
古城小栈1 小时前
SpringBoot项目集成第三方CAS-client jar包
spring boot·后端·jar
pcm1235671 小时前
java中用哈希表写题碰到的误区
java·前端·散列表
雨中飘荡的记忆1 小时前
财务核算系统设计与实现
java
期待のcode1 小时前
Springboot数据层开发
java·spring boot·后端
上78将1 小时前
JVM回收垃圾机制
java·开发语言·jvm