摘要:作为互联网软件开发同行,你是不是也默认 “一旦 JVM 抛出 OutOfMemoryError,整个应用就凉了”?前几天和团队新人排查线上问题,他看到日志里的 OOM 就急着重启服务,结果反而错过了关键的排查时机 —— 后来我们才发现,当时只是某个非核心线程内
作为互联网软件开发同行,你是不是也默认 “一旦 JVM 抛出 OutOfMemoryError,整个应用就凉了”?前几天和团队新人排查线上问题,他看到日志里的 OOM 就急着重启服务,结果反而错过了关键的排查时机 —— 后来我们才发现,当时只是某个非核心线程内存溢出,主线程还在正常处理请求。
其实在实际开发中,“OOM=JVM 崩溃” 的认知误区,已经让不少人踩过坑。今天咱们就用 3 组代码实验,结合 JVM 底层机制,把 “OOM 后 JVM 能否运行” 这件事讲透,以后不管是面试被问,还是线上排障,都能心里有底。
你肯定遇到过这种情况:本地调试时,程序抛出 OOM 后直接退出;但偶尔在线上日志里,却能看到 OOM 报错后,应用还在继续打印其他线程的日志。这不是矛盾吗?
之前我也困惑过,直到翻了《Java 虚拟机规范》才发现关键:OOM 本质是 “线程级” 的内存异常,不是 “JVM 级” 的致命错误。简单说,当一个线程在申请内存时触发 OOM,JVM 会先标记这个线程为 “待终止” 状态,然后抛出异常 —— 如果这个线程不是主线程,也不是持有关键资源(比如数据库连接池)的核心线程,那其他线程其实能继续运行。
但这里有个前提:触发 OOM 的内存区域,不能是 “JVM 运行必需的区域”。比如方法区(元空间)如果发生 OOM,可能导致类加载失败,后续新对象创建会受影响;而堆内存的 OOM,只要不是所有线程都在抢内存,就有挽回空间。
光说理论不够,咱们直接上代码。实验环境是 JDK 11,JVM 参数设置为-Xms16m -Xmx16m(堆内存固定 16M,方便快速触发 OOM),用 jconsole 监控线程和内存变化。
先写两个线程:ThreadA 负责循环创建大对象(触发堆 OOM),ThreadB 每隔 1 秒打印日志(验证是否存活)。
public class OOMTest1 { public static void main(String args) { // ThreadA:触发OOM的线程 Thread threadA = new Thread( -> { Listlist = 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 崩溃,其他线程可正常运行。
那如果是主线程(main 线程)触发 OOM,子线程会不会跟着挂?咱们把实验 1 改一下,让 main 线程创建大对象,ThreadB 保持不变。
public class OOMTest2 { public static void main(String args) { // 主线程自己创建大对象,触发OOM Listlist = 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 可能会触发 “优雅退出” 机制,主动中断子线程。
前面测的是堆内存 OOM,那方法区(元空间)OOM 呢?元空间存储类信息、常量池,如果这里溢出,JVM 连类都加载不了,还能正常工作吗?
咱们用 CGLIB 动态生成类,触发元空间 OOM(JVM 参数设置-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m):
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 后要不要重启,不能一概而论。这里给你 3 个实战处理步骤,帮你减少不必要的服务中断:
看日志里的 OOM 类型:如果是Java heap space(堆内存),且报错线程是 “定时任务线程”“日志收集线程” 等非核心线程,可以先不重启,观察其他线程是否正常;如果是Metaspace(元空间)、Direct buffer memory(直接内存),或者报错线程是 “主线程”“请求处理线程”,建议立即重启,避免故障扩大。如果决定不立即重启,一定要抓紧时间抓内存快照(jmap)和线程 dump(jstack):
# 抓内存快照(pid是JVM进程号)jmap -dump:format=b,file=heap.hprof [pid]# 抓线程dumpjstack [pid] > thread_dump.txt这些文件能帮你定位 “哪个对象占了太多内存”“哪个线程在疯狂申请资源”,后续优化才有的放矢。
给非核心线程设置 “内存使用上限”,比如用ThreadLocal控制单个线程的对象创建数量;对核心线程(如请求处理线程),在代码中加 “内存检查”,比如定期调用ManagementFactory.getMemoryMXBean查看堆内存使用情况,接近阈值时主动释放资源。OOM 不是 JVM 的 “死刑判决”:单个非核心线程的堆内存 OOM,不会导致 JVM 崩溃,其他线程可正常运行;核心区域 OOM 必须重启:元空间、直接内存等区域 OOM,会让 JVM 失去核心功能,再撑着也没用;排障别慌,先看日志和线程:遇到 OOM 先看 “哪个线程、哪个内存区域” 出问题,再决定是否重启,记得抓快照留证据。最后想问问你:你之前在线上遇到过 OOM 吗?当时是怎么处理的?有没有踩过 “盲目重启” 的坑?欢迎在评论区分享你的经历,咱们一起交流更多 JVM 排障技巧~
来源:左手小拇指