前言
作为互联网软件开发同行,你是不是也默认 "一旦 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 个核心结论,面试排障都有用
- OOM 不是 JVM 的 "死刑判决" :单个非核心线程的堆内存 OOM,不会导致 JVM 崩溃,其他线程可正常运行;
- 核心区域 OOM 必须重启:元空间、直接内存等区域 OOM,会让 JVM 失去核心功能,再撑着也没用;
- 排障别慌,先看日志和线程:遇到 OOM 先看 "哪个线程、哪个内存区域" 出问题,再决定是否重启,记得抓快照留证据。
最后想问问你:你之前在线上遇到过 OOM 吗?当时是怎么处理的?有没有踩过 "盲目重启" 的坑?欢迎在评论区分享你的经历,咱们一起交流更多 JVM 排障技巧~