面试必杀题:当发生OOM时,进程还能处理请求吗
大家好,我是车辙。还记得当初刚毕业那几年,意义风发,总觉得天下没有我不会的面试题。然后在一次字节的面试中,我 彻彻底底的翻车了。
Java 的优势有什么
面试官一上来,直接进入主题:你觉得在内存管理上,Java 有什么优势?
我:小菜一碟。相比于 C 语言的手动释放内存,Java 的优势在于 内存的自动管理,依赖于 垃圾回收机制,它能自动识别和清理不再使用的内存资源,消除了手动释放内存的繁琐过程,大大简化了开发人员的工作量。
什么是 OOM
面试官:那你知道什么是 OOM 吗?
我:这个我在线上也碰到过好多次了,Java 的 OOM 通常指的是 内存溢出(Out of Memory)异常。在 Java 应用程序中,每个对象都需要在内存中分配一定的空间。当应用程序需要分配更多内存空间来创建对象时,但可分配内存却不足以满足需求时,就会抛出 OOM 异常。
什么情况会产生 OOM
面试官:好小子,线上的事故代码不会都是你写的吧,那你说说有什么情况会导致 OOM?
我:比如说经常发生的 堆内存溢出, 在创建对象时,绝大多数情况占用的都是 JVM 的堆内存,当堆内存不足以分配时,则会抛出OOM异常。
java.lang.OutOfMemoryError: Java heap space
堆内存溢出的具体场景
面试官:你这个太抽象了,能不能具体点?
我:emm,常见导致内存溢出的情况有这么几种:
- 对象生命周期过长:如果某个对象的生命周期过长,而且该对象占用的内存很大,那么在不断创建新对象的过程中,堆内存会被耗尽,从而导致内存溢出。这种情况一般出现在用集合当缓存,却忽略了缓存的淘汰机制。
- 无限递归:递归调用中缺少退出条件或递归深度过大,会导致空间耗尽,引发溢出错误。往往在测试环境就会发现该问题,不会暴露在生产环境
- 大数据集合:在处理大量数据时,如果没有正确管理内存,例如加载过大的文件、查询结果集过大等,会导致内存溢出。
- JVM配置不当:如果JVM的内存参数配置不合理,例如堆内存设置过小,无法满足应用程序的内存需求,也会导致内存溢出。
下面的这个例子就是无限循环导致内存溢出。
List<Integer> list = new ArrayList<>();
while (true) {
list.add(1);
}
什么是内存泄漏
面试官:你知道在我们的程序里,有可能会出现内存泄漏,你对它了解吗?
我:对的,和内存溢出的情况不同,还有一种特殊场景,叫做内存泄漏( 本质上还是内存溢出,只不过是错误的内存溢出),指的是程序在运行过程中无法释放不再使用的内存,导致内存占用不断增加,最终耗尽系统资源,这种情况就被称为内存泄漏。
这一次,我提前抢答了, 常见导致内存泄漏的情况包括:
- 对象的引用未被正确释放:如果在使用完一个对象后,忘记将其引用置为 null 或者从数据结构中移除,那么该对象将无法被垃圾回收,导致内存泄漏。比如 ThreadLocal。
- 长生命周期的对象持有短生命周期对象的引用:如果一个长生命周期的对象持有了一个短生命周期对象的引用,即使短生命周期对象不再使用,由于长生命周期对象的引用仍然存在,短生命周期对象也无法被垃圾回收,从而造成内存泄漏。
- 过度使用第三方库:某些第三方库可能存在内存泄漏或者资源未正确释放的问题,如果使用不当或者没有适当地管理这些库,可能会导致内存溢出。
- 集合类使用不当:在使用集合类时,如果没有正确地清理元素,当集合不再需要时,集合中的对象也不会被释放,导致内存泄漏。
- 资源未正确释放:如果程序使用了诸如文件、数据库连接、网络连接等资源,在不再需要这些资源时没有正确释放,会导致资源泄漏,最终导致内存泄漏。
下面的这个例子就是 长生命周期的对象持有短生命周期对象的引用, 导致内存泄漏。
List<Integer> list2 = new ArrayList<>();
@GetMapping("/headOOM2")
public String headOOM2() throws InterruptedException {
while (true) {
list2.add(1);
}
}
还有其他情况吗
面试官:你说的都是堆的内存溢出,还有其他情况吗?
递归调用导致栈溢出
当递归调用的层级过深,栈空间无法容纳更多的方法调用信息时,会引发 StackOverflowError 异常,这也是一种 OOM 异常。例如,以下示例中的无限递归调用会导致栈溢出。
public class OOMExample {
public static void recursiveMethod() {
recursiveMethod();
}
public static void main(String[] args) {
recursiveMethod();
}
}
元空间(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 线程在处理请求时,抛出了 OOM 异常,整个进程还能处理请求吗? ”
当我正准备脱口而出的时候,面试官:“这个问题考察的内容还是挺多的,不是简单的是与否的问题。我建议你先整理一下思路。”
看到面试官的眼神,我就知道这道题有猫腻。思考了一会,我给出了答案。“我还是认为OOM 并不会导致整个进程挂掉”
面试官:你是怎么理解的,OOM 是不是意味着内存不够了。既然内存不够了,进程还能处理请求吗?
我:内存不够了还可以通过垃圾回收释放内存。
面试官:难道 OOM 不就是因为 GC 后,发现内存不足才会抛出的异常,这时候是不是可以理解为 GC 不了了。所以是:内存不够->GC后还不够-> OOM 这个流程。
我:此处经典国骂,当然我只能在内心想想。
这么一套组合拳下来,我彻底懵了。结果不出意外的挂了,面试官最后送我下楼的时候,仿佛在和我说:”我也不想这样,只能怪 HC 太少“
实战
回到家,我马上去进行了代码实战,用来测试 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<Integer> 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<Integer> 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 不是会把对象给回收吗
最后再回答下这个问题:
- 堆内存限制:Java程序的堆内存有一定的大小限制,如果频繁创建对象并且无法及时回收,堆空间可能会被耗尽。虽然垃圾回收器会尽力回收不再使用的对象,但如果 对象创建的速度超过垃圾回收器的回收速度,就会导致堆内存不足而发生 OOM。
- 垃圾回收的开销:尽管垃圾回收器会回收不再使用的对象,但 垃圾回收本身也是需要消耗时间和计算资源的。如果频繁创建大量的临时对象,垃圾回收器需要花费更多的时间来回收这些对象,导致应用程序的执行效率下降。
- 内存碎片化: 频繁创建和销毁对象会导致内存空间的碎片化。当内存中存在大量碎片化的空闲内存块时,即使 总的空闲内存足够,但可能无法找到连续的大块内存来分配给新对象。这种情况下,即使垃圾回收器回收了部分对象,仍然无法分配足够的内存给新创建的对象,从而导致OOM。 所以你可以从GC日志上发现,发生OOM时,你的堆大小没有到达你的阈值。
不知道到这,你看懂了没有。
总结
首先,我们铺垫了什么是 OOM,以及 OOM 发生的场景,包括内存溢出、内存泄漏,从而得出了这个问题: 当 Java 线程在处理请求时,抛出了 OOM 异常,整个进程还能处理请求吗?
接着通过代码实战,模拟了内存溢出和内存泄漏两个场景,暂时性的得出了结论:
- 内存溢出的情况,当 GC 的速度跟不上内存的回收时,会发生 OOM, 从而将那个线程 Kill 掉,在这种情况下,进程一般还能继续处理请求。
- 内存泄漏的情况,由于这些内存不能被回收掉,会发生OOM,从而将那个线程 Kill 掉,防止继续创建不能被回收的对象,此时有些不占用内存的线程可能将继续执行,而那些会占用大量内存的线程可能将无法执行,最坏的情况可能是进程直接挂掉。
如果这篇文章对您有所帮助,可以关注我的公众号《车辙的编程学习圈》,领取视频教程、电子书、面试攻略等海量资源。
我是车辙,掘金小册《SkyWalking》作者,一名常被HR调侃为XX杨洋的互联网打工人。欢迎大家点赞、评论、转发。