在《Java虚拟机规范》的规定里,除了程序计数器外,虚拟机内存的其他几个运行时区域都有发生OutOfMemoryError(下文称OOM)异常的可能。(本文主要是基于 jdk1.8 展开探讨)
Java 堆溢出
Java堆用于储存对象实例,我们只要不断地创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制后就会产生内存溢出异常。
模拟代码
下面是简单的模拟堆内存溢出的代码:
- /**
- * VM Args:-Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError
- * @author zhengsh
- * @date 2021-8-13
- */
- public class HeapOOM {
- public static void main(String[] args) {
- List<byte[]> list = new ArrayList<>();
- while (true) {
- list.add(new byte[2048]);
- }
- }
- }
返回结果信息如下所示:
- Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
- at cn.zhengsh.jvm.oom.HeapOOM.main(HeapOOM.java:16)
问题分析
我们需要定位是内存泄漏(Memory Leak)还是,内存溢出(Memory Overflow)
- 内存泄漏
- 内存溢出
内存泄漏
我们可以通过 jdk 自带的 jvisualvm 工具来加载堆快照文件进行分析。如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,找到泄漏对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们,根据泄漏对象的类型信息 以及它到GC Roots引用链的信息,一般可以比较准确地定位到这些对象创建的位置,进而找出产生内存泄漏的代码的具体位置。
内存溢出
如果不是内存泄漏,换句话说就是内存中的对象确实都是必须存活的,那就应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与机器的内存对比,看看是否还有向上调整的空间。再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运 行期的内存消耗。
虚拟机栈和本地方法栈溢出
HotSpot虚拟机中并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,-Xoss参数(设置 本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能由-Xss参数来设定。关于虚拟机栈和本地方法栈,在《Java虚拟机规范》中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出*Error异常。
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出 OutOfMemoryError异常。
《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机 的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法 容纳新的栈帧而导致*Error异常。
虚拟机栈内存溢出
*Error
示例代码:
- /**
- * VM Args:-Xss128k
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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;
- }
- }
- }
返回异常信息
- Exception in thread "main" java.lang.*Error
- stack length:992
- at cn.zhengsh.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
- at cn.zhengsh.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:14)
- //.... 省略更多
OutOfMemoryError
- package cn.zhengsh.jvm.oom;
- /**
- * @author zhengsh
- * @date 2021-08-13
- */
- public class JavaVMStackSOF2 {
- 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:6986
- Exception in thread "main" java.lang.*Error
- at cn.zhengsh.jvm.oom.JavaVMStackSOF2.test(JavaVMStackSOF2.java:22)
- at cn.zhengsh.jvm.oom.JavaVMStackSOF2.test(JavaVMStackSOF2.java:22)
总结
无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候, HotSpot虚拟机抛出的都是*Error异常。可是如果在允许动态扩展栈容量大小的虚拟机上,相同代码则会导致不一样的情况。
创建线程导致内存溢出
注意:下面的这个实验可能导致操作系统卡死,建议大家在虚拟机中执行
- /**
- * VM Args:-Xss512k
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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();
- }
- }
方法区和运行时常量池溢出
由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。HotSpot从JDK 7 开始逐步“去永久代”的计划,并在JDK 8中完全使用元空间来代替永久代。
方法区内存溢出
方法区的主要职责是用于存放类型的相关信息,如类 名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产 生大量的类去填满方法区,直到溢出为止。虽然直接使用Java SE API也可以动态产生类(如反射时的 GeneratedConstructorAccessor和动态代理等),但在本次实验中借助了CGLib直接操作字节码运行时生成了大量的动态类。
- /**
- * VM Args:-XX:MetaspaceSize=21m -XX:MaxMetaspaceSize=21m
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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 {
- }
- }
输出代码
- Caused by: java.lang.OutOfMemoryError: Metaspace
- Caused by: java.lang.OutOfMemoryError: Metaspace
常量池案例
String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的 字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用。在JDK 6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。
- /**
- * @author zhengsh
- * @date 2021-08-13
- */
- 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);
- }
- }
这段代码在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。
本机直接内存溢出
直接内存(Direct Memory)的容量大小可通过 -XX:MaxDirectMemorySize参数来指定,默认与Java堆最大值(由-Xmx指定)一致,代码越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准类库里面的类才能使用Unsafe的功能,在JDK 10时才将Unsafe 的部分功能通过VarHandle开放给外部使用),因为虽然 DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是 Unsafe::allocateMemory()。
- /**
- * VM Args:-Xmx20M -XX:MaxDirectMemorySize=10M
- *
- * @author zhengsh
- * @date 2021-08-13
- */
- 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
- Exception in thread "main" java.lang.OutOfMemoryError
- at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:616)
- at jdk.unsupported/sun.misc.Unsafe.allocateMemory(Unsafe.java:462)
- at cn.zhengsh.jvm.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:21)
参考资料
《深入理解 JVM 虚拟机-第三版》 周志明
https://docs.oracle.com/javase/specs/jls/se8/html/index.html
原文链接:https://mp.weixin.qq.com/s/ZZjku8060vazSLI2YP2x5A