ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

某团面试题:JVM 堆内存溢出后,其他线程是否还能继续工作?

某团面试题:JVM 堆内存溢出后,其他线程是否还能继续工作? 一、一道面试题为什么值得讲两万字很多同学在准备 Java 后端面试时都见过这样一道题假如 JVM 堆内存发生 OutOfMemoryError也就是我们常说的堆内存溢出那么其他线程还能继续工作吗初看之下这似乎是一道“背结论”的八股题。有人会脱口而出“不能因为堆都满了整个进程肯定挂了。”也有人会回答“能因为 OOM 只是一个 Error可以被 catch其他线程不受影响。”这两种说法都只说对了一半。这道题真正考察的并不是让你记住一个二元的“能”或者“不能”而是希望你能把下面这一整套知识串起来OutOfMemoryError 和普通 Exception 之间的关系JVM 运行时数据区里哪些区域是线程私有的哪些是线程共享的一次 OOM 到底是“杀死一个线程”还是“杀死整个进程”堆被占满之后其他线程每执行一行代码到底还需要不需要再分配堆内存为什么有些系统明明进程没挂对外却已经不可用了生产环境里我们应该如何做快速失败、熔断、兜底和监控。理解清楚这些之后这道题的答案自然就清晰了堆内存溢出后其他线程“理论上”可能继续工作但“实际上”往往处于一种极度脆弱、随时可能再失败的状态进程是否退出取决于异常是否被捕获、是否还有非守护线程存活以及 JVM 是否配置了退出或崩溃策略。下面我们从一个最小的内存模型开始把这个问题一层一层拆开。文章较长建议跟着代码实验一起看每一个结论都有对应的可运行示例。二、先把概念对齐OOM 不是“程序崩溃”2.1 OutOfMemoryError 的本质在 Java 的异常体系里所有“问题”都继承自Throwable它下面有两个直接子类Exception和Error。我们平时用 try/catch 处理的业务问题绝大多数属于Exception而Error表示的是 JVM 层面的严重问题官方文档建议一般不要在代码里捕获它。java.lang.OutOfMemoryError正是Error的一个子类完整继承关系是java.lang.Object java.lang.Throwable java.lang.Error java.lang.VirtualMachineError java.lang.OutOfMemoryError关键词有两个它是 Error不是 Exception。这意味着编译器不会强制你捕获它属于 unchecked 类型可以一路沿着调用栈向上传播直到某个线程的Thread.run()方法结束。它依然是一个可以被捕获的对象。Error 虽然严重但 Java 语言并没有禁止你写catch (OutOfMemoryError e)。捕获之后线程会继续执行 catch 后面的代码而不是立刻终止。这两点叠加起来就决定了 OOM 的“杀伤半径”并不是天然等于整个进程。它首先表现为当前正在执行分配操作的那个线程在某个栈帧上抛出了一个 Error。2.2 内存溢出与内存泄漏的区别面试里经常跟着问“什么是内存溢出什么是内存泄漏”两者只差一个字但含义不同。内存溢出Out of Memory程序需要的内存超过了 JVM 能够提供的内存无法继续分配。这是一个“结果”。内存泄漏Memory Leak某些对象已经不再被业务使用但依然被 GC Roots 间接引用导致垃圾回收器无法回收它们。这是一个“原因”。内存泄漏积累到一定程度就会导致内存溢出。但也有的 OOM 并不是泄漏造成的而是一次性分配了一个超大对象比如读了一个 100GB 的文件到内存里或者给某个数组设置了错误的下标上限。对这道面试题来说我们讨论的是“结果”层面的 OOM当堆已经无法满足新的分配请求时JVM 到底发生了什么。2.3 OOM 的类型清单很多人以为 OOM 只有一种其实OutOfMemoryError只是父类实际抛出的错误信息往往带有不同的描述常见的有Java heap spaceJava 堆空间不足最常见的堆溢出。GC overhead limit exceededGC 耗时占比过高JVM 判定再回收下去已经没有意义。Metaspace元空间不足通常和动态生成类、类加载器泄漏有关。Direct buffer memory直接内存不足通常和 NIO、Netty、堆外缓存有关。unable to create new native thread无法再创建新的本地线程通常和线程数达到上限或系统内存耗尽有关。Requested array size exceeds VM limit请求分配的数组大小超过了虚拟机限制。StackOverflowError严格来说它不是 OOM 的子类而是VirtualMachineError的另一个子类指线程栈溢出逻辑上可以放在一起讨论。不同类型发生的位置不同对“其他线程是否能继续工作”的影响也不同。本文第七节会逐一拆解。现在先把目光聚焦到面试题里的主角Java 堆。三、JVM 运行时数据区堆到底在哪里3.1 线程私有与线程共享要回答“堆溢出了其他线程还能不能用”必须先理解 JVM 的内存布局。JVM 的运行时数据区大致可以分成两类第一类是线程私有的区域每个线程一份互不影响程序计数器PC Register记录当前线程执行到哪条字节码指令几乎不占内存。Java 虚拟机栈Java Virtual Machine Stack每个方法调用对应一个栈帧存放局部变量表、操作数栈、动态链接、方法出口等。一个线程栈溢出会抛StackOverflowError通常只影响当前线程。本地方法栈Native Method Stack为本地方法服务结构和虚拟机栈类似。第二类是线程共享的区域所有线程共同使用Java 堆Java Heap存放对象实例和数组是垃圾回收器主要管理的区域也是本道面试题的主角。方法区 / 元空间Method Area / Metaspace存放类的元信息、常量、静态变量等。在 HotSpot 里JDK 8 之后使用本地内存实现称为元空间。运行时常量池是方法区的一部分存放编译期生成的字面量和符号引用。用一张图来表示flowchart LR A[JVM运行时数据区] -- B[线程私有区域] A -- C[线程共享区域] B -- B1[程序计数器] B -- B2[Java虚拟机栈] B -- B3[本地方法栈] C -- C1[Java堆] C -- C2[元空间] C -- C3[运行时常量池]注意这条边界非常重要栈是线程私有的堆是线程共享的。一个线程的栈溢出往往只影响它自己而堆的溢出影响的表面上是“当前请求分配的那个线程”背后却是所有线程赖以生存的公共资源被耗尽了。3.2 堆内存再拆解新生代与老年代HotSpot 虚拟机使用的分代收集思想现在虽然被 G1、ZGC 等“分区”模型逐步取代但理解新生代和老年代仍然有助于解释 OOM 的细节。以经典的 Parallel Scavenge Parallel Old 组合为例堆一般被分为新生代Young Generation包含 Eden 区、两个 Survivor 区。新创建的对象默认优先在 Eden 分配经过若干次 Minor GC 存活下来之后会晋升到老年代。老年代Old Generation存放生命周期较长的对象。老年代空间不足时会触发 Full GC如果 Full GC 之后仍然不足就会抛出OutOfMemoryError: Java heap space。分配对象时的大致流程如下flowchart TD A[新对象分配请求] -- B{Eden 是否够} B --|是| C[进入 Eden分配成功] B --|否| D[Minor GC] D -- E{Minor GC 后是否够} E --|是| C E --|否| F[尝试晋升老年代] F -- G{老年代是否够} G --|是| H[晋升或分配成功] G --|否| I[触发 Full GC] I -- J{Full GC 后是否够} J --|是| C J --|否| K[抛出 OutOfMemoryError Java heap space]这里有一个关键认知OOM 不是“内存突然变成 0”而是“在一次分配请求发生时即使执行了垃圾回收依然腾不出足够的连续空间”。所以在抛错的那一刻堆里往往并不是“一点内存都没有了”而是无法满足本次特定大小的分配。这个细节对理解“其他线程还能不能干活”非常关键。四、核心结论其他线程能否继续工作取决于三件事在进入代码实验之前我们先把结论摊开。堆内存溢出发生后其他线程是否能继续工作主要取决于以下三件事4.1 第一件事OOM 抛出后当前线程是否捕获了它如果没有捕获这个 Error 会一直沿着调用栈向上传播。当它传播到线程最顶层的Thread.run()方法仍未被处理时该线程就会终止。这里要注意终止的是这一个线程而不是 JVM 进程。JVM 只有在所有非守护线程都结束后才会退出。如果捕获了线程会执行 catch 块的代码。但捕获 OOM 不等于“堆又恢复了”catch 块里如果还要新建对象可能又会再次 OOM。4.2 第二件事其他线程接下来的代码是否需要继续从堆上分配内存Java 程序的很多看似简单的操作背后都隐含了堆分配例如用new创建对象对 String 进行拼接Integer、Long 等包装类型的自动装箱使用System.out.println(x)打印日志时内部对字符串的拼接和字符数组的创建抛出异常时JVM 要创建异常对象并填充栈轨迹访问某些框架的懒加载缓存时框架内部新建对象。如果其他线程只做纯计算操作已经存在的对象比如在 CPU 上做循环累加、读取已有数组里的值做运算那么它连堆分配都不需要确实可能继续工作。反过来只要它需要一块新的堆内存就会立刻撞上分配失败。4.3 第三件事JVM 是否配置了退出或崩溃策略JVM 提供了几个参数可以改变 OOM 之后的默认行为-XX:ExitOnOutOfMemoryErrorOOM 时直接退出 JVM。-XX:CrashOnOutOfMemoryErrorOOM 时让 JVM 崩溃并生成核心转储文件方便排障。-XX:OnOutOfMemoryError命令OOM 时执行指定的外部命令常见用法是重启进程或采集现场。如果没有这些参数默认行为就是“抛出 Error让异常沿栈传播由线程自己决定生死”。4.4 先说结论把这三点综合起来可以给出一个比较完整的答案JVM 堆内存溢出时抛出OutOfMemoryError的线程如果没有捕获该 Error会终止执行但 JVM 进程本身不会因为这一个线程的结束而立即退出。进程中的其他非守护线程只要它们接下来的逻辑不再需要从堆里分配新的对象理论上仍然可以继续运行。然而在实际生产环境中堆已经被打到极限GC 频繁发生任何带有少量堆分配的操作都可能再次失败其他线程会处于“随时崩溃”的脆弱状态通常表现为整个服务假死、雪崩从业务角度看已经不可用。因此标准做法不是“捕获 OOM 硬扛”而是快速失败、保存现场、重启或降级。这段话建议背下来它基本覆盖了面试官想考察的全部要点。下面我们用实验验证其中的每一个环节。五、用代码把结论讲清楚五个实验5.1 实验准备JVM 参数与观察方法为了让 OOM 在几秒钟内发生而不是等上几分钟我们会把堆设置得非常小例如-Xmx64m或-Xmx128m。这样程序频繁分配大对象时很快就能触发堆溢出。建议使用 JDK 8 或 JDK 11 运行下面例子的主体代码。部分演示 GC overhead limit 的实验在 JDK 8 的默认 Parallel GC 下最容易复现JDK 11 默认使用 G1可以通过参数-XX:UseParallelGC手动切换回 Parallel GC 进行验证。我们统一运行命令如下# 示例以 64MB 堆运行 OomThreadDemo java -Xmx64m -XX:PrintGCDetails -XX:PrintGCTimeStamps OomThreadDemo-XX:PrintGCDetails会把每次 GC 的日志打印到控制台你可以非常直观地看到内存逐渐逼近上限Minor GC 越来越频繁Full GC 从偶尔发生到持续发生最后抛出 OOM。下面每个实验都围绕一个具体问题展开。5.2 实验一单线程直接 OOM进程退出先看最简单的情况。一个单线程程序不断向ArrayList里塞大数组import java.util.ArrayList; import java.util.List; public class SingleThreadOom { public static void main(String[] args) { Listbyte[] list new ArrayList(); while (true) { // 每次分配 10MB堆很快被耗尽 list.add(new byte[10 * 1024 * 1024]); System.out.println(allocated one chunk); } } }运行时java -Xmx64m SingleThreadOom输出最后几行大致会是这样allocated one chunk allocated one chunk allocated one chunk Exception in thread main java.lang.OutOfMemoryError: Java heap space at SingleThreadOom.main(SingleThreadOom.java:7)在这个例子里主线程既没有 catch OOM也没有其他线程所以main线程在new byte[]处抛出 OOM该 Error 没有被捕获沿栈向上传播到main方法出口主线程终止由于没有其他非守护线程JVM 进程随之退出。这代表的是最极端的场景堆溢出导致整个程序结束。但这里要分清因果关系进程退出不是因为 OOM 本身而是因为唯一的非守护线程main终止后JVM 没有其他线程可以继续运行才触发了进程退出。换句话说是“线程都结束了”导致进程退出而不是“堆满了”直接杀死进程。5.3 实验二多线程下一个线程 OOM其他线程继续运行现在进入本文最核心的实验。我们启动两个线程一个线程不断分配大对象直到 OOM另一个线程只做纯计算不分配任何堆内存。观察 OOM 发生后第二个线程是否还能继续工作。public class MultiThreadOom { public static void main(String[] args) { // 线程 A不断分配大对象直到 OOM Thread allocator new Thread(() - { Listbyte[] list new ArrayList(); while (true) { list.add(new byte[10 * 1024 * 1024]); System.out.println(allocator: allocated one chunk); } }, allocator); // 线程 B只做纯计算不分配堆内存 Thread calculator new Thread(() - { long sum 0; long i 0; while (true) { sum i; if (i % 1000000 0) { System.out.println(calculator: still alive, sum sum); } } }, calculator); allocator.start(); calculator.start(); } }运行时java -Xmx64m MultiThreadOom输出大致会是这样allocator: allocated one chunk allocator: allocated one chunk calculator: still alive, sum499999500000 allocator: allocated one chunk Exception in thread allocator java.lang.OutOfMemoryError: Java heap space at MultiThreadOom.lambda$main$0(MultiThreadOom.java:8) calculator: still alive, sum499999500000 calculator: still alive, sum499999500000 calculator: still alive, sum499999500000 ...这个实验直接验证了核心结论allocator 线程在new byte[]处抛出 OOM由于没有捕获该线程终止calculator 线程只做纯计算不分配堆内存因此 OOM 后依然继续运行不断打印 “still alive”JVM 进程没有退出因为 calculator 这个非守护线程仍然存活。这个实验告诉我们OOM 杀死的是“触发它的那个线程”而不是整个进程。只要还有其他非守护线程存活JVM 就不会退出。5.4 实验三其他线程需要分配内存时会立刻再次 OOM实验二里的 calculator 线程之所以能继续运行是因为它不分配堆内存。现在我们改一下让 calculator 线程每隔一段时间也分配一个小对象观察它是否还能继续工作。public class MultiThreadOomAlloc { public static void main(String[] args) { Thread allocator new Thread(() - { Listbyte[] list new ArrayList(); while (true) { list.add(new byte[10 * 1024 * 1024]); System.out.println(allocator: allocated one chunk); } }, allocator); Thread worker new Thread(() - { long i 0; while (true) { // 每次循环都分配一个小对象 Object obj new Object(); i; if (i % 100000 0) { System.out.println(worker: alive, i i); } } }, worker); allocator.start(); worker.start(); } }运行时java -Xmx64m MultiThreadOomAlloc输出大致会是这样allocator: allocated one chunk allocator: allocated one chunk worker: alive, i100000 allocator: allocated one chunk Exception in thread allocator java.lang.OutOfMemoryError: Java heap space at MultiThreadOomAlloc.lambda$main$0(MultiThreadOomAlloc.java:8) Exception in thread worker java.lang.OutOfMemoryError: Java heap space at MultiThreadOomAlloc.lambda$main$1(MultiThreadOomAlloc.java:17)可以看到allocator 线程先抛出 OOM 并终止紧接着worker 线程在下一次new Object()时也抛出 OOM因为堆已经被占满任何新的分配请求都会失败两个线程都终止后没有非守护线程存活JVM 进程退出。这个实验说明只要其他线程还需要从堆上分配内存它们就会在 OOM 后立刻再次失败。这就是为什么生产环境中堆溢出后整个服务往往表现为“假死”——所有需要分配对象的线程都会陆续崩溃。5.5 实验四捕获 OOM 后线程能否继续工作前面说过OOM 是可以被捕获的。现在我们验证如果捕获了 OOM当前线程是否还能继续执行后续代码。public class CatchOom { public static void main(String[] args) { Listbyte[] list new ArrayList(); try { while (true) { list.add(new byte[10 * 1024 * 1024]); System.out.println(allocated one chunk); } } catch (OutOfMemoryError e) { System.out.println(caught OOM: e.getMessage()); } System.out.println(main thread still alive after catching OOM); } }运行时java -Xmx64m CatchOom输出大致会是这样allocated one chunk allocated one chunk allocated one chunk caught OOM: Java heap space main thread still alive after catching OOM这个实验说明捕获 OOM 后当前线程不会终止会继续执行 catch 块后面的代码但要注意堆并没有恢复。如果 catch 块里再尝试分配大对象会再次抛出 OOM因此捕获 OOM 的正确姿势是记录现场、释放资源、快速失败而不是继续执行业务逻辑。5.6 实验五配置 ExitOnOutOfMemoryError 后进程直接退出最后验证 JVM 参数对行为的影响。我们给程序加上-XX:ExitOnOutOfMemoryError观察进程是否在 OOM 时直接退出。java -Xmx64m -XX:ExitOnOutOfMemoryError MultiThreadOom输出大致会是这样allocator: allocated one chunk allocator: allocated one chunk calculator: still alive, sum499999500000 allocator: allocated one chunk Terminating due to java.lang.OutOfMemoryError: Java heap space可以看到即使 calculator 线程还在正常运行JVM 也会在 OOM 发生时直接退出这是因为-XX:ExitOnOutOfMemoryError让 JVM 在遇到 OOM 时立即调用System.exit()不管其他线程是否存活。这个实验说明JVM 参数可以改变 OOM 的“杀伤半径”。默认情况下 OOM 只杀死触发它的线程但配置了退出策略后OOM 会直接杀死整个进程。5.7 五个实验小结把五个实验放在一起对比实验场景OOM 后其他线程进程是否退出实验一单线程无捕获无其他线程退出线程都结束了实验二多线程其他线程纯计算继续运行不退出实验三多线程其他线程也分配内存立刻再次 OOM退出线程都结束了实验四捕获 OOM当前线程继续执行不退出实验五配置 ExitOnOutOfMemoryError被强制终止立即退出这五个实验完整覆盖了面试题的所有分支其他线程能否继续工作取决于它是否需要分配内存、是否捕获了 OOM以及 JVM 是否配置了退出策略。
返回列表