深入理解Java虚拟机-2.4 实战:OutOfMemoryError异常- 高飞网

2.4 实战:OutOfMemoryError异常

2016-02-26 16:44:20.0

Java堆溢出

    Java堆用于存储对象实例,只要不断地创建对象,并且保证GC Roots到对象的引用之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制时就会产生内存溢出异常

    下面的清单中限制Java堆大小为20MB,不可扩展(堆最小值-Xms和最大值-Xmx设置相同),通过参数-XX:+HeapDumpOnOutOfMemoryError可让虚拟机在出现内存溢出异常时Dump出当前的内存堆转储快照以便事后进行分析。

/**
 * 测试JVM 堆内存溢出
 * java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOOM
 * 这里把最小最大内存设置为相同,即禁止了动态分配内存,当内存过大时就会抛出异常OutOfMemoryError
 * jhat -port 8080 java_pid19502.hprof
 */
import java.util.*;
public class HeapOOM{
	static class OOMObject{}
	public static void main(String[] args){
		List<OOMObject> list = new ArrayList<OOMObject>();
		/*while(true){
			list.add(new OOMObject());
		}*/
		System.out.println(new Object().getBytes().length+";");
	}
}

输出:

[deploy@iZ25w5rxg9zZ jvm]$ java -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError HeapOOM
 java.lang.OutOfMemoryError: Java heap space
 Dumping heap to java_pid19657.hprof ...
 Heap dump file created [27483228 bytes in 0.211 secs]
 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:2245)
	at java.util.Arrays.copyOf(Arrays.java:2219)
	at java.util.ArrayList.grow(ArrayList.java:242)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
	at java.util.ArrayList.add(ArrayList.java:440)
	at HeapOOM.main(HeapOOM.java:12)

如上已经出现异常信息了,要分析dump文件,可以用Eclipse IDE的Memory Analyzer Tool(MAT)。
Java堆内存的OOM异常是实际应用中常见的内存溢出异常。当出现Java堆内存溢出时,注意查看异常信息应为:java.lang.OutOfMemoryError: Java heap space

    如上是在Memory Analyze Tool插件中分析dump得到的分析图。这里可以通过See stacktrace查看异常堆栈或引用链发生发生异常的类和位置。

    解决思路:
    1)如果是内存泄露(不需要的对象但GC不掉),可进一步通过工具查看泄露对象到GC Roots的引用链,找到泄露的位置;
    2)如果是内存溢出(确实需要的对象但内存不够),则说明对象必须要活着,那么需要调整内存参数-Xms -Xmx。


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

    由于在HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,虽然-Xoss参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由-Xss参数设定。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:
    a)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflow异常
    b)如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常

这里把异常分为两部分,看似严谨,却存在着相互重叠的地方:当栈空间无法分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。

/**
 * 测试栈内存溢出
 * 方法每递归一次会生成一个同名的方法压入栈中
 * java -Xss228k StackOF
 *
 **/
public class StackOF{
        public static void main(String args[]){
                new StackOF().test();//递归调用
        }

        public void test(){
                test();
        }
}

输出:

[deploy@iZ25w5rxg9zZ jvm]$ java -Xss228k StackOF
 Exception in thread "main" java.lang.StackOverflowError
        at StackOF.test(StackOF.java:13)
        at StackOF.test(StackOF.java:13)
        at StackOF.test(StackOF.java:13)
        at StackOF.test(StackOF.java:13)
        ...

    实验表明:在单线程环境下,无论是由于栈帧太大,还是虚拟机栈容量太小,当内存无法分配时,虚拟机都抛出StackOverflowError异常。

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

    由于运行时常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起进行。

    String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。由于常量池分配在永久代内,可通过-XX:PermSize和-XX:MaxPermSize限制方法区大小,从而间接限制其中常量池的容量。

/**
 * 运行时常量池内存溢出
 * java -XX:PermSize=10m -XX:MaxPermSize=10m RuntimeConstantPoolOOM
 */
import java.util.*;
public class RuntimeConstantPoolOOM{
	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)
	at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOm.java 18)

       从运行结果中可以看到,运行时常量池溢出,在OutOfMemoryError后面跟随的提示信息是"PermGen space",说明运行时常量池属于方法区的一部分。但需要指出的是这是运行在jdk1.6环境中输出的结果,在jdk1.7以后,开始逐步“去永久代”的事情,就不会出现上面的异常,而是:

Java HotSpot(TM) 64-Bit Server VM warning: INFO: os::commit_memory(0x00000000f716a000, 63528960, 0) failed; error='Cannot allocate memory' (errno=12)
#
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (malloc) failed to allocate 63528960 bytes for committing reserved memory.
# An error report file with more information is saved as:
# /home/deploy/len/jvm/jmm/hs_err_pid9533.log

    关于字符串常量池的实现问题,还可以引申出一个更有意思的影响:

/**
 * 运行时常量池内存溢出
 * java -XX:PermSize=10m -XX:MaxPermSize=10m RuntimeConstantPoolOOM
 */
import java.util.*;
public class RuntimeConstantPoolOOM2{
	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);
	}
}

上面的程序在jdk1.6中会显示两个false,而在jdk1.7中,显示一个true,一个false。原因如下:

在jdk1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,必然不是同一个引用,将返回false。

在jdk1.7中的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例的引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。对str2比较返回false,是因为"java"这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合"首次出现"的原则,而"计算机软件"这个字符串则是首次出现的,因此返回true。

    方法区用于存放Class的相关信息,如类名、访问修饰符、常量池,字段描述、方法描述等。对于这些区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。下面用CGLIb创建代理。这个在现存框架spring、hibernate中都会出现。

import java.lang.reflect.Method;

import net.sf.cglib.proxy.Enhancer;
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;

/**
 *  * VM args: -XX:PermSize=10M -XX:MaxPermSize=10M
 */
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() {
                @Override
                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" 
 * Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
 */

在jdk1.6中会输出:

Caused by:java.lang.OutOfMemoryError:PermGen-space

    方法区溢出是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了CGLib字节码增强和动态语言之外,还有:大量jsp或动态产生jsp文件的应用(jsp第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会被视为不同的类)等。

本机直接内存溢出

    DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样。

    由DirectMemory导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见明显的异常,如果发现OOM之后Dump很小,而程序中又直接或间接使用了NIO,则应检查下是不是这方面的原因。

import java.lang.reflect.Field;

import sun.misc.Unsafe;
/**
 *VM args: -Xmx=20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
	private static final int _1MB = 1024 * 1024;
	public static void main(String[] args) throws IllegalArgumentException, IllegalAccessException {
		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 DirectMemoryOOM.main(DirectMemoryOOM.java:13)