Interview must-kill question: When OOM occurs, can the process still process the request

Hi everyone, I'm Rut. I still remember the first few years when I just graduated, and I always felt that there were no interview questions that I could not understand. Then in a byte interview, I completely overturned .

What are the advantages of Java

As soon as the interviewer came up, he went directly to the topic: What advantages do you think Java has in terms of memory management?

Me: It's a piece of cake. Compared with the manual release of memory in C language, the advantage of Java lies in the automatic management of memory , which relies on the garbage collection mechanism . It can automatically identify and clean up memory resources that are no longer in use, eliminating the cumbersome process of manually releasing memory and greatly simplifying the process. Developer workload.

What is OOM

Interviewer: Do you know what OOM is?

Me: I have encountered this many times online. Java's OOM usually refers to an Out of Memory exception. In a Java application, each object requires a certain amount of space allocated in memory. An OOM exception is thrown when the application needs to allocate more memory space to create an object, but the available memory is not enough to meet the demand.

What situation will produce OOM

Interviewer: Good boy, you didn’t write all the accident codes online, so can you tell me what will cause OOM?

Me: For example, heap memory overflow often occurs . When creating objects, most of the cases occupy the heap memory of the JVM. When the heap memory is not enough to allocate, an OOM exception will be thrown.

java.lang.OutOfMemoryError: Java heap space

Specific scenarios of heap memory overflow

Interviewer: You are too abstract, can you be more specific?

Me: emm, there are several common situations that cause memory overflow:

  1. The object life cycle is too long : If the life cycle of an object is too long and the memory occupied by the object is large, the heap memory will be exhausted in the process of continuously creating new objects, resulting in memory overflow. This situation generally occurs when a collection is used as a cache, but the elimination mechanism of the cache is ignored.
  2. 无限递归:递归调用中缺少退出条件或递归深度过大,会导致空间耗尽,引发溢出错误。往往在测试环境就会发现该问题,不会暴露在生产环境
  3. 大数据集合:在处理大量数据时,如果没有正确管理内存,例如加载过大的文件、查询结果集过大等,会导致内存溢出。
  4. JVM配置不当:如果JVM的内存参数配置不合理,例如堆内存设置过小,无法满足应用程序的内存需求,也会导致内存溢出。

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

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

什么是内存泄漏

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

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

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

  1. 对象的引用未被正确释放:如果在使用完一个对象后,忘记将其引用置为 null 或者从数据结构中移除,那么该对象将无法被垃圾回收,导致内存泄漏。比如 ThreadLocal。
  2. 长生命周期的对象持有短生命周期对象的引用:如果一个长生命周期的对象持有了一个短生命周期对象的引用,即使短生命周期对象不再使用,由于长生命周期对象的引用仍然存在,短生命周期对象也无法被垃圾回收,从而造成内存泄漏。
  3. 过度使用第三方库:某些第三方库可能存在内存泄漏或者资源未正确释放的问题,如果使用不当或者没有适当地管理这些库,可能会导致内存溢出。
  4. 集合类使用不当:在使用集合类时,如果没有正确地清理元素,当集合不再需要时,集合中的对象也不会被释放,导致内存泄漏。
  5. 资源未正确释放:如果程序使用了诸如文件、数据库连接、网络连接等资源,在不再需要这些资源时没有正确释放,会导致资源泄漏,最终导致内存泄漏。

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

List<Integer> list2 = new ArrayList<>();

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

还有其他情况吗

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

递归调用导致栈溢出

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

public class OOMExample {
    public static void recursiveMethod() {
        recursiveMethod();
    }
    
    public static void main(String[] args) {
        recursiveMethod();
    }
}

元空间(Metaspace)耗尽

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

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

  1. 类加载过多:如果应用程序动态加载大量的类或者使用动态生成类的方式,会导致元空间的使用量增加。如果无法及时卸载这些类,元空间可能会耗尽。
  2. 字符串常量过多:Java中的字符串常量会被存储在元空间中。如果应用程序中使用了大量的字符串常量,尤其是较长的字符串,可能会导致元空间的耗尽。
  3. 频繁使用反射:反射操作需要大量的元数据信息,会占用较多的元空间。如果应用程序频繁使用反射进行类的操作,可能会导致元空间耗尽。
  4. 大量动态代理:动态代理是一种使用反射创建代理对象的技术。如果应用程序大量使用动态代理,将会生成大量的代理类,占用较多的元空间。
  5. 未正确限制元空间大小:默认情况下,元空间的大小是不受限制的,它会根据需要动态扩展。如果没有正确设置元空间的大小限制,或者限制过小,可能会导致元空间耗尽。

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

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

终极问题

面试官满意的点了点头,小伙子你知道的还挺多,那我再问你一个问题哈:”当 Java 线程在处理请求时,抛出了 OOM 异常,整个进程还能处理请求吗?

当我正准备脱口而出的时候,面试官:“这个问题考察的内容还是挺多的,不是简单的是与否的问题。我建议你先整理一下思路。”

看到面试官的眼神,我就知道这道题有猫腻。思考了一会,我给出了答案。“我还是认为OOM 并不会导致整个进程挂掉”

面试官:你是怎么理解的,OOM 是不是意味着内存不够了。既然内存不够了,进程还能处理请求吗?

我:内存不够了还可以通过垃圾回收释放内存。

面试官:难道 OOM 不就是因为 GC 后,发现内存不足才会抛出的异常,这时候是不是可以理解为 GC 不了了。所以是:内存不够->GC后还不够-> OOM 这个流程。

我:此处经典国骂,当然我只能在内心想想。

这么一套组合拳下来,我彻底懵了。结果不出意外的挂了,面试官最后送我下楼的时候,仿佛在和我说:”我也不想这样,只能怪 HC 太少“

实战

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

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

堆内存溢出

  1. 首先我们创建一个方法,调用它,每隔一秒不停的循环打印控制台信息,它的主要作用是模拟其他线程处理请求
@GetMapping("/writeInfo")
public String writeInfo() throws InterruptedException {
    while (true) {
        Thread.sleep(1000);
        System.out.println("正在输出信息");
    }
}
  1. 接着再创建一个死循环往 List 中放入对象的方法,它的主要作用是模拟导致OOM的那个线程
@GetMapping("/headOOM")
public String headOOM() throws InterruptedException {
    List<Integer> list = new ArrayList<>();
    while (true) {
        list.add(1);
    }
}
  1. 最终结果是headOOM抛出了 OOM 异常,但是控制台还在不停的打印。【这边截图太大了,就不贴出来了】

  1. 这就是答案吗?其实不是,在第一步中,仅仅是在控制台打印出了日志,并没有创建明确的对象。将它稍微改动下,加一行,每次打印前先创建 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 。往下看

堆内存泄漏

  1. 老规矩,还是上面的方法
public String writeInfo() throws InterruptedException {
    while (true) {
        Thread.sleep(1000);
        Byte[] bytes = new Byte[1024 * 1024 * 10];
        System.out.println("正在输出信息");
    }
}
  1. 创建一个内存泄漏的方法,list2 作用域是在类对象级别,从而产生内存泄漏
List<Integer> list2 = new ArrayList<>();
@GetMapping("/headOOM2")
public String headOOM2() throws InterruptedException {
    while (true) {
        list2.add(1);
    }
}
  1. 然后继续执行,结果首先是headOOM2这个方法对应的线程抛出 OOM。

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

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

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

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

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

最后再回答下这个问题:

  1. 堆内存限制:Java程序的堆内存有一定的大小限制,如果频繁创建对象并且无法及时回收,堆空间可能会被耗尽。虽然垃圾回收器会尽力回收不再使用的对象,但如果对象创建的速度超过垃圾回收器的回收速度,就会导致堆内存不足而发生 OOM
  2. 垃圾回收的开销:尽管垃圾回收器会回收不再使用的对象,但垃圾回收本身也是需要消耗时间和计算资源的。如果频繁创建大量的临时对象,垃圾回收器需要花费更多的时间来回收这些对象,导致应用程序的执行效率下降。
  3. 内存碎片化:频繁创建和销毁对象会导致内存空间的碎片化。当内存中存在大量碎片化的空闲内存块时,即使总的空闲内存足够,但可能无法找到连续的大块内存来分配给新对象。这种情况下,即使垃圾回收器回收了部分对象,仍然无法分配足够的内存给新创建的对象,从而导致OOM。 所以你可以从GC日志上发现,发生OOM时,你的堆大小没有到达你的阈值。

不知道到这,你看懂了没有。

总结

首先,我们铺垫了什么是 OOM,以及 OOM 发生的场景,包括内存溢出、内存泄漏,从而得出了这个问题:当 Java 线程在处理请求时,抛出了 OOM 异常,整个进程还能处理请求吗?

接着通过代码实战,模拟了内存溢出和内存泄漏两个场景,暂时性的得出了结论:

  1. 内存溢出的情况,当 GC 的速度跟不上内存的分配时,会发生 OOM, 从而将那个线程 Kill 掉,在这种情况下,进程一般还能继续处理请求。
  2. 内存泄漏的情况,由于这些内存不能被回收掉,会发生OOM,从而将那个线程 Kill 掉,防止继续创建不能被回收的对象,此时有些不占用内存的线程可能将继续执行,而那些会占用大量内存的线程可能将无法执行,最坏的情况可能是进程直接挂掉。

如果这篇文章对您有所帮助,可以关注我的公众号《车辙的编程学习圈》,领取视频教程、电子书、面试攻略等海量资源。

我是车辙,掘金小册《SkyWalking》作者,一名常被HR调侃为XX杨洋的互联网打工人。欢迎大家点赞、评论、转发。

image.png

Guess you like

Origin juejin.im/post/7239187347355287609