OutOfMemoryError异常实战

java堆溢出

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

-XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常的时候Dump出当前的内存堆转储快照以便进行事后分析。

public class HeapOOM {
    static class OOMObject{}

    public static void main(String[] args) {
        List<OOMObject> list = new ArrayList<>();
        while(true){
            list.add(new OOMObject());
        }
    }
}
output:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid12216.hprof ...
Heap dump file created [28307898 bytes in 0.160 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:265)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
	at java.util.ArrayList.add(ArrayList.java:462)
	at com.example.oom.HeapOOM.main(HeapOOM.java:17)

Process finished with exit code 1

java堆内存OOM异常是实际应用中最常见的内存溢出异常情况。出现java堆内存溢出时,异常堆栈信息OOM后会进一步提示:java heap space。

也可以使用参数-XX:HeapDumpPath=${目录}自定义dump文件地址

使用idea插件JProfiler分析dump文件:
image

image

image

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

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

关于虚拟机栈和本地方法栈,《java虚拟机规范》中描述了两种异常:

  • 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  • 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

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

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

  • 使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
  • 定义大量的本地变量,增大此方法帧中本地变量表的长度。结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。
public class JavaVMStackSOF {
    private int stackLength = 1;

    public void stackLength() {
        stackLength++;
        stackLength();
    }

    public static void main(String[] args) {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLength();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }

    }
}
output:
Exception in thread "main" java.lang.StackOverflowError
stack length:998
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:12)
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:13)
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:13)
public class JavaVMStackSOF {
    private static int stackLength = 1;

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

        stackLength++;

        stackLength();
        ;

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

    public static void main(String[] args) {
        try {
            stackLength();
        } catch (Throwable e) {
            System.out.println("stack length:" + stackLength);
            throw e;
        }

    }
}
output:
Exception in thread "main" java.lang.StackOverflowError
stack length:136
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:20)
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:22)
at com.example.oom.JavaVMStackSOF.stackLength(JavaVMStackSOF.java:22)

实验结果表明:无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常

以上实验结果是基于栈空间无法自动扩展,如果栈空间可以自动扩展,并且在扩展中申请不到足够的内存,则会抛出OutOfMemoryError而不是StackOverflowError。

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 oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

以上程序创建线程产生OOM,可能会导致操作系统假死,执行需谨慎。

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

由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行。

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

在jdk6或更早之前的HotSpot虚拟机中,常量池都是分配在永久代中,我们可以通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的容量。

/**
 * @ClassName RuntimeConstantPoolOOM
 * @Author jia_xx
 * @Date 2022/6/19 21:18
 * Args:-XX:PermSize=8M -XX:MaxPermSize=8M
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用Set保持着常量池的引用,避免Full GC回收常量池的行为
        Set<String> set = new HashSet<>();
        int i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}
jdk6会报错:
output:
java.lang.OutOfMemoryError:PermGen space

java8没有报错

使用jdk7或更高版本的jdk,原本放在永久代的字符串常量池被移至java堆之中,所以在jdk7及以上版本,限制方法区的容量对该测试来说是毫无意义的。这个时候使用-Xmx参数限制最大堆到6MB就可以看到以下两种运算结果之一,具体取决于哪里的对象分配时产生了溢出:

/**
 * @ClassName RuntimeConstantPoolOOM
 * @Author jia_xx
 * @Date 2022/6/19 21:18
 * Args:-Xms6m -Xmx6m
 */
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
        //使用Set保持着常量池的引用,避免Full GC回收常量池的行为
        Set<String> set = new HashSet<>();
        int i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.Integer.toString(Integer.java:403)
	at java.lang.String.valueOf(String.java:3099)
	at com.example.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:18)
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);
    }
}
output(jdk8):
true
false

在jdk6中运行,会得到两个false。在jdk6中,intern()方法会把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而由StringBuilder创建的字符串实例在java堆上,所以必然不可能是同一个引用,结果将返回false。

而在jdk7及以上,intern()方法实现就不需要再拷贝字符串的实例到永久代了,既然字符串常量池已经移到java堆中,那只需要在常量池里记录一下首次出现的实例引用即可,因此intern()返回的引用和由StringBuilder创建的那个字符串实例就是同一个。

str2返回false,是因为“java”这个字符串已经出现过了(在加载sun.misc.Version这个类的时候进入常量池),不符合“首次遇到”原则。

我们再来看看方法区的其他部分内容,方法区的主要职责是用于存放类型的相关信息

  • 类名
  • 访问修饰符
  • 常量池
  • 字段描述
  • 方法描述

对于该区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出为止。我们可以借助CGLib直接操作字节码运行时生成大量的动态类

当前很多框架如Spring、Hibernate对类进行增强时,都会使用到CGLib这类字节码技术,当增强的类越多,就需要越大的方法区以保证动态生成的新类型可以载入内存。另外,很多运行于java虚拟机上的动态语言(例如Groovy等)通常都会持续创建新类型来支撑语言的动态性,随着这类动态语言的流行,方法区溢出现象也越容易遇到。

/**
 * @ClassName JavaMethodAreaOOM
 * @Author jia_xx
 * @Date 2022/6/19 21:54
 * 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 o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
                    return methodProxy.invokeSuper(o,objects);
                }
            });
            enhancer.create();
        }
    }

    static class OOMObject{
    }
}
jdk output:
oom:PermGen space

除了使用CGLib字节码增强和动态语言外,常见的还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为java类)、基于OSGI的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

在jdk8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面的案例动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。为了让使用者有预防实际应用里出现的类似于以上案例产生的破坏性操作,HotSpot还提供了一些参数作为元空间的防御措施,主要包括:

  • -XX:MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
  • -XX:MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过-XX:MaxMetaspaceSize(如果设置了的话)的情况下,适当提高该值。
  • -XX:MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:MaxMetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。

本机直接内存溢出

直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与java堆最大值(由-Xmx指定)一致。

代码清单越过了DirectByteBuffer类直接通过反射获取Unsafe实例进行内存分配(Unsafe类的getUnsafe()方法指定只有引导类加载器才会返回实例,体现了设计者希望只有虚拟机标准库里面的类才能使用Unsafe的功能),因为虽然使用DirectByteBuffer分配内存也会抛出内存溢出异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配就会在代码里手动抛出溢出异常,真正申请分配内存的方法是Unsafe::allocateMemory()。

/**
 * @ClassName DirectMemoryOOM
 * @Author jia_xx
 * @Date 2022/6/20 22:46
 * VM args:-Xmx20M -XX:MaxDirectMemorySize=10M
 */
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}
output:
Exception in thread "main" java.lang.OutOfMemoryError
	at sun.misc.Unsafe.allocateMemory(Native Method)
	at com.example.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:21)

由直接内存导致的内存溢出,一个明显的特征是在Heap Dump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值