Java OutOfMemory异常清单 —— 在自己的机器上制造内存溢出

带着问题阅读

  • 如何制造OutOfMemory
  • jvm启动参数怎么设置?
  • 如何根据异常信息判断哪个区域发生内存溢出?
  • 发生内存溢出后如何解决?
  • 方法区从JDK1.6到JDK1.8都经历了什么?
  • JDK1.8新增的Metaspace是什么东西?


导语

上一讲我们已经了解了Java虚拟机的内存模型,既然我们知道各个内存区域存储的内容,那么只要在代码上做一些“手脚”,就可以制造出内存溢出(OutOfMemory)异常,这就是我们这一讲要做的事。

在机器上制造各种OutOfMemory异常,目的有三:

  • 通过代码验证上一讲所讲的各个运行时区域所存储的内容;
  • 帮助读者在实际项目中遇到内存溢出异常时,能够根据异常信息快速判断是哪个区域的内存溢出,知道什么样的代码可能导致这些区域内存溢出,以及出现异常后该如何处理。
  • 以后大家遇到OutOfMemory,把异常环境信息在这篇文章搜索一下,就可以找到分析和解决的思路。

本文是Effective Java专栏Java虚拟机专题的第三讲,如果你觉得看完之后对你有所帮助,欢迎订阅本专栏,也欢迎您将本专栏分享给你身边的工程师同学。

在学习本节课程之前,建议您了解一下以下知识点:

  • Java虚拟机的都有哪几块内存区域?每个区域存储的都是什么数据?


下文中代码的开头的注释都说明了执行代码时要设置的虚拟机启动参数。如果您使用控制台命令来执行,直接在java命令后面加上启动参数即可;如果是通过Eclipse IDE来执行,则在Debug/Run Configuration - Arguments页签进行设置:

Java OutOfMemory异常清单 —— 在自己的机器上制造内存溢出_第1张图片


Java堆溢出

通过上一讲的讲解,我们已经知道,Java堆是用于存储对象实例的,因此,只要我们不断创建对象,并且保证对象不被垃圾回收机制清除,那么当堆中对象的大小超过了最大堆的容量限制,就会出现堆内存溢出。

下面这段代码,将Java堆的大小设置为20MB,并且不可扩展(通过将堆的最小值-Xms参数和最大值-Xmx参数设置为相等的20MB);通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常时生成当前内存堆的快照,以便事后进行分析。

/**
 * VM Args:-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 */
public class HeapOOM {

	static class OOMObject {
	}

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

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

运行结果:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid10284.hprof ...
Heap dump file created [27866984 bytes in 0.144 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 com.hzy.jvm.chp02.HeapOOM.main(HeapOOM.java:18)

控制台打印了OutOfMemoryError,并且提示是Java heap发生的内存溢出,同时生成了dump文件。

对于Java堆溢出,一般通过内存映像分析工具(如Eclipse Memory Analyzer)对dump文件进行堆快照分析,确认内存中的对象是不是必要的:

  • 如果对象不是必要的,那就属于内存泄漏,需要分析为什么对象没有被回收;
  • 如果对象确实有必要继续存在,那就属于内存溢出,需要通过将堆的大小调高(-Xmx和-Xms)来避免内存溢出。

这些都只是Java堆内存问题的简单思路,后面的课程将会教大家如何使用内存分析工具进行分析。


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

由于HotSpot虚拟机不区分虚拟机栈和本地方法栈,因此,对于HotSpot来说,-Xoss参数(设置本地方法栈大小)是无效的,栈容量只由-Xss设置。

在Java虚拟机规范中,这个区域有两种异常情况:

  • 如果线程运行时的栈帧(什么是栈帧?)的总大小超过虚拟机限制的大小,会抛出StackOverflow异常,这一点通常发生在递归运行时;
  • 如果虚拟机栈设置为可以动态扩展,并且在扩展时无法申请到足够内存,则会抛出OutOfMemory异常。

StackOverflowError

首先我们来写一个递归的程序,验证第一点:

/**
 * 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:1001
Exception in thread "main" java.lang.StackOverflowError

控制台打印了异常发生时的栈的深度,1001,并且抛出StackOverflowError。这里的栈的深度,取决于栈中每个栈帧的大小,这个在不同机器上是不一样的,栈帧越大,栈所能达到的深度就越小。

我们可以简单理解为:

if(栈的深度*栈帧的平均大小 > -Xss的值)

then StackOverflowError

因此,当发生StackOverflowError时,我们要检查这些栈的创建都是有必要的吗:

  • 如果是程序的原因导致代码不停的递归,那么就是bug;
  • 如果确实需要递归这么多次,需要这么大的栈容量,那么就要调高-Xss的值,获取更大的栈容量。

OutOfMemoryError

对于栈的OutOfMemory异常,这里引用周老师的结论,用自己的话转述一下:

在单个线程下,当栈的大小超过-Xss设置的大小限制时,抛出的都是StackOverflowError;

在多线程的情况下,由于每创建一个线程,都需要划分一部分的内存,因此当机器内存已经被消耗干净时,再去创建线程,由于已经无法划分内存给新的线程,因此会导致OutOfMemory异常。

下面这段代码可以产生OutOfMemory异常(不建议执行,本人执行时电脑直接死机了)

/**
 * 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();
       }
}


报错信息类似于:

java.lang.OutOfMemoryError: unable to create new native thread

对于这种异常,我们需要确认是否有必要创建这么多线程,如果真的有必要,那么我们可以通过减少最大堆容量和减少栈容量,来让虚拟机占用的内存更小,有更多的内存可以用来创建线程。


方法区溢出

方法区存储的是虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据,在JDK1.7之前,HotSpot都是使用“永久代”来管理这些数据,也就是说,方法区的数据,其实也是属于堆内存的,会占用堆内存

因此:方法区的数据大小 < -XX:MaxPermSize < -Xmx

我们只要限制一下永久代的大小(-XX:MaxPermSize),很容易就会发生方法区溢出。


常量溢出

下面来演示一下常量过多导致的方法区溢出,这里用到了String.intern()方法,它的作用是:如果字符串常量池已经包含了这个String对象的字符串,则直接返回,否则,将此String对象添加到常量池中,并返回此String对象的引用。

首先请在JDK1.6的环境下运行下面这段代码,我们将永久代的大小限制为10MB:

/**
 * VM Args:-XX:PermSize=10M -XX:MaxPermSize=10M
 */
public class RuntimeConstantPoolOOM {

	public static void main(String[] args) {
		// 使用List保持着常量池引用,避免Full GC回收常量池行为
		List list = new ArrayList();
		// 10MB的PermSize在integer范围内足够产生OOM了
		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 com.hzy.jvm.chp02.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)


接着,我们在JDK1.7的环境下执行同样的代码,会发现while循环一直执行下去。原因是在JDK1.7中,HotSpot已经不再将常量放入永久代中进行管理,而是放到内存上限是本机内存的Native Memory中,这样做的好处就是减少了内存溢出的几率(没有了-XX:MaxPermSize的限制),同时常量不再占用堆的内存。这种“去永久代”的做法,从JDK1.7开始进行。


类信息溢出

当然,JDK1.7并没有完成地“去永久代”,因此还是会出现OutOfMemoryError:PermGen space. 尤其是当运行时产生大量的类的时候,当前很多主流框架,如Spring、Hibernate,都会在对类进行增强时,使用到CGLib这样的字节码技术,动态产生大量的Class,因此如果配置不当,很容易出现永久代溢出。

运行下面代码(需要下载cglib.lib, 我用的是3.2.5,另外还需要下载cglib所依赖的asm.jar,3.2.5的cglib配套的是5.2的asm):

/**
 * 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() {
				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.8的Metaspace溢出和堆溢出

从JDK1.7开始的“去永久代”化,终于在JDK1.8中,被彻底的执行了,永久代被彻底removed,同时HotSpot新增了一块叫做Mataspace的区域,并提供了-XX:MetaspaceSize-XX:MaxMetaspaceSize参数,来设置运行Java虚拟机使用的Metaspace的初始容量和最大容量。

不过并不是所有永久代的数据都放到Metaspace,根据Oracle上一篇文章的介绍以及我在本机做的实验,对于方法区里的这些数据:类信息、常量、静态变量、JIT编译器编译后的代码只有类信息(Classes Metadata)是放到Metaspace了,其他的数据,都被放到到Java堆上,而我们知道,常量在jdk1.7是放在Native Memory的,因此,如果你是从jdk1.7升级到jdk1.8,有可能会发现Java堆的内存压力变大了。

要验证Metaspace主要存储的是类信息,只需要把上面两段代码(RuntimeConstantPoolOOM和JavaMethodAreaOOM)在jdk1.8的环境下执行一遍即可,记得将启动参数设置修改为:-XX:MetaspaceSize=10M -XX:MaxMetaspaceSize=10M

RuntimeConstantPoolOOM的运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at java.util.Arrays.copyOf(Arrays.java:3332)
    at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
    at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
    at java.lang.StringBuilder.append(StringBuilder.java:136)
    at com.hzy.understandjvm.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:16)

Java堆溢出,说明常量确实放到堆中。


JavaMethodAreaOOM的运行结果:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
    at java.lang.Class.forName0(Native Method)
    at java.lang.Class.forName(Class.java:348)
    at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:467)
    at net.sf.cglib.core.AbstractClassGenerator.generate(AbstractClassGenerator.java:336)
    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.hzy.understandjvm.JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:24)

Metaspace溢出,说明类信息确实放在Metaspace.


本机直接内存溢出

本机直接内存的容量可以通过-XX:MaxDirectMemorySize指定,如果不指定,默认与Java堆最大值一样。

本机直接内存溢出的一个明显特征是,dump文件很小,因为主要对象都在direct memory了,并且异常信息也不会说明是在哪个区域发生内存溢出,就像这样:java.lang.OutOfMemoryError

可以通过下面这段代码制造本机直接内存溢出:

/**
 * 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);
		}
	}
}


总结

这一讲,在自己的机器上做了大量的破坏性试验,演示了各个内存区域的内存溢出异常时如何发生的,同时也给了大家定位和修复问题的一些建议。其中,方法区存储的对象类型在jdk 1.6、1.7、1.8都有变化,希望大家多看几遍,牢记于心。

下一讲,将讲解自动内存管理模块的一个非常重要的知识点——GC。


课后思考

除了本文所讲的异常,你还见过什么其他的内存溢出异常?欢迎在评论区留言,O(∩_∩)O谢谢。


上一讲课后思考题的答案

上一讲的问题是——“如果要你在自己的机器上模拟Java堆的内存溢出,你会怎么做?”。

答案已经在本讲里了,既然Java堆是存放的对象实例,那么只要做两个动作,

1: 把-Xms -Xmx参数调小

2: 在程序中不停地new对象


参考资料

  • 《深入理解Java虚拟机》 周志明
  •   DZone java-8-permgen-metaspace
  •   About G1 Garbage Collector, Permanent Generation and Metaspace



你可能感兴趣的:(JVM,Effective,Java)