深入理解JVM_3_OutOfMemoryError异常现场

引:之前面对JVM运行内存的分析,总会提到出现OutMemoryError异常,接下来我们详细看下常出现这种异常的现场。

Java堆溢出

我们通过限制Java堆的大小为20MB,不可扩张(将堆得最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时Dump(备份)出当前的内存堆转储快照以便时候分析处理。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class HeapOOM {

static class OOMobject {
}

public static void main(String[] args) {
List<OOMobject> list = new ArrayList<>();

while (true) {
list.add(new OOMobject());
}
}
}

运行结果:

1
2
3
4
5
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid81611.hprof ...
Heap dump file created [27573572 bytes in 0.121 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)...

我们很容易在输出结果中看到Java heap space OOM出现在堆中。我们要解决这个区域的异常主要是通过内存映像分析工具(很多)对Dump出来的堆转储快照进行分析,重点确认是内存泄漏(Memory leak)还是内存溢出(Memory Overflow)。

  1. 内存泄漏:被分配的内存的对象不会被回收,永久占据内存。

    解决方法:通过工具查看泄漏对象到GC Roots的引用链。

  2. 内存溢出:无法申请到内存。

    解决方法:检查虚拟机的堆参数(-Xmx与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长的情况,尝试减少程序运行期的内存消耗。

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

我们说过HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,所以设置本地方法栈大小是无效的,栈容量只由-Xss参数设定,在Java虚拟机规范中描述了两种异常

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

我个人想如果单线程中栈的内存大小等于总内存大小,那么上面两种异常应该是等价的吧,但是基本上是不可能的。所以单线程中出现得基本上都是StackOverflowError异常。

单线程代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// VM args: -Xss128k
public class HeapOOM {

static class OOMobject {
}

public static void main(String[] args) {
List<OOMobject> list = new ArrayList<>();

while (true) {
list.add(new OOMobject());
}
}
}

运行结果:

1
2
3
stack lenth:18855
Exception in thread "main" java.lang.StackOverflowError
at com.todorex.demo.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:10)

这里抄一下书的结论:在单个线程下,无论是由于栈帧太大还是虚拟机容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

多线程代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 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) {
JavaVMStackOOM javaVMStackOOM = new JavaVMStackOOM();
javaVMStackOOM.stackLeakByThread();
}
}

我在自己的机器上没有运行出来,可能需要点时间,不过机器变卡了,我想其实这里解释一下就好,它应该会抛出OutOfMemoryError异常。

解释:首先操作系统分给每个进程的内存是有限制的,所以总的方法栈的大小也是有限制的,但是每个线程都需要方法栈,所以线程建立的越多,剩余的方法栈内存就越小,一直创建线程,进程所拥有的内存终将被耗尽,到最后就会抛出OutOfMemoryError异常。

注意:线程数和方法栈大小是成反比的,所以在开发多线程的应用时应该特别注意,如果不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程了。

方法区(元空间)和运行时常量池溢出

运行时常量池溢出

在之前的博客也提到过在jdk1.6以及之前运行时常量池是放在方法区中的,存的是对象,所以可以设置虚拟机参数-XX:PermSize和-XX:MaxPermSize来限制方法区的大小,来模拟常量池溢出,但是jdk1.7及以后运行时常量池被移除了方法区,常量池存储的不再是对象,而是对象的引用,真正的对象存储在堆中,我们改变虚拟机参数为:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError运行下面程序:

1
2
3
4
5
6
7
8
9
10
11
// VM args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class RuntimeConstantPoolOOM {

public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

运行结果:

1
2
3
java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid10818.hprof ...
Heap dump file created [25172419 bytes in 0.251 secs]

上面结果提示GC开销超过限制,默认的话,如果你98%的时间都花在GC上并且回收了才不到2%的空间的话,虚拟机就会抛这个异常。

其实我们之前也提起过在JDK1.8及以后,字符串常量池从永久代移到到元空间中,元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:-XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整,如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。-XX:MaxMetaspaceSize,最大空间,默认是没有限制的。除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:-XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集,-XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集,具体验证代码如下:

1
2
3
4
5
6
7
8
9
10
11
// VM args: -XX:MetaspaceSize=4M -XX:MaxMetaspaceSize=4M
public class RuntimeConstantPoolOOM {

public static void main(String[] args) {
List<String> list = new ArrayList<>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}

运行结果:

1
2
Error occurred during initialization of VM
OutOfMemoryError: Metaspace

关于这个字符串常量池的实现问题,还真的会出现一个很意思的问题或者说是一个很奇怪的问题。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// jdk:1.8
public class RuntimeConstantPoolOOM {

public static void main(String[] args) {

String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);

String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);

String str3 = new StringBuilder("ma").append("in").toString();
System.out.println(str3.intern() == str3);
}
}

运行结果:

1
2
3
true
false
false

jdk1.6中,intern()方法会把首次遇到的字符串实例复制在永久代,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用。而在jdk1.7及以后,intern()的实现不会再复制实例,只是在常量池中记录首次出现得实例的引用,因此intern()返回的由StringBuilder创建的那个字符串是同一个实例,而关于上面的运行结果,我想java和main之前都是在字符串常量池中都有他的引用了,所以返回的都是false。

方法区溢出

方法区用于存放Class的相关信息,如类名,访问修饰符,常量池,字段描述,方法描述等,基本的思路是运行时产生大量的类去填充方法区,下面是借助CGLib(cglib和asm的依赖有个坑,选择cglib2.2,asm3.1亲测可用)来操作字节码运行时生成大量的动态类,这种场景在Spring,Hibernate中经常出现,需要多注意,本人使用的JDK1.8,所以测试的是方法区的变迁元空间,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// VM args:-XX:MetaspaceSize=4M -XX:MaxMetaspaceSize=4M
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 o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
return methodProxy.invokeSuper(o, objects);
}
});
OOMObject oomObject = (OOMObject) enhancer.create();
oomObject.sayHi();
}

}
static class OOMObject{
public void sayHi(){
System.out.println("hi");
}
}
}

结果输出:

1
2
Error occurred during initialization of VM
OutOfMemoryError: Metaspace

这类异常经常出现在web应用中,需要多注意。

本机直接内存溢出

DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样,在《深入理解Java虚拟机》中用了以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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);
}
}
}

但是在自己电脑中没有运行成功,反而让自己的电脑死机了,这个地方还是没有弄懂??????,希望懂的大佬给我点支持。

这个异常在使用NIO中可能会出现,所以在使用的时候需要多注意。

总结

在总结得过程中,知道了各个内存区域可能会出现OOM的情况,重要的是了解了方法区在jdk1.6到1.7到1.8的变迁,有兴趣的人可以深入了解。

参考

  1. 《深入理解Java虚拟机》
  2. CGLIB介绍与原理
  3. Java8内存模型—永久代(PermGen)和元空间(Metaspace)
  4. 深入探究JVM | 探秘Metaspace