JVM-OOM异常
在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能。
1、Java堆溢出
先介绍两个概念:
- 内存溢出(Out Of Memory) :申请内存空间时,JVM没有足够的内存空间了。
- 内存泄露 (Memory Leak):申请内存空间时,申请到了,但是没有释放,导致内存空间浪费。
/**
* VM Args:
* -Xms20m : 堆空间最少的容量
* -Xmx20m : 堆空间最大的容量
* -XX:+HeapDumpOnOutOfMemoryError:打印堆栈溢出错误参数
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
// 打印信息
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid24264.hprof ...
Heap dump file created [28170028 bytes in 0.102 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:267)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
at java.util.ArrayList.add(ArrayList.java:464)
at jvm.HeapOOM.main(HeapOOM.java:15)
Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟随进一步提示“Java heap space”。
要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如:我这里利用的是IDEA 适配的插件:JProfiler ;具体怎么安装和使用可自行百度)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(MemoryOverflow)。下图是我使用JProfiler 打开的堆转储快照文件。
可以在图中看到,这里创建的了大量的OOMObject对象:
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。
2、虚拟机栈和本地方法栈溢出
由于HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
- 1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
- 2)如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。
《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。
尝试下面两种行为是否能让HotSpot虚拟机产生OutOfMemoryError异常:
-
使用-Xss参数减少栈内存容量:
结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
-
定义了大量的本地变量,增大此方法帧中本地变量表的长度。
结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
-
测试一:使用-Xss参数减少栈内存容量
/** * VM Args:-Xss128k 栈容量大小 */ public class JavaVMStackSOF { private int stackLength = 1; public void stackLeak() { stackLength++; stackLeak(); } public static void main(String[] args) throws Throwable { JavaVMStackSOF oom = new JavaVMStackSOF(); try { oom.stackLeak(); } catch (Throwable e) { System.out.println("stack length:" + oom.stackLength); throw e; } } } // 运行结果: stack length:987 Exception in thread "main" java.lang.StackOverflowError at jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12) at jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13) at jvm.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13) .... 省略 at jvm.JavaVMStackSOF.main(JavaVMStackSOF.java:19) -
测试二:定义了大量的本地变量,增大此方法帧中本地变量表的长度。
/** * VM Args:-Xss128k */ public class JavaVMStackSOF { private static int stackLength = 0; public static void test() { long unused1, unused2, unused3, unused4, unused5, unused6, unused7, unused8, unused9, unused10, unused11, unused12, unused13, unused14, unused15, unused16, unused17, unused18, unused19, unused20, unused21, unused22, unused23, unused24, unused25, unused26, unused27, unused28, unused29, unused30, unused31, unused32, unused33, unused34, unused35, unused36, unused37, unused38, unused39, unused40, unused41, unused42, unused43, unused44, unused45, unused46, unused47, unused48, unused49, unused50, unused51, unused52, unused53, unused54, unused55, unused56, unused57, unused58, unused59, unused60, unused61, unused62, unused63, unused64, unused65, unused66, unused67, unused68, unused69, unused70, unused71, unused72, unused73, unused74, unused75, unused76, unused77, unused78, unused79, unused80, unused81, unused82, unused83, unused84, unused85, unused86, unused87, unused88, unused89, unused90, unused91, unused92, unused93, unused94, unused95, unused96, unused97, unused98, unused99, unused100; stackLength++; test(); unused1 = unused2 = unused3 = unused4 = unused5 = unused6 = unused7 = unused8 = unused9 = unused10 = unused11 = unused12 = unused13 = unused14 = unused15 = unused16 = unused17 = unused18 = unused19 = unused20 = unused21 = unused22 = unused23 = unused24 = unused25 = unused26 = unused27 = unused28 = unused29 = unused30 = unused31 = unused32 = unused33 = unused34 = unused35 = unused36 = unused37 = unused38 = unused39 = unused40 = unused41 = unused42 = unused43 = unused44 = unused45 = unused46 = unused47 = unused48 = unused49 = unused50 = unused51 = unused52 = unused53 = unused54 = unused55 = unused56 = unused57 = unused58 = unused59 = unused60 = unused61 = unused62 = unused63 = unused64 = unused65 = unused66 = unused67 = unused68 = unused69 = unused70 = unused71 = unused72 = unused73 = unused74 = unused75 = unused76 = unused77 = unused78 = unused79 = unused80 = unused81 = unused82 = unused83 = unused84 = unused85 = unused86 = unused87 = unused88 = unused89 = unused90 = unused91 = unused92 = unused93 = unused94 = unused95 = unused96 = unused97 = unused98 = unused99 = unused100 = 0; } public static void main(String[] args) { try { test(); } catch (Error e) { System.out.println("stack length:" + stackLength); throw e; } } } // 运行结果: stack length:51 Exception in thread "main" java.lang.StackOverflowError at jvm.JavaVMStackSOF.test(JavaVMStackSOF.java:32) at jvm.JavaVMStackSOF.test(JavaVMStackSOF.java:33) at jvm.JavaVMStackSOF.test(JavaVMStackSOF.java:33) .... 省略 at jvm.JavaVMStackSOF.main(JavaVMStackSOF.java:58) -
上述两个测试案例说明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。
注意:上面两个测试案例都是基于 单线程测试的,如果测试时不局限于单线程通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常。
-
测试三:该种情况是通过同步不停的创建线程,导致内存溢出异常(不要轻易用,电脑容易死机)
注意,这里是设置的是占容量大小
/** * VM Args:-Xss2M 栈容量大小 */ public class JavaVMStackOOM { private void dontStop() { while (true) { } } public void stackLeakByThread() { while (true) { Thread thread = new Thread(new Runnable() { @Override public void run() { dontStop(); } }); thread.start(); } } public static void main(String[] args) throws Throwable { JavaVMStackOOM oom = new JavaVMStackOOM(); oom.stackLeakByThread(); } } // 运行结果: // 这里直接照抄书本内容,64位 windows没有测出来这个结果,直接电脑死机了。 // 在32位操作系统下的运行结果: Exception in thread "main" java.lang.OutOfMemoryError: unable to create native thread
出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~2000是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。
但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到。
3、方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK 7开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。
我自己这里用的JDK8测试,所以是元空间,且字符串常量池被移至Java堆之中了。
无论是在JDK 7中继续使用-XX:MaxPermSize参数或者在JDK 8及以上版本使用-XX:MaxMetaSpaceSize参数把方法区容量同样限制在6MB,也都不会重现JDK 6中的溢出异常,循环将一直进行下去,就是因为字符串常量池被移至Java堆之中。
所以,下面在测试运行时常量池的时候,设置的是:堆大小 -Xmx6m
3.1 运行时常量池
/**
* VM Args:
* -XX:PermSize=6M
* -XX:MaxPermSize=6M
* 上面这个设置永久代在 JDK7之后没用了;
* 我这里代码测试用的是 JDK8,所以通过设置 堆大小:-Xmx6m 才回出现异常,且此时的异常是:堆内存溢出
*/
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
Set<String> set = new HashSet<String>();
// short类型的 常量会放在常量池中
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
// 运行结果:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:704)
at java.util.HashMap.putVal(HashMap.java:663)
at java.util.HashMap.put(HashMap.java:612)
at java.util.HashSet.add(HashSet.java:220)
at jvm.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:16)
这里再介绍一下String::intern() :
String::intern() 是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。
下面这段代码在JDK 6中运行,会得到两个false,而在JDK 7中运行,会得到一个true和一个false。产生差异的原因是,在JDK 6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。
而JDK 7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为“java” 这个字符串在执行String-Builder.toString()之前就已经出现过了,字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。
至于为什么 "java"已经出现过了,看代码里面注释的解释:(我这里利用的JDK8测试的)
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
String str1 = new StringBuilder("烤").append("包子").toString();
System.out.println(str1.intern() == str1);
// 这是因为“java”这个字符串在执行StringBuilder.toString()之前就已经出现过了
// 应该是 字节码编译阶段,类加载阶段,会对 rt.jar这些包进行加载;
// 所以,"java" 这个 字符串字面量应该提前加载进入常量池中
// 使得,"java" 在这里之前已经提前出现
// 所以,这里返回 true
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
// 运行结果:
true
false
3.2 方法区溢出
方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。
方法区溢出利用CGLIB直接操作字节码运行时生成了大量的动态类: (注意,这里是和堆溢出的区别,这里生成的是动态类)
- pom.xml需要导入CGLIB
<dependency> <groupId>cglib</groupId> <artifactId>cglib</artifactId> <version>2.2.2</version> </dependency> - 借助CGLib使得方法区出现内存溢出异常:(元空间OOM)
/** * VM Args: * 这个是JDK7 永久代: * -XX:PermSize=10M -XX:MaxPermSize=10M * JDK8 元空间: * -XX:MaxMetaspaceSize=10M 元空间最大值 * -XX:MetaspaceSize=10M 元空间的初始空间大小 * -XX:MinMetaspaceFreeRatio * 作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。 * -XX:Max-MetaspaceFreeRatio * 用于控制最大的元空间剩余容量的百分比 */ public class JavaMethodAreaOOM { 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 obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } static class OOMObject { } } // 运行结果: Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
4、本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过 -XX:MaxDirectMemorySize 参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致,下面的代码实例越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe的部分功能通过VarHandle开放给外部使用),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。
由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。
/**
* VM Args:
* -Xmx20M : 堆内存最大值
* -XX:MaxDirectMemorySize=10M : 直接内存大小
*/
public class DirectMemoryOOM {
private static final int _1MB = 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(_1MB);
}
}
}
// 运行结果:
Exception in thread "main" java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at jvm.DirectMemoryOOM.main(DirectMemoryOOM.java:20)