JVM探秘:内存溢出
本系列笔记主要基于《深入理解java虚拟机:jvm高级特性与最佳实践 第2版》,是这本书的读书笔记。
在 java 虚拟机内存区域中,除了程序计数器外,其他几个内存区域都可能会发生outofmemoryerror,这次通过一些代码来验证虚拟机各个内存区域存储的内容。
在实际工作中遇到内存溢出异常时,需要做到能根据异常信息快速判断是哪个内存区域的溢出,知道什么样的代码会导致这些区域内存溢出,并且知道出现内存溢出后如何处理。
java堆溢出
java 堆用于存储对象实例,只要不断的扩展对象,并且保证 gc roots 到对象有可达路径来避免垃圾回收,那么对象数量到达堆的最大容量后就会发生内存溢出异常。
模拟堆内存溢出
以下代码会把堆大小限制在20m且不可扩展(将最小参数-xms
和最大参数-xmx
设为相同就会避免自动扩展),通过参数-xx:+heapdumponoutofmemoryerror
可以让虚拟机在发生内存溢出时dump出内存快照用来分析。
参数 | 说明 |
---|---|
-xx:+heapdumponoutofmemoryerror | 内存溢出时自动导出内存快照 |
-xx:heapdumppath=e:/dumps/ | 导出内存快照时保存的路径 |
/** * 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
配置对应的目录下,如下图:
堆内存溢出分析
要分析 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
文件,打开后显示的是概述信息,这里会显示快照的一些基本信息、环境属性以及线程信息。
然后点击类
,打开后如下图:
从上图可以看到,数量最多的且占用内存最大的对象是oomobject类型的实例,oomobject类型共有实例810,326
个,大小总共12,965,216
个字节(byte),而这些对象都是在while循环中new出来加入到list中的,都是应该存活的对象,也就是说发生的oom是内存溢出而不是内存泄漏。
然后在oomobject
的记录上右键点击在实例试图中显示
,则会打开实例视图,见下图:
可以看到其中一个oomobject
对象的引用链,它被一个object[]
数组中的元素引用,我们都知道arraylist
是基于数组实现的,而这个object[]
数组对象就是一个 gc root,它的内存地址是578296
。
虚拟机栈和本地方法栈溢出
在内存区域那篇文章讲到过,hotspot虚拟机把本地方法栈和虚拟机栈合二为一了,栈容量由-xss
参数设置。关于虚拟机栈和本地方法栈,虚拟机规范规定了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出*error异常。
- 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出outofmemoryerror异常。
这里把异常分为了两种,看似严谨实际上有相互重叠的地方,当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,本质上只是对同一个问题的不同描述而已。
有两种方法会抛出*error异常,一种是通过-xss
参数减小栈内存容量;一种是定义大量局部变量,从而增大此方法帧中的局部变量表的长度。以下代码是第一种:
/** * java栈内存溢出异常 * 通过减小栈内存容量抛出*error * 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.*error 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)
实验结果表明,不论是减小栈容量大小还是增加栈帧大小,当内存无法分配时虚拟机抛出的都是*error异常。
如果不限于单线程,不断的建立线程的情况下倒是会抛出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