Java发生OOM能否继续处理请求?

当你被问到“当Java程序发生OOM时,进程还能继续处理请求吗?”这样的面试题,会不会很懵?这里分享一次网友车辙在当初刚毕业那几年,意义风发,总觉得天下没有自己不会的面试题。然后在一次字节的面试中,彻彻底底的翻车的面试过程,希望提供大家一些面试经验。

Java 的优势有什么

面试官一上来,直接进入主题:你觉得在内存管理上,Java 有什么优势?

我:小菜一碟。相对于需要手动释放内存的 C 语言,Java 则通过垃圾回收机制实现了自动内存管理。这一机制能够自动辨识并清理不再使用的内存资源,从而省去了繁琐的手动释放过程,极大地简化了开发人员的工作。这让开发者能够将精力更专注地放在业务逻辑的构建上,而不必过多忧虑内存管理的问题,从而大大简化了开发人员的工作量。

什么是 OOM

面试官:请问您是否熟悉内存溢出(OOM)情况?

我:在我的经验中,我在线上经常遇到内存溢出的情况。特别是在使用Java编写的应用程序中,OOM通常是指内存溢出异常,即当应用程序需要为新对象分配内存空间时,可用内存不足以满足需求,从而导致了OOM异常的抛出。

什么情况会产生 OOM

面试官:好小子,线上的事故代码不会都是你写的吧,那你能谈谈是什么情况会导致内存溢出呢?

我:当然,一个常见的情况就是堆内存溢出。在创建对象时,大部分情况下都是占用 JVM 的堆内存。一旦堆内存无法满足对象的分配需求,就会抛出OOM异常。

错误信息通常是:java.lang.OutOfMemoryError: Java heap space

堆内存溢出的具体场景

面试官:你这个太抽象了,能不能具体点?

我:嗯,常见导致内存溢出的情况有这么几种:

对象生命周期过长:如果某个对象的生命周期过长,而且该对象占用的内存很大,那么在不断创建新对象的过程中,堆内存会被耗尽,从而导致内存溢出。这种情况一般出现在用集合当缓存,却忽略了缓存的淘汰机制。

无限递归:递归调用中缺少退出条件或递归深度过大,会导致空间耗尽,引发溢出错误。往往在测试环境就会发现该问题,不会暴露在生产环境

大数据集合:在处理大量数据时,如果没有正确管理内存,例如加载过大的文件、查询结果集过大等,会导致内存溢出。

JVM配置不当:如果JVM的内存参数配置不合理,例如堆内存设置过小,无法满足应用程序的内存需求,也会导致内存溢出。

下面的这个例子就是无限循环导致内存溢出。

List list = new ArrayList();
while (true) {
    list.add(1);
}

什么是内存泄漏

面试官:你知道在我们的程序里,有可能会出现内存泄漏,你对它了解吗?

我:对的,和内存溢出的情况不同,还有一种特殊场景,叫做内存泄漏(本质上还是内存溢出,只不过是错误的内存溢出),指的是程序在运行过程中无法释放不再使用的内存,导致内存占用不断增加,最终耗尽系统资源,这种情况就被称为内存泄漏。

这一次,我提前抢答了, 常见导致内存泄漏的情况包括:

对象的引用未被正确释放:如果在使用完一个对象后,忘记将其引用置为 null 或者从数据结构中移除,那么该对象将无法被垃圾回收,导致内存泄漏。比如 ThreadLocal。

长生命周期的对象持有短生命周期对象的引用:如果一个长生命周期的对象持有了一个短生命周期对象的引用,即使短生命周期对象不再使用,由于长生命周期对象的引用仍然存在,短生命周期对象也无法被垃圾回收,从而造成内存泄漏。

过度使用第三方库:某些第三方库可能存在内存泄漏或者资源未正确释放的问题,如果使用不当或者没有适当地管理这些库,可能会导致内存溢出。

集合类使用不当:在使用集合类时,如果没有正确地清理元素,当集合不再需要时,集合中的对象也不会被释放,导致内存泄漏。

资源未正确释放:如果程序使用了诸如文件、数据库连接、网络连接等资源,在不再需要这些资源时没有正确释放,会导致资源泄漏,最终导致内存泄漏。

下面的这个例子就是长生命周期的对象持有短生命周期对象的引用, 导致内存泄漏。

List list2 = new ArrayList();

@GetMapping("/headOOM2")
public String headOOM2() throws InterruptedException {
    while (true) {
        list2.add(1);
    }
}

还有其他情况吗

面试官:你说的都是堆的内存溢出,还有其他情况吗?

递归调用导致栈溢出

当递归调用的层级过深,栈空间无法容纳更多的方法调用信息时,会引发 StackOverflowError 异常,这也是一种 OOM 异常。例如,以下示例中的无限递归调用会导致栈溢出。

public class RecursiveExample {
    public static void recursiveFunction() {
        recursiveFunction();
    }

    public static void main(String[] args) {
        recursiveFunction();
    }
} 

元空间(Metaspace)耗尽

元空间是 Java 8 及以后版本中用来存储类元数据的区域。它取代了早期版本中的永久代(PermGen)。元空间主要用于存储类的结构信息、方法信息、静态变量以及编译后的代码等。

当程序加载和定义大量类、动态生成类、使用反射频繁操作类等情况下,可能会导致元空间耗尽。常见导致元空间耗尽的情况包括:

类加载过多:如果应用程序动态加载大量的类或者使用动态生成类的方式,会导致元空间的使用量增加。如果无法及时卸载这些类,元空间可能会耗尽。

字符串常量过多:Java中的字符串常量会被存储在元空间中。如果应用程序中使用了大量的字符串常量,尤其是较长的字符串,可能会导致元空间的耗尽。

频繁使用反射:反射操作需要大量的元数据信息,会占用较多的元空间。如果应用程序频繁使用反射进行类的操作,可能会导致元空间耗尽。

大量动态代理:动态代理是一种使用反射创建代理对象的技术。如果应用程序大量使用动态代理,将会生成大量的代理类,占用较多的元空间。

未正确限制元空间大小:默认情况下,元空间的大小是不受限制的,它会根据需要动态扩展。如果没有正确设置元空间的大小限制,或者限制过小,可能会导致元空间耗尽。

下面的这个例子就是类加载过多导致的内存泄漏。

public class OOMExample {
    public static void main(String[] args) {
        while (true) {
            ClassLoader classLoader = new CustomClassLoader();
            classLoader.loadClass("com.example.LargeClass");
        }
    }
}

终极问题

面试官满意地点了点头,表示我对Java线程处理请求时的情况了解得很多。接着,他提出了一个问题:“当Java线程在处理请求时,发生了OOM异常,整个进程还能继续处理请求吗?”

在我正准备回答的时候,面试官却提醒我这个问题涉及的内容较为复杂,不是简单的是与否问题。他建议我先整理一下思路。

面试官的眼神透露出这道题目有些深意。经过一番思考,我给出了我的答案:“我认为OOM并不会导致整个进程崩溃。”

面试官随即追问:“你是怎么理解的?OOM难道不是内存不足的表现吗?既然内存不足,进程还能继续处理请求吗?”

我解释道:“尽管出现OOM,但通过垃圾回收机制仍有可能释放一些内存。”

面试官却反驳:“不是因为在垃圾回收后,发现内存依然不足才会抛出OOM异常吗?这难道不意味着垃圾回收已经无法继续执行,导致内存不足,进而触发了OOM异常吗?整个流程是内存不足,垃圾回收无效,最终OOM。”

我只能在内心默默吐槽,面对这样的组合拳,我有些措手不及。很遗憾,面试最终以失败告终。面试官在我离开时似乎在暗示:“这种情况我也不愿意发生,只能怪编程经验不够丰富。”

实战

回到家,我马上去进行了代码实战,用来测试 OOM。

环境是:OpenJdk 11 -Xms100m -Xmx100m -XX:+PrintGCDetails

堆内存溢出

首先我们创建一个方法,调用它,每隔一秒不停的循环打印控制台信息,它的主要作用是模拟其他线程处理请求。

@GetMapping("/writeInfo")
public String writeInfo() throws InterruptedException {
    while (true) {
        Thread.sleep(1000);
        System.out.println("正在输出信息");
    }
}

接着再创建一个死循环往 List 中放入对象的方法,它的主要作用是模拟导致OOM的那个线程。

@GetMapping("/headOOM")
public String headOOM() throws InterruptedException {
    List list = new ArrayList();
    while (true) {
        list.add(1);
    }
}

最终结果是headOOM抛出了 OOM 异常,但是控制台还在不停的打印。【这边截图太大了,就不贴出来了】

这就是答案吗?其实不是,在第一步中,仅仅是在控制台打印出了日志,并没有创建明确的对象。将它稍微改动下,加一行,每次打印前先创建 10M 的对象。

public String writeInfo() throws InterruptedException {
    while (true) {
        Thread.sleep(1000);
        Byte[] bytes = new Byte[1024 * 1024 * 10];
        System.out.println("正在输出信息");
    }
}

结果依旧会继续打印。看到这里有些人可能会说,答案确实是”还能继续执行”,我只能说你是 Too Young Too Simple 。往下看

堆内存泄漏

老规矩,还是上面的方法

public String writeInfo() throws InterruptedException {
    while (true) {
        Thread.sleep(1000);
        Byte[] bytes = new Byte[1024 * 1024 * 10];
        System.out.println("正在输出信息");
    }
}

创建一个内存泄漏的方法,list2 作用域是在类对象级别,从而产生内存泄漏

List list2 = new ArrayList();
@GetMapping("/headOOM2")
public String headOOM2() throws InterruptedException {
    while (true) {
        list2.add(1);
    }
}

然后继续执行,结果首先是headOOM2这个方法对应的线程抛出 OOM。

接着是 WriteInfo这个方法对应的线程抛出OOM,所以我猜测现在整个进程基本都不能处理请求了。

为了印证这个猜测,再去调用下 writeInfo这个方法,直接抛出 OOM 异常。说明我们的猜测是对的。

这时候你如果把那个 10M 改成1M,writeInfo 这个方法就又能执行下去了,不信的话就去试试看吧。

这说明内存泄漏的情况,其他线程能否继续执行下去,取决于这些线程的执行逻辑是否会占用大量内存。

不发生内存泄漏的情况下,为什么频繁创建对象会导致OOM,GC 不是会把对象给回收吗

  1. 堆内存限制:Java程序的堆内存有大小限制。如果频繁创建对象且无法及时回收,堆空间可能被耗尽。垃圾回收器会尝试回收不再使用的对象,但若创建速度超过回收速度,堆内存不足将导致OOM。
  2. 垃圾回收开销:尽管垃圾回收器回收不再使用的对象,但垃圾回收本身消耗时间和计算资源。频繁创建临时对象会增加回收时间,降低应用程序效率。
  3. 内存碎片化:频繁创建和销毁对象会导致内存空间碎片化。即使总的空闲内存足够,若没有足够的连续大块内存分配给新对象,也会发生OOM。

通过观察GC日志,可能发现OOM发生时,堆大小未达到阈值,进一步说明其他因素导致了OOM异常。

总结

首先,我们为你阐述了何为OOM以及其发生场景,包括内存溢出和内存泄漏,然后引出了问题:当Java线程在处理请求时,抛出OOM异常,整个进程是否仍能处理请求。

随后,我们进行了代码实验,模拟了内存溢出和内存泄漏两种情况,得出以下结论:

  1. 内存溢出情况下,若GC速度跟不上内存分配速度,导致OOM并杀死线程,通常整个进程仍能继续处理请求。
  2. 内存泄漏情况下,未能回收的内存可能引发OOM,继而杀死线程以防止无法回收对象继续产生。此时,那些不占用大量内存的线程可能会继续执行,但那些耗费大量内存的线程可能会无法执行。极端情况下,进程可能会崩溃。

以上结论反映了OOM对进程的影响,并指出了在不同情况下,进程是否仍能处理请求。

你可能感兴趣的:(java,面试)