实战:OutOfMemoryError异常

实战:OutOfMemoryError异常

参照周志明老师的《深入理解Java虚拟机》第3版进行实践。

实战目的有两个:第一,通过代码验证《Java虚拟机规范》中描述的各个运行时区域存储的内容;第二,希望读者在工作中遇到实际的内存溢出异常时,能根据异常提示信息迅速得知是哪个区域的内存溢出,知道怎样的代码可能会导致这些区域内存溢出,以及出现这些异常后该如何处理。

本节代码清单开头都注释了执行时需要设置的虚拟机启动参数(注释中“VM Args”后面跟着的参数),这些参数对实验的结果有直接影响,请读者调试代码的时候不要忽略掉。如果读者使用控制台命令来执行程序,那直接跟在Java命令之后书写就可以。如果读者使用Eclipse,则可以参考下图在Debug/Run页签中的设置,其他IDE工具均有类似的设置。

image-20210808205059362

本节所列的代码均在基于Open JDK7中的HotSpot虚拟机上进行。不过内存溢出异常与虚拟机本身的实现细节密切相关,并非全是Java语言中约定的公共行为。因此,不同发行商、不同版本的Java虚拟机,其需要的参数和程序运行的结果都可能会有所差别。

OpenJDK下载地址

Windows配置OpenJDK

我安装的Open JDK如下图所示:

image-20210817190732796

在Eclipse中点击Window→Preference,选择Java→Installed JREs,点击右侧Add,如下图所示:

image-20210817191133386

选择Standard VM,点击Next;

image-20210817191210246

点击Directory添加相应的JRE路径即可;

image-20210817191413154

切换项目使用的JDK时,右击项目,点击Build Path→Configure Build Path,选择Libraries,选中JRE System Library,然后点击右侧的Edit,如下图所示:

image-20210817191731018

选择Altenate JRE,选择要使用的JDK即可,如下图所示:

image-20210817191830202

一、Java堆溢出

1.1 Java堆内存溢出

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

代码清单1-1中限制Java堆的大小为20MB,不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免对自动扩展),通过参数-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。

代码清单1-1 Java堆内存溢出异常测试

import java.util.ArrayList;
import java.util.List;

/**
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 * @author stephana
 */

public class HeapOOM {
	
	static class OOMObject {
	}

	public static void main(String[] args) {
		List<OOMObject> list = new ArrayList<OOMObject>();
		
		while (true) {
			list.add(new OOMObject());
		}

	}

}

运行结果:

image-20210809171043263

Java堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java堆内存溢出时,异常堆栈信息 ”java.lang.OutOfMenoryError“ 会跟随进一步提示 ”Java heap space" 。

1.2 Eclipse Memory Analyzer

要解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

点击Eclipse导航栏中的Help→Install New Software,

image-20210810191306440

点击Work with右边的Add…

image-20210810191353041

Name填MAT,Location填Eclipse Memory Analyzer最新版中的Update Site,

image-20210810191659043

image-20210810191759410

然后点击Add,再点击右侧的Select All,然后next安装就可以了。

下图显示了使用Eclipse Memory Analyzer打开的堆转储快照文件。

image-20210809174009562

如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾回收器无法回收它们,根据泄漏对象的类型信息以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。

如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

以上是处理Java堆内存的简约思路。

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

由于HotSpot虚拟机并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由 -Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:

​ 1)如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。

​ 2)如果虚拟机的栈内存支持动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常。

为了验证这点,我们可以做两个实验,先把实验范围限制在单线程中操作,尝试下面两种行为是否能让HotSpot虚拟机产生OutOfMemoryError异常:

  • 使用-Xss参数减少栈内存容量。

    结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

  • 定义了大量的本地变量,增大此方法帧中本地变量表的长度。

    结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

2.1 使用-Xss参数减少栈内存容量

首先,对第一种情况进行测试,具体如代码清单2-1所示。

代码清单2-1 虚拟机栈和本地方法栈测试

/**
 * VM Args: -Xss128k
 * @author stephana
 *
 */

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;
		}

	}

}

运行结果:

image-20210810172850832

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制。下图是Ubuntu18.03LTS下低于最小限制的提示。

image-20210810173210242

2.2 定义了大量的本地变量

我们继续验证第二种情况,为了多占用局部变量表空间,不得不定义一长串变量,具体如代码清单2-2所示。

代码清单2-2 虚拟机栈和本地方法栈测试(作为第2点测试程序)

/**
 * @author stephana
 *
 */

public class JavaVMStackSOF {

	private static int stackLength = 0;

	public static void stackLeak() {
		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++;

		stackLeak();

		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 {
			stackLeak();
		} catch (Error e) {
			System.out.println("stack length: " + stackLength);
			throw e;
		}

	}

}

运行结果:

image-20210810174653928

实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常。可是如果在运行动态扩展栈容量大小的虚拟机上,相同代码则会导致不同的情况。譬如远古时代的Classic虚拟机,这款虚拟机可以支持动态扩展栈内存的容量,在Windows上的JDK1.0.2运行代码清单2-2的话(如果这时候要调整栈容量就应该改用-oss参数了),得到的结果会是OutOfMemoryError(就不测试了)

可见相同的代码在Classic虚拟机中成功产生了OutOfMemoryError而不是StackOverflowError异常。如果测试时不限于单线程,通过不断建立线程的方式,在HotSpot上也是可以产生内存溢出异常的,具体如代码清单2-3所示。但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

原因其实不难理解,操作系统分配给每个进程的内存时有限制的,譬如32位Windows的单个进程最大内存限制为2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两部分的内存的最大值,那剩余的内存即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果把直接内存和虚拟机进程本身耗费的内存也去掉的话,剩下的内存就由虚拟机栈和本地方法栈来分配了。因此为每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽,代码清单2-3就演示了这种情况。

代码清单2-3

/**
 * VM Args: -Xss2M(这时候不妨设大些,请在32位系统下允许)
 * @author stephana
 *
 */

public class JavaVMStackOOM {
	
	private void dontStop() {
		while (true) {
		}
	}
	
	public void stackLeakByThread() {
		while (true) {
			Thread thread = new Thread(new Runnable() {
				@Override
				public void run() {
					dontStop();
				}
			});
		}
	}

	public static void main(String[] args) throws Throwable{
		JavaVMStackOOM oom = new JavaVMStackOOM();
		oom.stackLeakByThread();
	}

}

在32位操作系统下的运行结果:

image-20210814192810582

出现StackOverflowError异常时,会有明确错误堆栈可供分析,相对而言比较容易定位到问题所在。如果使用HotSpot虚拟机默认参数,栈深度在大多数情况下(因为每个方法压入栈的帧大小并不是一样的,所以只能说大多数情况下)到达1000~1200是完全没有问题,对于正常的方法调用(包括不能做尾递归优化的递归调用),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数量或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过“减少内存”的手段来解决内存溢出的方式,如果没有这方面处理经验,一般比较难以想到,这一点读者需要在开发32位系统的多线程应用时注意。也是由于这种问题较为隐蔽,从JDK7起,以上提示信息中 “unable to create native thread” 后面,虚拟机会特别注明原因可能是 “possibly out of memory or process/resource limits reached" 。

三、方法区和运行区常量池溢出

3.1 运行时常量池导致的内存溢出

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。前面曾经提到HotSpot从JDK7开始逐步“去永久代”的计划,并在JDK8中完全使用元空间来代替永久代的背景故事,在此我们就以测试代码来观察一下,使用“永久代”还是“元空间”来实现方法区,对程序有什么实际的影响。

String::intern()是一个本地方法,他的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。在JDK6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过 -XX:PermSize和 -XX:MAxPermSize限制永久代的大小,即可间接限制其中常量池的容量,具体实现如代码清单3-1所示,首先以JDK6来运行代码。

代码清单3-1 运行时常量池导致的内存溢出

import java.util.HashSet;
import java.util.Set;

/**
 * VM Args: -XX:PermSize=6M -XX:MaxPermSize=6M
 * @author stephana
 *
 */

public class RuntimeConstantPoolOOM {

	public static void main(String[] args) {
		// 使用Set保持常量池引用,避免Full GC回收常量池行为
		Set<String> set = new HashSet<String>();
		//在short范围内足以让6MB的PermSize产生OOM了
		short i = 0;
		while (true) {
			set.add(String.valueOf(i++).intern());
		}
	}

}

这里用Oracle JDK6能成功报错,用Open JDK6会出现和JDK7一样的无限循环,不会报错。

运行结果:

image-20210817160323251

从运行结果中可以看到,运行时常量池溢出时,在OutOfMemoryError异常后面跟随的提示信息是”PermGen space",说明运行时常量池的确是属于方法区(即JDK6的HotSpot虚拟机中的永久代)的一部分。

而使用JDK7或更高版本的JDK来运行这段程序并不会得到相同的结果,无论是在JDK7中继续使用 -XX:MaxPermSize参数或者在JDK8及以上版本使用 -XX:MaxMetaspaceSize参数吧方法区容量同样限制在6MB,也都不会重现JDK6中的溢出异常,循环将一直进行下去,永不停歇。出现这种变化,是因为自JDK7起,原本存放在永久代的字符串常量池被移至Java堆之中,所有在JDK7及以上版本,限制方法区的容量对该测试用例来说是毫无意义的。这时候使用 -Xmx参数限制最大堆到6MB就能够看到以下两种运行结果之一,具体取决于哪里的对象分配时产生了溢出:

//OOM异常一
Exception in thread "main" java.lang.OutOfMemoryErrpr: Java heap space
	at java.base/java.lang.Integer.toString(Integer.java:440)
	at java.base/java.lang.String.valueOf(String.java:3058)
	at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)
	
//OOM异常一
Exception in thread "main" java.lang.OutOfMemoryErrpr: Java heap space
	at java.base/java.util.HashMap.resize(HashMap.java:699)
	at java.base/java.util.HashMap.putVal(HashMap.java:658)
	at java.base/java.util.HashMap.put(HashMap.java:607)
	at java.base/java.util.HashMap.add(HashSet.java:220)
	at RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java from InputFileObject:14)

我测试的结果如下图所示。

image-20210816193142058

3.2 String.intern()返回引用的测试

关于这个字符串常量池的实现在哪里出现问题,还可以引申出一些更有意思的影响,具体见代码清单3-2所示。

代码清单3-2 String.intern()返回引用的测试

/**
 * @author stephana
 *
 */

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

}

这段代码在JDK6中运行,会得到两个false,如下图所示:

image-20210817161341212

而在JDK7中运行,会得到一个true和一个false,如下图所示:

image-20210817164003824

产生差异的原因是,在JDK6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串对象实例在Java堆上,所以必然不可能是同一个引用,结果将返回false。

而JDK7(以及部分其他虚拟机,例如JRockit)的intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。而对str2比较返回false,这是因为"java"这个字符串在执行StringBui.toString()之前就已经出现过了(加载sun.misc.Version这个类的时候进入常量池,参考RednaxelaFX的知乎回答),字符串常量池中已经有它的引用,不符合intern()方法要求“首次遇到”的原则,“计算机软件”这个字符串则是首次出现的,因此结果返回true。

3.3 借助CGLib使得方法区出现内存溢出异常

再来看看方法区其他部分的内容,方法区的主要职责是用于存放类型的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时参数大量的类去填满方法区,直到溢出未知。在代码清单3-3里借助GCLib(需要引入asm和cglib 的jar包)直接操作字节码运行时生成了大量的动态类。

代码清单3-3 借助CGLib使得方法区出现内存溢出异常

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
 * @author stephana
 *
 */

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 object, Method method, Object[] args, MethodProxy proxy) throws Throwable {
					return proxy.invokeSuper(object, args);
				}
			});
			enhancer.create();
		}
	}
	
	static class OOMObject{
		
	}

}

JDK7中的运行结果:

image-20210817173503283

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达到的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注这些类的回收状况。

四、本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过 -XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由 -Xmx指定)一致,代码清单4-1越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配,因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码手动抛出溢出异常,真正申请分配内存的方法使Unsafe::allocateMemory()。

代码清单4-1 使用unsafe分配本机内存

import java.lang.reflect.Field;
import sun.misc.Unsafe;


/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 * @author stephana
 *
 */

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

运行结果:

image-20210817184638577

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值