JVM探秘3:内存溢出

Stella981
• 阅读 459

在 Java 虚拟机内存区域中,除了程序计数器外,其他几个内存区域都可能会发生OutOfMemoryError,这次通过一些代码来验证虚拟机各个内存区域存储的内容。

在实际工作中遇到内存溢出异常时,需要做到能根据异常信息快速判断是哪个内存区域的溢出,知道什么样的代码会导致这些区域内存溢出,并且知道出现内存溢出后如何处理。

Java堆溢出

Java 堆用于存储对象实例,只要不断的扩展对象,并且保证 GC Roots 到对象有可达路径来避免垃圾回收,那么对象数量到达堆的最大容量后就会发生内存溢出异常。

模拟堆内存溢出

以下代码会把堆大小限制在20M且不可扩展(将最小参数-Xms和最大参数-Xmx设为相同就会避免自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在发生内存溢出时Dump出内存快照用来分析。

/**
 * Java堆内存溢出异常
 * VM args: -Xms20M -Xmx20M -XX:+HeapDumpOnOutOfMemoryError
 * -Xms和-Xmx设为相同值避免堆内存自动扩展,
 * -XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在发生OOM时Dump出内存快照
 * Run With JDK 1.8
 * */
public class HeapOOM {

    static class OOMObject{
    }

    public static void main(String[] args){
        List<OOMObject> list = new ArrayList<>();
        while(true){
            list.add(new OOMObject());
        }
    }
}

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid1344.hprof ...
Heap dump file created [29068691 bytes in 0.108 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3210)
    at java.util.Arrays.copyOf(Arrays.java:3181)
    at java.util.ArrayList.grow(ArrayList.java:261)
    at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:235)
    at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:227)
    at java.util.ArrayList.add(ArrayList.java:458)
    at test.oom.HeapOOM.main(HeapOOM.java:21)

可以从异常信息中看到,OOM异常发生在“main”线程,发生的内存区域是“Java heap space”。

通过IntelliJ IDEA运行的话,可以点击Edit Configurations配置VM参数,生成的堆Dump快照文件为hprof后缀,存放在Working directory配置对应的目录下,如下图:

JVM探秘3:内存溢出

堆内存溢出分析

要分析 Java 堆的内存溢出,首先通过快照分析工具(如Java VisualVM)对 Dump 出来的的快照进行分析,确认内存中的对象是否是必要的。如果是不必要的而没有垃圾回收掉,则发生的是内存泄漏(Memory Leak);如果都是必要的,则是内存溢出(Memory Overflow)。

如果是内存泄漏,通过工具进一步查看对象实例到 GC Roots 的引用链,找到泄露对象是通过什么路径与 GC Roots 相关联导致垃圾收集器无法回收它们。根据泄露对象的类型信息和到 GC Roots 的引用链,就可以定位到泄露代码的位置。

如果是内存溢出,也就是说这些对象还都必须存活,那么就检查堆内存的大小参数(-Xms与-Xmx)与物理内存比较还是否可以调大,再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。

打开 JDK 自带的分析工具 Java VisualVM(bin目录下的jvisualvm.exe),点击文件->装入选择堆快照java_pid1344.hprof文件,打开后显示的是概述信息,这里会显示快照的一些基本信息、环境属性以及线程信息。

然后点击,打开后如下图:

JVM探秘3:内存溢出

从上图可以看到,数量最多的且占用内存最大的对象是OOMObject类型的实例,OOMObject类型共有实例810,326个,大小总共12,965,216个字节(byte),而这些对象都是在while循环中new出来加入到List中的,都是应该存活的对象,也就是说发生的OOM是内存溢出而不是内存泄漏。

然后在OOMObject的记录上右键点击在实例试图中显示,则会打开实例视图,见下图:

JVM探秘3:内存溢出

可以看到其中一个OOMObject对象的引用链,它被一个Object[]数组中的元素引用,我们都知道ArrayList是基于数组实现的,而这个Object[]数组对象就是一个 GC Root,它的内存地址是578296

虚拟机栈和本地方法栈溢出

在内存区域那篇文章讲到过,HotSpot虚拟机把本地方法栈和虚拟机栈合二为一了,栈容量由-Xss参数设置。关于虚拟机栈和本地方法栈,虚拟机规范规定了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这里把异常分为了两种,看似严谨实际上有相互重叠的地方,当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,本质上只是对同一个问题的不同描述而已。

有两种方法会抛出StackOverflowError异常,一种是通过-Xss参数减小栈内存容量;一种是定义大量局部变量,从而增大此方法帧中的局部变量表的长度。以下代码是第一种:

/**
 * Java栈内存溢出异常
 * 通过减小栈内存容量抛出StackOverflowError
 * VM args: -Xss128K
 * Run With JDK 1.8
 * */
public class StackOOM {

    private int stackLength = 1;
    
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) throws Throwable {
        StackOOM oom = new StackOOM();
        try {
            oom.stackLeak();
        }catch(Throwable e){
            System.out.println("stack length: " + oom.stackLength);
            throw e;
        }
    }
}

运行结果:

stack length: 998
Exception in thread "main" java.lang.StackOverflowError
    at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:15)
    at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:16)
    at com.cellei.outofmemory.StackOOM.stackLeak(StackOOM.java:16)
    ...
    at com.cellei.outofmemory.StackOOM.main(StackOOM.java:22)

实验结果表明,不论是减小栈容量大小还是增加栈帧大小,当内存无法分配时虚拟机抛出的都是StackOverflowError异常。

如果不限于单线程,不断的建立线程的情况下倒是会抛出OutOfMemoryError异常,但跟栈空间是否足够大没有直接关系,而且栈是线程私有的内存区域。这种情况下,每个线程的栈分配的内存越大,就越容易产生内存溢出异常。

虚拟机提供了参数来控制堆内存和方法区的最大容量,物理内存减去堆内存最大值,再减去方法区的最大值,程序计数器消耗内存很小忽略不计,剩下的就被虚拟机栈和本地方法栈瓜分了。所以每个线程分配到的栈容量越大,则可以建立的线程数量越少,建立线程时就越容易把剩下的内存耗尽。如果建立过多导致了内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

方法区和运行时常量池溢出

JDK1.6及之前,运行时常量池是方法区的一部分,且方法区还使用永久代实现,那时候可以在限制永久代大小的情况下,循环调用String.intern()方法造成运行时常量池溢出而导致方法区溢出。使用参数-XX:PermSize-XX:MaxPermSize来限制永久代也就是方法区的大小。String.intern()方法是一个Native方法,作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表常量池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中。

JDK1.7的时候常量池挪到了堆内存中,到了JDK1.8就干脆取消了永久代,取而代之的是元空间(MetaSpace),且元空间是位于本地内存而不是虚拟机内存。

以下代码,在JDK1.6及之前的版本中会产生内存溢出:

/**
 * 要求运行在 JDK1.6 或以前
 * 导致常量池溢出从而产生永久代溢出
 * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
 * Run With JDK 1.6
 */
public class ConstantPoolOverflowTest
{
    public static void main(String[] args)
    {
        List<String> list = new ArrayList<String>();
        int i = 0;
        while (true)
        {
            list.add(String.valueOf(i++).intern());
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    ...

可见运行结果提示了PermGen space,表明是那个版本的永久代也就是方法区溢出。

既然JDK1.7及之后常量池挪到了 Java 堆中,在那之后的版本如何产生方法区溢出呢?既然方法区用于存放类的相关信息,基本思路就是在运行时产生大量的类去填充方法区,直到溢出。可以使用 JDK 的动态代理,也可以使用第三方库比如 CGLib 实现。

以下代码使用CGLib库,在运行时不断的产生类导致方法区溢出。由于JDK1.8的方法区改为了使用元空间实现,所以可以使用参数-XX:MetaspaceSize-XX:MaxMetaspaceSize限制方法区大小。

/**
 * 限制元空间大小后
 * 使用CGLib运行时产生类,导致元空间也就是方法区溢出
 * VM Args:-XX:MetaspaceSize=8M -XX:MaxMetaspaceSize=28M
 * Run With JDK 1.8
 */
public class MethodAreaOOM {

    static class OOMObject{
    }

    public static void main(String[] args){
        while(true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(OOMObject.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                public Object intercept(Object o, Method method, Object[] objects, 
                MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o, objects);
                }
            });
            enhancer.create();
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:345)
    at net.sf.cglib.proxy.Enhancer.generate(Enhancer.java:492)
    at net.sf.cglib.core.AbstractClassGenerator$ClassLoaderData.get(AbstractClassGenerator.java:114)
    at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:291)
    at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:480)
    at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:305)
    at com.cellei.oom.MethodAreaOOM.main(MethodAreaOOM.java:29)

可见异常信息提示Metaspace,就是说元空间(方法区)内存溢出了。方法区溢出也是一种比较常见的溢出,一个类要被垃圾收集器回收,判定条件是比较苛刻的。在经常动态产生大量 Class 的应用中,要特别注意类的回收情况。

本机内存直接溢出

DirectMemory容量可以通过参数-XX:MaxDirectMemorySize指定,如果不指定,则默认与 Java 堆最大值(-Xmx)一样。通过反射获取Unsafe实例进行内存分配,allocateMemory()方法会真正申请分配内存。

/**
 * 不断的申请内存,导致本机内存溢出
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 * Run With JDK 1.8
 * */
public class DirectMemoryOOM {

    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) throws Exception{
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1M);
        }
    }
}

运行结果:

Exception in thread "main" java.lang.OutOfMemoryError
    at sun.misc.Unsafe.allocateMemory(Native Method)
    at com.cellei.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:20)

由DirectMemory导致的内存溢出,有一个特点就是Heap Dump文件中不会看到明显异常,如果Dump文件非常小,又直接间接使用了NIO,则有可能是这方面的原因。

本文代码的 Github Repo 地址:https://github.com/cellei/JVM-Practice

发表于 2018-04-12,最后编辑于 2018-04-15
本文作者: Cellei
本文链接: http://www.cellei.com/blog/2018/04121
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

点赞
收藏
评论区
推荐文章
九路 九路
3年前
1 Java内存区域与内存溢出异常
1java虚拟机对内存的管理java虚拟机在执行java程序的时候把内存分为若干个不同的区,这些区各自有不同的用处,以及创建和销毁时间.有的区随着虚拟机的启动而启动,有的区则依赖用户线程的启动和结束而启动和结束.根据java虚拟机规范,java虚拟机将内存分为下面几个部分:如下图image(https://imghelloworld.o
Wesley13 Wesley13
2年前
java 内存溢出 栈溢出的原因与排查方法
1、内存溢出的原因是什么?内存溢出是由于没被引用的对象(垃圾)过多造成JVM没有及时回收,造成的内存溢出。如果出现这种现象可行代码排查:一)是否应用中的类中和引用变量过多使用了Static修饰如publicstaitcStudents;在类中的属性中使用static修
Wesley13 Wesley13
2年前
java虚拟机(四)
 学习了java运行时数据区,知道每个内存区域保存什么数据,可以参考:https://www.cnblogs.com/huigelaile/p/diamondshine.html,然后了解内存溢出和内存泄露是很有必要的,一方面是为了面试,更重要是的在工作中能够快速定位错误原因并且解决内存溢出分类:1、java.lang.OutOf
Wesley13 Wesley13
2年前
Java 几种常见的OOM
Java虚拟机内存有好几个运行时数据区会有OOM的异常,如果能够区分根据报错区分出是哪些区域报出来的异常,会更便于定位问题,解决问题。1.Java堆溢出原因:由于不断创建对象实例,当对象数量达到了最大堆的容量限制后产生内存溢出异常。现象:java.lang.OutOfMemoryError:Javaheapspace解决方法:1)首
Easter79 Easter79
2年前
Tomcat中JVM内存溢出及合理配置
Tomcat本身不能直接在计算机上运行,需要依赖于硬件基础之上的操作系统和一个Java虚拟机。Tomcat的内存溢出本质就是JVM内存溢出,所以在本文开始时,应该先对JavaJVM有关内存方面的知识进行详细介绍。一、JavaJVM内存介绍JVM管理两种类型的内存,堆和非堆。按照官方的说法:“Java虚拟机具有一个堆,堆是运行时数据区域,
Wesley13 Wesley13
2年前
Java内存溢出和内存泄露后怎么解决
1.首先这里先说一下内存溢出和内存泄露的区别:内存溢出outofmemory,是指程序在申请内存时,没有足够的内存空间供其使用,出现outofmemory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。内存泄露memoryleak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,
Wesley13 Wesley13
2年前
Java知识图谱
1JVM1.内存模型(内存分为几部分?堆溢出、栈溢出原因及实例?线上如何排查?)2.类加载机制3.垃圾回收2Java基础什么是接口?什么是抽象类?区别是什么?什么是序列化?网络通信过程及实践什么是线程?java线程池运行过程及实践(Exec
Wesley13 Wesley13
2年前
Java内存区域与内存溢出异常
Java的内存管理是一个老生常谈的问题,虽然Java号称可以自动管理自己的内存,使程序员从内存管理的围墙解放出来,但是一连串的内存泄漏和溢出方面的问题,使得我们不得不去深入了解Java的内存管理机制。本篇文章将从Java的内存区域开始剖析Jvm的内存机制,阐述内存溢出异常产生的原因。运行时数据区域众说周知,Java程序是运行在Java虚拟机
Stella981 Stella981
2年前
JVM笔记二:Java内存区域
Java程序在虚拟机自动内存管理的机制的帮助下,不容易出现内存泄露和内存溢出问题,这也就要求程序员需要了解虚拟机处理内存的机制,以解决OOM问题。运行时数据区域!Java虚拟机运行时数据区(https://oscimg.oschina.net/oscnet/3755e1d9e9bf4068b2b3b77b4c0b6bf99b8.jpg)
Wesley13 Wesley13
2年前
tomcat报错:This is very likely to create a memory leak问题解决
这种问题在开发中经常会碰到的,看看前辈的总结经验Tomcat内存溢出的原因  在生产环境中tomcat内存设置不好很容易出现内存溢出。造成内存溢出是不一样的,当然处理方式也不一样。  这里根据平时遇到的情况和相关资料进行一个总结。常见的一般会有下面三种情况:  1.OutOfMemoryError:Javaheapspace