写点什么

JVM 实战—OOM 的原因和模拟以及案例

  • 2025-01-06
    福建
  • 本文字数:13026 字

    阅读完需:约 43 分钟

1.线上系统突然由于 OOM 内存溢出挂掉

 

(1)最常遇到的故障——系统 OOM


作为 Java 程序员,先不考虑系统依赖的缓存、消息队列、数据库等挂掉。就 Java 系统本身而言,最常见的故障原因就是 OOM,即内存溢出。

 

所谓 OOM 内存溢就是:JVM 内存有限,但系统程序不断往 JVM 内存里面创建对象,结果 JVM 内存放不下对象,就直接溢出了。如下图示:



一旦系统程序不停地往 JVM 内存里放入大量对象,JVM 实在放不下后,就会 OutOfMemory 内存溢出,直接瘫痪不能工作了。

 

通常而言,内存溢出会对 Java 系统造成毁灭性打击,因为这代表 JVM 内存不足以支撑系统程序的运行。

 

所以一旦发生 OOM,就会导致系统程序直接停止运转,甚至会导致 JVM 进程直接崩溃掉,进程都没了。这时线上看起来的场景就是,用户突然发现点击 APP、点击没反应了。然后大量投诉和反馈给客服,客服转移给运营,运营反馈给技术。

 

(2)如何处理 OOM


当发生 OOM 之后,系统到底为什么会突然 OOM?系统代码到底产生了多少对象、为什么会产生这么多对象?JVM 为什么会放不下这么多对象?怎么排查这个问题、又如何解决?

 

2.什么是内存溢出及哪些区域会发生内存溢出

 

(1)一个常见的问题


JVM 里的内存溢出到底指的是什么,哪些区域有可能会发生内存溢出?接下来从 JVM 核心运行原理出发,介绍哪些地方可能会发生内存溢出。

 

(2)运行一个 Java 系统就是运行一个 JVM 进程


首先需要清楚的是:启动一个 Java 系统,其本质就是启动一个 JVM 进程。比如下面的一段代码:运行这个代码时,会发生哪些事情?


public class HelloWorld {    public static void main(String[] args) {        String message = "Hello World";        System.out.println(message);    }}
复制代码


写好的代码都是后缀为".java"的源代码,这种代码是不能运行的。所以第一步就是先把".java"源代码文件编译成一个".class"字节码文件,这个字节码文件才是可以运行的。如下图示:



接着对于这种编译好的字节码文件,比如 HelloWorld.class。如果 HelloWorld.java 里面包含了 main 方法,那么就可以在命令行中使用"java 命令"来执行这个字节码文件。

 

一旦执行了 java 命令,就会启动一个 JVM 进程,这个 JVM 进程就会负责执行写好的那些代码。如下图示:



所以首先要清楚的是:运行一个 Java 系统,本质上就是启动一个 JVM 进程。由这个 JVM 进程来负责执行编写好的代码,而且 JVM 进程就会从指定代码的 main 方法入手,开始执行代码。

 

(3)JVM 进程怎么执行编写好的那些代码


Java 作为面向对象的语言,最基本的代码组成单元就是类。Java 开发平时写的 Java 代码,就是写一个一个的类。然后在一个个的类里定义各种变量、方法、数据结构,实现业务逻辑。所以 JVM 要执行代码,首先需要把写好的类加载到内存里。

 

在 JVM 的内存区域里有一块区域叫永久代,JDK 1.8 以后叫 Metaspace。这块内存区域是用来存放各种类的信息,包括 JDK 自身的一些类的信息。

 

JVM 有一套类加载的机制,类加载器会负责从编译好的.class 字节码文件中把类加载到内存。如下图示:



既然 Metaspace 是用来存放类信息的,那么就有可能发生 OOM。所以第一块可能发生 OOM 的区域,就是存放类信息的 Metaspace 区域。

 

(4)Java 虚拟机栈:让线程执行各种方法


写好的 Java 代码虽然是一个个的类,但核心代码逻辑一般都封装在类里的各种方法中。比如 JVM 加载 HelloWorld 类到内存后,会怎样执行里面的代码呢?

 

Java 语言中的一个通用的规则是:JVM 进程总是从 main 方法开始执行的。既然在 HelloWorld 中写了 main()方法,那么 JVM 就会执行该方法的代码。

 

一.JVM 进程里会如何执行 main()方法


其实所有方法的执行,都必须依赖 JVM 进程中的某个线程去执行,所以可以理解为线程才是执行代码的核心主体。JVM 进程启动之后默认会有一个 main 线程,这个 main 线程就是专门负责执行 main()方法的。



二.方法里的局部变量放在哪里


现在又有一个问题,在 main()方法里定义了一个局部变量 message,方法里的局部变量可能会有很多,那么这些局部变量是放在哪里的呢?

 

每个线程都会有一个自己的虚拟机栈,就是所谓的栈内存。每个线程执行一个方法就会为方法创建一个栈桢放入自己的虚拟机栈里,然后就会在这个方法的栈桢里放入该方法中定义的各种局部变量。如下图示:



可以设置 JVM 中每个线程的虚拟机栈的内存大小,一般设置为 1M。既然每个线程的虚拟机栈的内存大小是固定的,那也可能会发生 OOM。所以第二块可能发生 OOM 的区域,就是每个线程的虚拟机栈内存。

 

(5)堆内存:存放代码中创建的各种对象


在一些方法中,可能会频繁创建各种各样的对象,这些对象都是放在堆内存里的。如下图示:



通常在 JVM 中分配给堆内存的空间是固定的,所以当程序不停在堆内存创建对象时,堆内存也有可能发生内存溢出。因此第三块可能发生 OOM 的区域,就是堆内存空间。

 

(6)总结


可能发生 OOM 的区域有三块:

第一块是存放类信息的 Metaspace 区域

第二块是每个线程的虚拟机栈内存

第三块是堆内存空间

 

3.Metaspace 如何因类太多而发生内存溢出


(1)Metaspace 区域是如何触发内存溢出的


在启动一个 JVM 时是可以设置很多参数,其中有一些参数就是专门用来设置 Metaspace 区域的内存大小。如下所示:


 -XX:MetaspaceSize=512m  -XX:MaxMetaspaceSize=512m
复制代码


这限定了 Metaspace 区域的内存大小为 512M。



所以在一个 JVM 中,Metaspace 区域的大小是固定的,比如 512M。如果 JVM 不停加载类,加载了很多类导致 Metaspace 满了,此时会如何?

 

此时由于 Metaspace 区域满了,就会触发 FGC。FGC 会进行 Young GC 回收新生代、会进行 Old GC 回收老年代、并且尝试回收 Metaspace 区域中的类。



一.当 Metaspace 区域满了就会触发 FGC,尝试回收 Metaspace 的类


那么什么样的类才是可以被回收的呢?这个条件是相当的苛刻,包括但不限于以下一些:比如这个类的类加载器要被回收、这个类的所有对象实例也要被回收等。所以当 Metaspace 区域满了,未必能回收掉里面很多的类。如果回收不了多少类,但程序还在加载类放到 Metaspace 中,会怎么样?

 

二.FGC 尝试回收了 Metaspace 中的类之后发现还是没能腾出足够空间


此时还要继续往 Metaspace 中放入更多的类,就会引发内存溢出的问题。一旦发生内存溢出就说明 JVM 已经没办法继续运行下去,系统就崩溃了。

 

以上一二两点便是 Metaspace 区域发生内存溢出的根本原因:Metaspace 满了之后先 FGC -> 发现回收不了足够空间就 OOM。

 

(2)什么情况下会发生 Metaspace 内存溢出


Metaspace 这块区域一般很少发生内存溢出,如果发生内存溢出一般都是由于如下两个原因:

 

原因一:系统上线时使用默认的 JVM 参数,没有设置 Metaspace 区域的大小。这就可能会导致默认的 Metaspace 区域才几十 M 而已。对于一个大系统,它自己会有很多类+依赖 jar 包的类,几十 M 可能不够。所以对于这种原因,通常在上线系统时设置好 Metaspace 大小如 512M。

 

原因二:开发人员有时候会用 CGLIB 之类的技术动态生成一些类。一旦代码中没有控制好,导致生成的类过多时,那么就容易把 Metaspace 给占满,从而引发内存溢出。

 

(3)总结


Metaspace 区域发生内存溢出的原理是:Metaspace 满了之后先 FGC -> 发现回收不了足够空间就 OOM。

 

两种常见的触发 Metaspace 内存溢出原因是:默认 JVM 参数导致 Metaspace 区域过小 + CGLIB 等动态生成类过多。

 

因此只要合理分配 Metaspace 区域的内存大小,避免无限制地动态生成类,一般 Metaspace 区域都是比较安全的,不会触发 OOM 内存溢出。

 

4.无限制调用方法如何让线程的栈内存溢出

 

JVM 加载写好的类到内存之后,下一步就是去通过线程去执行方法,此时就会有方法栈帧的入栈和出栈相关操作,所以接下来分析线程栈内存溢出的原因。

 

(1)一个线程调用多个方法时的入栈和出栈


如下是一个相对完整的 JVM 运行原理图:



先看如下代码:


public class HelloWorld {    public static void main(String[] args) {        String message = "HelloWorld";        System.out.println(message);        sayHello(message);    }        public static void sayHello(String name) {        System.out.println(name);    }}
复制代码


按前面介绍:JVM 启动后,HelloWorld 类会被加载到内存,然后会通过 main 线程执行 main()方法。

 

此时在 main 线程的虚拟机栈里,就会压入 main()方法对应的栈桢,main()方法对应的栈桢里就会放入 main()方法中的局部变量。

 

此外可以手动设置每个线程的虚拟机栈的内存大小,一般默认设置 1M。所以,main 线程的虚拟机栈内存大小一般也是固定的。

 

上面代码在 main()方法中又继续调用了一个 sayHello()方法,而且 sayHello()方法中也有自己的局部变量,所以会将 sayHello()方法的栈桢压入到 main 线程的虚拟机栈中去。如下图示:



接着 sayHello()方法运行完毕,不需要在内存中为该方法保存其信息了,此时就会将 sayHello()方法对应的栈桢从 main 线程的虚拟机栈里出栈。如下图示:



接着当 main()方法运行完毕,会将其栈桢从 main 线程的虚拟机栈里出栈。

 

(2)方法的栈桢也会占用内存


每个线程的虚拟机栈的大小是固定的,比如可能就是 1M。而一个线程每调用一个方法,就会将该方法的栈桢压入虚拟机栈中。方法的栈桢里就会存放该方法的局部变量,从而也会占用内存。

 

(3)导致 JVM 栈内存溢出的原因


既然一个线程的虚拟机栈内存大小是有限的,比如 1M。如果不停地让一个线程去调用各种方法,然后不停地把调用的方法所对应的栈桢压入虚拟机栈里,那么就会不断地占用这个线程 1M 的栈内存。

 

大量方法的栈桢就会消耗完这个 1M 的线程栈内存,最终导致出现栈内存溢出的问题,如下图示:



(4)导致 JVM 栈内存溢出的场景


即便线程的栈内存只有 128K 或 256K,都能进行一定深度的方法调用。但是如果执行的是一个递归方法调用,那就不一定了。如下代码所示:


public static void sayHello(String name) {    sayHello(name);}
复制代码


一旦出现上述递归代码,一个线程就会不停地调用同一个方法。即使是同一个方法,每一次方法调用也会产生一个栈桢压入栈里。比如线程对 sayHello()进行 100 次递归调用,就会有 100 个栈桢压入中。所以如果运行上述代码,就会不停地将 sayHello()方法的栈桢压入栈里。最终一定会消耗掉线程的栈内存,引发栈内存溢出。但发生栈内存溢出,往往都是代码 bug 导致的,正常情况下很少发生。

 

(5)总结


栈内存溢出的原因和场景:原因是大量的栈帧会消耗完线程的栈内存 + 场景是方法无限递归调用。

 

所以只要避免代码出现无限方法递归,一般就能避免栈内存溢出。

 

5.对象太多导致堆内存实在放不下而内存溢出

 

前面分析了 Metaspace 和栈内存两块内存区域发生内存溢出的原因,同时介绍了较为常见的引发它们内存溢出的场景。一般只要注意代码,都不太容易引发 Metaspace 和栈内存的内存溢出。真正容易引发内存溢出的,其实是堆内存区域。如果系统创建出来的对象实在太多,那么就会导致堆内存溢出。

 

(1)对象首先在 Eden 区分配之后触发 YGC


首先,系统在运行时会不断创建对象,大量的对象会填满 Eden 区。一旦 Eden 区满了之后,就会触发一次 YGC,然后存活对象进入 S 区。如下图示:




(2)高并发场景下导致 YGC 后存活对象太多


一旦出现高并发场景,可能导致进行 YGC 时很多请求还没处理完毕。然后 YGC 后就会存活较多对象,并且在 Survivor 区放不下。此时这些存活对象只能进入到老年代中,于是老年代也会很快被占满。如下图示:



一旦老年代被占满就会触发 FGC,如下图示:



假设 YGC 过后有一批存活对象,Survivor 放不下。此时就等着要进入老年代,然后老年代也满了。那么就得等老年代进行 GC 来回收一批对象,才能存放 YGC 后存活的对象。但是不幸的事情发生了,老年代 GC 过后依然存活下来很多对象。

 

由于新生代 YGC 后有一批存活对象还在等着放进老年代,但此时老年代 GC 后空间依然不足。所以这批新生代 YGC 后的存活对象没法存放了,只能内存溢出。

 

这个就是典型的:堆内存实在放不下过多对象而导致内存溢出的原因。当老年代都已经占满了,还要往里面放对象。而且已经触发 FGC 回收了,老年代还是没有足够内存空间,那只能发出内存溢出的异常。

 

(3)什么场景会发生堆内存的溢出


发生堆内存溢出的原因:

有限的内存中放了过多对象,而且大多都是存活的,此时即使 FGC 后还是有大部分对象存活,要继续放入更多对象已经不可能,只能引发内存溢出。

 

发生内存溢出有几种场景:

场景一:系统承载高并发请求,因为请求量过大导致大量对象都是存活的

此时无法继续往堆内存里放入新的对象了,就会引发 OOM 系统崩溃。

场景二:系统有内存泄漏,创建了很多对象,结果对象都是存活的没法回收

由于不能及时取消对它们的引用,导致触发 FGC 后还是无法回收。此时只能引发内存溢出,因为老年代已经放不下更多的对象了。

场景三:代码问题创建的对象占用了大量内存,且该方法一直在长时间运行

这样导致占用大量内存的对象一直不释放。

 

因此引发堆内存 OOM 的原因可能是:系统负载过高、存在内存泄漏、创建大量对象长时间运行,不过 OOM 一般是由代码写得差或设计缺陷引发的。

 

(4)总结


一.发生堆内存 OOM 的根本原因

对象太多且都是存活的,即使 FGC 过后还是没有空间,此时放不下新对象,只能 OOM。

 

二.发生堆内存 OOM 的常见场景

系统负载过高 + 内存泄露 + 代码问题创建大量对象长时间运行。

 

6.模拟 JVM Metaspace 内存溢出的场景(动态生成 268 个类占 10M)


(1)Metaspace 内存溢出原理


Metaspace 区域发生内存溢出的一个场景就是:不停地动态生成类,导致程序不停加载类到 Metaspace 区域里,而且这些动态生成的类还不能被回收掉。

 

这样一旦 Metaspace 区域满了,就会触发 FGC 回收 Metaspace 中的类,但此时的类大多不能被回收。

 

因此即使触发过 FGC 后,Metaspace 区域还是不能放下任何一个类,此时就会导致 Metaspace 区域的内存溢出,导致 JVM 也崩溃掉。

 

(2)一段 CGLIB 动态生成类的代码示例


如果要用 CGLIB 来动态生成一些类,可以在 pom.xml 中引入以下依赖:


<dependency>    <groupId>cglib</groupId>    <artifactId>cglib</artifactId>    <version>3.3.0</version></dependency>
复制代码


接着使用 CGLIB 来动态生成类,代码如下:


import net.sf.cglib.proxy.Enhancer;import net.sf.cglib.proxy.MethodInterceptor;import net.sf.cglib.proxy.MethodProxy;import java.lang.reflect.Method;

public class CglibDemo { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(Car.class); enhancer.setUseCache(false); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable { if (method.getName().equals("run")) { System.out.println("Check before run"); return methodProxy.invokeSuper(o, objects); } else { return methodProxy.invokeSuper(o, objects); } } });

Car car = (Car) enhancer.create(); car.run(); } } static class Car { public void run() { System.out.println("Run..."); } }}
复制代码


main()方法会通过 CGLIB 的 Enhancer 类生成一个 Car 类的子类,首先 main()方法后会定义一个 Car 类,它有一个 run()方法。


static class Car {    public void run() {        System.out.println("Run...");    }}
复制代码


接着在下面的代码片段中,会设置动态生成类:


Enhancer enhancer = new Enhancer();enhancer.setSuperclass(Car.class);enhancer.setUseCache(false);
复制代码


其中 Enhancer 类就是用来动态生成类的,给 enhancer 设置 SuperClass,表示动态生成的类是 Car 类的子类。既然动态生成的类是 Car 的子类,那么该类也有 Car 的 run()方法,于是通过如下代码对动态生成的类的 run()方法进行改动。


enhancer.setCallback(new MethodInterceptor() {    @Override    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {        if (method.getName().equals("run")) {            System.out.println("Check before run");            return methodProxy.invokeSuper(o, objects);        } else {            return methodProxy.invokeSuper(o, objects);        }    }});
复制代码


这个片段的意思是:如果调用子类对象的方法,会先被这里的 MethodInterceptor 拦截。拦截之后就会判断,如果调用的是 run 方法,那么就增加打印。之后通过 methodProxy.invokeSuper(o, objects)调用父类 Car.run()方法。

 

这样就通过 CGLIB 的 Enhancer 类动态生成了一个 Car 类的子类了,且定义好调用这个子类所继承父类的 run()方法时的额外逻辑,这就是动态创建类。

 

(3)限制 Metaspace 大小看看内存溢出效果


首先设置一下这个程序的 JVM 参数,限制 Metaspace 区域小一点。如下所示,把这个程序的 JVM 中的 Metaspace 区域设置为仅仅 10M:


 -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
复制代码


然后在上述代码中加入一个计数器,看看当前创建了多少个子类,如下所示:


long counter = 0;while (true) {    System.out.println("正在动态创建第" + (++counter) + "个子类");    ...}
复制代码


接着用上述 JVM 参数来运行程序,可以看到如下所示的打印输出:


正在动态创建第268个子类Exception in thread "main" java.lang.OutOfMemoryError: Metaspace  at java.lang.Class.forName0(Native Method)  at java.lang.Class.forName(Class.java:348)  at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:467)  at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:339)  at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)  at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:117)  at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:294)  at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)  at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)  at com.demo.rpc.test.CglibDemo.main(CglibDemo.java:29)
复制代码


目前创建了 268 个 Car 类的子类了,在创建了 268 个类时,10M 的 Metaspace 区域就被耗尽了,接着就会看到如下异常:


java.lang.OutOfMemoryError: Metaspace
复制代码


这个 OutOfMemoryError: Metaspace 就是经典的元数据区内存溢出,而且明确显示是 Metaspace 这块区域发生内存溢出了。一旦内存溢出,正常运行的 JVM 进程直接会崩溃掉,程序就会退出。

 

7.模拟 JVM 栈内存溢出的场景(线程调用 6000 次方法占 1M 栈内存)

 

(1)JVM 中的栈内存会占多大


一个问题:JVM 进程到底会占用机器多少内存?先不考虑其他内存区域,仅仅考虑核心区域:Metaspace 区域、堆内存区域、各个线程的栈内存区域。

 

一.Metaspace 区域一般会分配 512M


只要代码里不胡乱生成类,一般都能存放一个系统运行时需要的类(1.3 万个)。


二.堆内存区域一般会分配机器内存的一半大小


毕竟还要考虑机器的其他进程对内存的使用。


三.栈内存区域


考虑一个最基本的 4 核 8G 的线上机器配置:其中给 Metaspace 有 512M、给堆内存 4G,操作系统自己也用一些内存。那么可以认为有剩余一两 G 的内存是能留给栈内存的。

 

通常会设置每个线程的栈内存就是 1M,假设一个 JVM 进程内一共有 1000 个线程,这些线程包括:JVM 的后台线程 + 系统依赖的第三方组件的后台线程 + 系统核心工作线。如果每个线程的栈内存需要 1M,那 1000 个线程就需要 1G 的栈内存空间。所以基本上这套内存模型是比较合理的。

 

其实一般来说,4 核 8G 机器上运行的 JVM 进程,Tomcat 内部所有线程加起来大概几百个线程,也就占据几百 M 内存。如果线程太多,4 核 CPU 负载也会过高,也不好。

 

所以 JVM 对机器内存的总消耗就是:Metaspace 区域内存 + 堆内存 + 几百个线程的栈内存。

 

如果给每个线程的栈内存分配过大空间,那么能创建的线程数就会变少。如果给每个线程的栈内存分配过小空间,那么能创建的线程数就会较多。当然一般建议给栈内存分配 1M 的大小就可以了。

 

(2)栈内存溢出的原理


其实每个线程的栈内存是固定的,如果一个线程无限制地调用方法,每次方法调用都会有一个栈桢入栈,此时就会导致线程的栈内存被消耗殆尽。

 

通常而言我们的线程不会连续调用几千次甚至几万次方法。一般发生这种情况,只有一个原因,就是代码有 bug,出现了死循环调用或者是无限制的递归调用。最后连续调用几万次方法后,没法放入更多方法栈桢,栈内存就溢出了。

 

(3)栈内存溢出的代码示例


public class Demo {    public static long counter = 0;        public static void main(String[] args) {        work();    }        public static void work() {        System.out.println("第" + (++counter) + "次调用");        work();    }}
复制代码


上面的代码非常简单:就是 work()方法调用自己,进入一个无限制的递归调用,陷入死循环。在 main 线程的栈中,会不停压入 work()方法的栈桢,直到耗尽 1M 内存。然后需要设置这个程序的 JVM 的栈内存为 1M。


 -XX:ThreadStackSize=1m
复制代码


接着运行这段代码,会看到如下打印输出:


第5791次调用java.lang.StackOverflowError
复制代码


当这个线程调用 5790 次方法后,线程的虚拟机栈里会压入 5790 个栈桢。最终这 5790 个栈桢把 1M 的栈内存给塞满了,引发栈内存溢出,StackOverflowError 就是线程栈内存溢出。

 

(4)总结


可以看到 1M 的栈内存可让线程连续调用 5000 次以上的方法。其实这个数量已经很多了,除了递归,线程一般不会调用几千个方法。所以这种栈内存溢出是极少出现的,一般出现也都是代码中的 bug 导致。

 

8.模拟 JVM 堆内存溢出的场景(36 万个 Object 对象才占 10M 堆内存)

 

Metaspace 区域和栈内存的溢出,一般都是极个别情况下才会发生。堆内存溢出才是非常普遍的现象。一旦系统负载过高,比如并发量过大、数据量过大、出现内存泄漏等,就很容易导致 JVM 内存不够用,从而导致堆内存溢出,然后系统崩溃。所以接下来就模拟一下堆内存溢出的场景。

 

(1)堆内存溢出的原因


假设现在系统负载很高,不停地创建对象放入内存。一开始会将对象放入到新生代的 Eden 区,但因系统负载太高,很快 Eden 区就被占满,于是触发 YGC。

 

但 YGC 时发现,由于高负载,Eden 区里的对象大多都是存活的,而 S 区也放不下这些存活的对象,这时只能把存活对象放入老年代中。

 

由于每次 YGC 都有大批对象进入老年代,几次 YGC 后老年代就会被占满。在接下来的一次 YGC 后又有一大批对象要进入老年代时,就会触发 FGC。

 

但是这次 FGC 之后,老年代里还是占满了由于高负载而依然存活的对象。这时 YGC 的存活对象在 FGC 后还是无法放入老年代,于是就堆内存溢出。

 

(3)用示例代码来演示堆内存溢出的场景


如下代码所示:


public class Demo {    public static void main(String[] args) {        long counter = 0;        List<Object> list = new ArrayList<Object>();        while(true) {            list.add(new Object());            System.out.println("当前创建了第" + (++counter) + "个对象");        }    }}
复制代码


代码很简单,就是在一个 while 循环里不停地创建对象,而且对象全部都是放在 List 里面被引用的,也就是不能被回收。不停地创建对象,Eden 区满了,这些对象全部存活便全部转移到老年代。反复几次后老年代满了,然后 Eden 区再次满的时候触发 YGC。此时 YGC 后存活对象再次进入老年代,老年代会先 FGC。但这次 FGC 回收不了任何对象,因此 YGC 后的存活对象无法进入老年代。

 

所以接下来用下面的 JVM 参数来运行一下代码:限制堆内存大小总共就只有 10m,这样可以尽快触发堆内存的溢出。


 -Xms10m -Xmx10m
复制代码


在控制台打印的信息中可以看到如下的信息:


当前创建了第360145个对象Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
复制代码


所以从这里就可以看到:在 10M 的堆内存中,用最简单的 Object 对象占满老年代需要 36 万个对象。然后当堆内存实在放不下任何其他对象时,就会 OutOfMemory。而且会明确显示是 Java heap space,也就是堆空间发生了内存溢出。

 

9.一个超大数据量处理系统的 OOM(数据缓存本地 + 重试发送直到 Kafka 恢复)

 

(1)超大数据量处理系统的案例


前面提过一个大数据量的计算引擎系统,用该系统案例分析过 GC 问题。因为它处理的数据量实在是太大,负载也过高。所以该系统除了 GC 问题外,其实还有 OOM 问题。

 

该系统的工作流程就是,不停地从数据存储中加载大量的数据到内存里来进行复杂的计算。如下图示:



这个系统会不停地加载数据到内存里来计算,每次少则加载几十万条数据,多则加载上百万条数据,所以系统的内存负载压力是非常大的。

 

这个系统每次加载数据到内存里计算完毕后,就需要将计算好的数据推送给另一个系统。两个系统间的数据推送和交互,最适合基于消息中间件来实现。因此选择将数据先推送到 Kafka,另一个系统再从 Kafka 里取数据。这就是这个系统完整的运行流程:加载数据 -> 计算数据 -> 推送数据,如下图示:



(2)针对 Kafka 故障设计的高可用场景


数据计算系统要推送计算结果到 Kafka 去,万一 Kafka 挂了怎么办?此时就必须设计一个针对 Kafka 故障的高可用机制。

 

刚开始负责这块的工程师选择了一个思考欠佳的技术方案:一旦发现 Kafka 故障,就将数据都留存在内存里。然后从内存取出数据不停地进行重试,直到 Kafka 恢复。如下图示:



这时就有一个隐患了,万一真的遇上 Kafka 故障。那么一次计算对应的数据就驻留内存无法释放,一直重试等 Kafka 恢复。然后数据计算系统还在不停地加载数据到内存里来处理,每次计算完的数据还无法推送到 Kafka,又全部驻留在在内存里等待着。如此循环往复,必然导致内存里的数据越来越多,这绝对是一个不合理的方案。

 

(3)无法释放的内存最终导致 OOM


使用上面这个不合理的方案时,刚好发生了 Kafka 的短暂临时故障。此时系统无法将计算后的数据推送给 Kafka,便全部驻留在内存里等待。与此同时,数据计算系统还在不停加载数据到内存里计算,这必然会导致内存里的数据越来越多。

 

每次 Eden 区占满后,大量存活的对象必须转入老年代,而且老年代里的这些对象还无法释放,最终老年代一定会被占满。从而在某一次 Eden 区满了之后,一大批对象又要转移到老年代时,此时老年代即使 FGC 后还是没有空间能放得下存活对象,于是 OOM。最后这个系统全线崩溃,无法正常运行。

 

(4)如何处理这个问题


其实很简单,当时就临时直接取消了 Kafka 故障下的重试机制。一旦 Kafka 故障,直接丢弃掉本地计算结果,释放大量数据占用的内存。

 

后续的改进:一旦 Kafka 故障,则将计算结果写本地磁盘,允许内存中的数据被回收。这就是一个真实的线上系统设计不合理导致的内存溢出问题。

 

10.两个新手误写代码如何导致 OOM(方法循环调用自己 + 动态代理没缓存)

 

(1)案例一:写出了一个无限循环调用


这是由一位实习生写出一个 bug,导致线上系统出现栈内存溢出的场景。当时有一个非常重要的系统,我们设计了一个链路监控机制。也就是会在一个比较核心的链路节点,写一些重要日志到 ES 集群里去,事后会基于 ELK 进行核心链路日志的一些分析。

 

同时对这个机制做了规定:如果在某节点写日志时发生异常,此时也需要将该异常写入 ES 集群里。因为后续在分析时,需要知道系统运行到这里有一个异常。因此当时那位实习生写出来的伪代码大致如下:


try {    //业务逻辑    ...    log();} catch (Exception e) {    log();}

public void log() { try { //将日志写入ES集群 ... } catch (Ezception e) { log(); }}
复制代码


上述代码中:log()方法出现异常(ES 集群出现故障),会在 catch 中再次调用 log()方法。

 

有一次 ES 短暂故障了,结果导致 log()方法写日志到 ES 时抛异常。一旦 log()方法抛异常进入 catch 语句块时,又会再次重新调用 log()方法。然后 log()方法再次写 ES 抛异常,继续进入 catch 块,于是出现循环调用。

 

在 ES 集群故障时,线上系统本来不应该有什么问题的。因为核心业务逻辑都是可以运行,最多就是无法把日志写入 ES 集群而已。

 

但是因为这个循环调用的 bug,导致在 ES 故障时:所有系统全部写日志都会陷入一个无限循环调用 log()方法的困境中,而一旦方法在无限循环调用它自己,一定会导致线程的栈内存溢出,从而导致 JVM 崩溃。

 

改进措施:系统居然因为这么一个小问题崩溃了,这就是一次非真实的线上案例。后续通过严格的持续集成 + 严格的 Code Review 标准来避免此类问题,每个人每天都会写一点代码,这个代码必须配套单元测试可以运行的。然后代码会被提交到持续集成服务器上,并被集成到整体代码里。在持续集成服务器上,整体代码会自动运行单元测试 + 集成测试。

 

在单元测试+集成测试中:都会要求针对一些 try catch 中可能走到 catch 的分支写一些测试的。一旦有这类代码,只要提交到持续集成系统上,就会自动运行测试触发。此外每次提交的代码也必须交给指定的其他同事进行 Code Review。别人需要仔细审查提交的每一行代码,一旦发现问题就重新修改代码。从此之后,这种低端的问题再也没有发生过。

 

(2)案例二:没有缓存的动态代理


这个案例同样是之前的一个新手工程师写的。因为经验不足,有一次在实现一块代码机制时,犯了一个很大的错误。

 

简单来说,当时他想实现一个动态代理机制:即在系统运行时,针对已有的某个类生成一个动态代理类(动态生成类),然后对那个类的一些方法调用做些额外的处理。

 

大概的伪代码与下面的代码是类似的:


while (true) {    Enhancer enhancer = new Enhancer();    enhancer.setSuperclass(Car.class);    enhancer.setUseCache(false);    enhancer.setCallback(new MethodInterceptor() {        @Override        public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {              if (method.getName().equals("run")) {                  System.out.println("Check before run");                  return methodProxy.invokeSuper(o, objects);              } else {                  return methodProxy.invokeSuper(o, objects);              }        }    });

Car car = (Car) enhancer.create(); car.run();}
复制代码


类似这种代码有个问题:当使用 CGLIB 的 Enhancer 针对某个类动态生成一个子类后,这个动态生成类(Enhancer 对象)完全可以缓存起来。这样下次直接用这个已经生成好的子类来创建对象即可,如下所示:


private volatile Enhancer enhancer = null;public void doSomething() {    if (enhancer == null) {        this.enhancer = new Enhancer();        enhancer.setSuperclass(Car.class);        enhancer.setUseCache(false);        enhancer.setCallback(new MethodInterceptor() {            @Override            public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {                if (method.getName().equals("run")) {                    System.out.println("Check before run");                    return methodProxy.invokeSuper(o, objects);                } else {                    return methodProxy.invokeSuper(o, objects);                }            }        });
Car car = (Car) enhancer.create(); car.run(); }}
复制代码


其实这个动态生成类(Enhancer 对象)只要生成一次就可以了,下次可以直接用这个动态生成类(Enhancer 对象)创建一个对象。但是当时没有缓存这个动态生成类,每次调用方法都生成一个类。

 

有一次线上系统负载很高,于是这个框架瞬间创建了一大堆类,塞满 Metaspace 并无法回收。进而导致 Metaspace 区域直接内存溢出,系统也崩溃了。

 

后来对于这类问题的改进措施是:严格要求每次上线必须走自动化压力测试。在高并发压力下系统是否正常运行支撑 24 小时,以此判断是否可以上线。

 

这样类似于这类代码在上线之前就会被压力测试露出马脚,因为压力一大瞬间会引发这个问题。

 

(3)总结


上线前必须做代码 Review + 自动化压力测试。


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18653932

体验地址:http://www.jnpfsoft.com/?from=001YH

用户头像

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
JVM实战—OOM的原因和模拟以及案例_Python_不在线第一只蜗牛_InfoQ写作社区