OOM内存溢出问题

在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(OOM)异常的可能。通常而言,内存溢出问题对系统是毁灭性的,它代表VM内存不足以支撑程序的运行,所以—旦发生这个情况,就会导致系统直接停止运转,甚至会导致VM进程直接崩溃掉。OOM是非常严重的问题,这节就来看下通常有哪些原因导致OOM。
1、元空间溢出
1)元空间溢出原因

  • Metaspace 这块区域一般很少发生内存溢出,如果发生内存溢出—般都是因为两个原因:
  • Metaspace 参数设置不当,比如 Metaspace 内存给的太小,就很容易导致 Metaspace 不够用
  • 代码中用 CGLib、ASM、javassist 等动态字节码技术动态创建一些类,如果代码写的有问题就可能导致生成过多的类而把 Metaspace 塞满

2)模拟元空间溢出
下面通过CGLib来不断创建类来模拟塞满 Metaspace。
首先在 pom.xml 添加 cglib 的依赖:

<dependency>
    <groupId>cglib</groupId>
    <artifactId>cglib</artifactId>
    <version>3.2.4</version>
</dependency>

下面这段程序通过CGLib不断地创建代理类:

    @RequestMapping(value="test003")
    @ResponseBody
    public void test003(){
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(IService.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }

    static class IService {    }

设置如下的JVM参数:元空间固定100M,还添加了追踪类加载和卸载的参数

-Xms200M
-Xmx200M
-Xmn150M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseConcMarkSweepGC
-XX:+TraceClassLoading
-XX:+TraceClassUnloading
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:D:datagc.log

运行程序一会就报OOM错误,然后直接退出运行。
从 Caused by: java.lang.OutOfMemoryError: Metaspace 可以看出是由于 Metaspace 引起的OOM。而且从上面类加载的追踪可以看到,程序一直在加载CGLIB动态创建的代理类。

再看下GC日志:可以看出由于元空间满了触发了一次 FullGC。

2、栈溢出
1)栈溢出原因
通过前两篇文章可以知道,每个线程都会有一个线程栈,线程栈的大小是固定的,比如设置的1MB。这个线程每调用一个方法,都会将调用方法的栈桢压入线程栈里,方法调用结束就弹出栈帧。栈桢会存储方法的局部变量、异常表、方法地址等信息,也是会占用一定内存的。
如果这个线程不停的调用方法,不停的压入栈帧,而没有弹出栈帧,比如递归调用没有写好结束条件,那线程栈迟早都会被占满,然后导致栈内存溢出。一般来说,引发栈内存溢出,往往都是代码里写了一些bug导致的,正常情况下很少发生。
关于虚拟机栈和本地方法栈,《Java虚拟机规范》中描述了两种异常:StackOverflowError 和 OutOfMemoryError。
① StackOverflowError
如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。栈深度在大多数情况下到达1000~2000是完全没有问题,对于正常的方法调用,这个深度应该完全够用了。
② OutOfMemoryError
如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError 异常。而HotSpot虚拟机是不支持扩展的,而且栈深度是动态变化的,在设置线程栈大小时(-Xss),如果设置小一些,相应的栈深度就会缩小。
所以 HotSpot 虚拟机栈溢出只会因为栈容量无法容纳新的栈帧而导致 StackOverflowError 异常,而不会出现 OutOfMemoryError 异常。
2)模拟栈溢出
运行如下这段代码:递归调用 recursion 方法,没有结束条件,所以必定会导致栈溢出

    @RequestMapping(value="test004")
    @ResponseBody
    public void test004(@RequestParam("count") int count) {
        System.out.println("times: " + count++);
        test004(count);
    }

设置如下JVM参数:线程栈设置为256K

-Xms200M
-Xmx200M
-Xmn150M
-Xss256K
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M

运行一会就出现了 StackOverflowError 异常:

3、堆溢出
1)堆溢出原因
堆内存溢出主要就是因为有限的内存中放了过多的对象,而且大多数都是存活的,即使GC过后还是大部分都存活,然后堆内存无法在放入对象就导致堆内存溢出。
—般来说堆内存溢出有两种主要的场景:
系统负载过高,请求量过大,导致大量对象都是存活的,无法继续放入对象后,就会引发OOM系统崩溃
系统有内存泄漏的问题,莫名其妙创建了很多的对象,而且都是存活的,GC时无法回收,最终导致OOM
2)模拟堆溢出
运行如下代码:不断的创建 String 对象,而且都被 datas 引用着无法被回收掉,最终必然会导致OOM。

@RequestMapping(value="test005")
    @ResponseBody
    public void test005() {
        Set<String> datas = new HashSet<>();
        while (true) {
            datas.add(UUID.randomUUID().toString());
        }
    }

设置如下JVM参数:新生代、老年代各100M。

-Xms200M
-Xmx200M
-Xmn100M
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=10M
-XX:MaxMetaspaceSize=10M
-XX:+UseParNewGC

OutOfMemoryError:可以看到由于Java heap space 不够了导致OOM。

4、堆外内存溢出
1)堆外内存
Java中还有一块区域叫直接内存(Direct Memory),也叫堆外内存,它的的容量大小可通过 -XX:MaxDirectMemorySize 参数来指定,如果不指定,则默认与Java堆最大值(-Xmx)一致。
如果想在Java代码里申请使用一块堆外内存空间,可以使用 DirectByteBuffer 这个类,然后构建一个 DirectByteBuffer 对象,这个对象本身是在JVM堆内存里的。但是在构建这个对象的同时,就会在堆外内存中划出来一块内存空间跟这个对象关联起来。当 DirectByteBuffer 对象没地方引用了,成了垃圾对象之后,就会在某一次YoungGC或FullGC的时候把 DirectByteBuffer 对象回收掉,然后就可以释放掉 DirectByteBuffer 关联的堆外内存了。
2)模拟堆外内存溢出
如果创建了很多的 DirectByteBuffer 对象,占用了大量的堆外内存,而这些 DirectByteBuffer 对象虽然成为了垃圾对象,如果没有被GC回收掉,那么就不会释放堆外内存,久而久之,就有可能导致堆外内存溢出。
但是NIO实际上有个机制是当堆外内存快满了的时候,就调用一次 System.gc() 来建议JVM去执行一次 GC,把垃圾对象回收掉,进而释放堆外内存。
运行如下代码:通过 ByteBuffer.allocateDirect 循环分配1M的堆外内存,allocateDirect 内部会构建 DirectByteBuffer 对象。

    private static final int _1M = 1024 * 1024;

    @RequestMapping("/test002")
    public void test002(){
        ByteBuffer byteBuffer;
        for (int i = 0; i < 40; i++) {
            byteBuffer = ByteBuffer.allocateDirect(_1M);
        }
    }

设置如下JVM参数:新生代300M,堆外内存最大20M,这样不会触发YoungGC。

-Xms500M
-Xmx500M
-Xmn300M
-XX:MaxDirectMemorySize=20M
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:D:gc.log

运行程序后看GC日志:可以看到由于堆外内存不足,NIO调用了两次 System.gc(),这样就没有导致OOM了。

 如果我们再加上 -XX:+DisableExplicitGC 参数,禁止调用 System.gc():

-Xms500M
-Xmx500M
-Xmn300M
-XX:MaxDirectMemorySize=20M
-XX:+PrintGC
-XX:+DisableExplicitGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:D:gc.log

这时就会发现抛出了堆外内存溢出的异常了:

所以一般来说,如果程序中使用了堆外内存时,为了保险起见,就不要设置 -XX:+DisableExplicitGC 参数了。

5、OOM问题如何解决
1)OOM分析思路
一般来说解决OOM问题大致的思路是类似的,出现OOM时,首先从日志中分析是哪块区域内存溢出了,然后分析下OOM的线程栈,如果是自己编写的代码通过线程栈基本就能看出问题所在。
然后先检查下内存是否分配合理,是否存在频繁YoungGC和FullGC,因为如果内存分配不合理就会导致年轻代和老年代迅速占满或长时间有大量对象存活,那必然很快占满内存,也有可能导致OOM。
最后可以结合MAT工具分析下堆转储快照,堆转储包含了堆现场全貌和线程栈信息,可以知道是什么对象太多导致OOM的,然后分析对象引用情况,定位是哪部分代码导致的内存溢出,找出根源问题所在。
但是分析OOM问题一般来说是比较复杂的,一般线上系统OOM都不是由我们编写的代码引发的,可能是由于使用的某个开源框架、容器等导致的,这种就需要了解这个框架,进一步分析其底层源码才能从根本上了解其原因。
2)堆转储快照
加入如下启动参数就可以在OOM时自动dump内存快照:

  • -XX:+HeapDumpOnOutOfMemoryError:OOM时自动dump内存快照
  • -XX:HeapDumpPath=dump.hprof:快照文件存储位置

有了内存快照后就可以使用 MAT 这类工具来分析大量创建了哪些对象。但是对于堆外内存溢出来说,dump的快照文件不会看见什么明显的异常,这个时候就要注意检查下程序是不是使用了堆外内存,比如使用了NIO,然后从这方面入手去排查。

七、性能调优总结
1、调优过程总结
一般来说GC频率是越少越好,YoungGC的效率很快,FullGC则至少慢10倍以上,所以应尽可能让对象在年轻代回收掉,减少FullGC的频率。一般一天只发生几次FullGC或者几天发生一次,甚至不发生FullGC才是一个比较良好的JVM性能。
从前面的调优过程可以总结出来,老年代调优的前提是年轻代调优,年轻代调优的前提是合理分配内存空间,合理分配内存空间的前提就是估算内存使用模型。
因此JVM调优的大致思路就是先估算内存使用模型,合理分配各代的内存空间和比例,尽量让年轻代存活对象进入Survivor区,让垃圾对象在年轻代被回收掉,不要进入老年代,减少 FullGC 的频率。最后就是选择合适的垃圾回收器。

2、频繁FullGC的几种表现
当出现如下情况时,我们就要考虑是不是出现频繁的FullGC了:

  • 机器 CPU 负载过高
  • 频繁 FullGC 报警
  • 系统无法处理请求或者处理过慢

CPU负载过高一般就两个场景:

  • 在系统里创建了大量的线程,这些线程同时并发运行,而且工作负载都很重,过多的线程同时并发运行就会导致机器CPU负载过高。
  • 机器上运行的VM在执行频繁的FullGC,FullGC是非常耗费CPU资源的。而且频繁的FullGC会导致系统时不时的卡死。

3、频繁FullGC的几种常见原因
① 系统承载高并发请求,或者处理数据量过大,导致YoungGC很频繁,而且每次YoungGC过后存活对象太多,内存分配不合理,Survivor区域过小,导致对象频繁进入老年代,频繁触发FullGC
② 系统一次性加载过多数据进内存,搞出来很多大对象,导致频繁有大对象进入老年代,然后频繁触发FullGC
③ 系统发生了内存泄漏,创建大量的对象,始终无法回收,一直占用在老年代里,必然频繁触发FullGC
④ Metaspace 因为加载类过多触发FullGC
⑤ 误调用 System.gc() 触发 FullGC
4、JVM参数模板
通过前面的分析总结,JVM参数虽然没有固定的标准,但对于一般的系统,我们其实可以总结出一套通用的JVM参数模板,基本上保证JVM的性能不会太差,又不用一个个系统去调优,在某个系统遇到性能问题时,再针对性的去调优就可以了。
对于一般的系统,我们可能使用4核8G的机器来部署,那么总结一套模板如下:

  • 堆内存分配4G,新生代3G,老年代1G,Eden区2.4G,Survivor区各300M,一般来说YoungGC后存活的对象小于150M就没太大问题
  • 元空间给个 512M 一般就足够了,如果系统会运行时创建很多类,可以调大这个值
  • -XX:MaxTenuringThreshold 对象GC年龄调整为5岁,让长期存活的对象更快的进入老年代
  • -XX:PretenureSizeThreshold 大对象阀值设置为1M,如果有超过1M的大对象,可以调整下这个值
  • -XX:+UseParNewGC、-XX:+UseConcMarkSweepGC,垃圾回收器使用 ParNew + CMS 的组合
  • -XX:CMSFullGCsBeforeCompaction设置为0,每次FullGC后都进行一次内存碎片整理
  • -XX:+CMSParallelInitialMarkEnabled,CMS初始标记阶段开启多线程并发执行,降低FullGC的时间
  • -XX:+CMSScavengeBeforeRemark,CMS重新标记阶段之前,先尽量执行一次Young GC
  • -XX:+DisableExplicitGC,禁止显示手动GC
  • -XX:+HeapDumpOnOutOfMemoryError,OOM时导出堆快照便于分析问题
  • -XX:+PrintGC,打印GC日志便于出问题时分析问题
-Xms4G
-Xmx4G
-Xmn3G
-Xss1M
-XX:SurvivorRatio=8
-XX:MetaspaceSize=512M
-XX:MaxMetaspaceSize=512M
-XX:MaxTenuringThreshold=5
-XX:PretenureSizeThreshold=1M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=92
-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSWaitDuration=2000
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSParallelInitialMarkEnabled
-XX:+CMSScavengeBeforeRemark
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=dump.hprof
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:./gc.log
郭慕荣博客园
原文地址:https://www.cnblogs.com/jelly12345/p/14855696.html