第二章 3-OutOfMemoryError异常实战

概述

在Java虚拟机规范的描述中,除了程序计数器外,虚拟机内存的其它几个运行区域都有发生OOM异常的可能,下面我们模拟几个异常发生的场景。

Java堆溢出

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

使用-Xmx5m -Xms5m -XX:+HeapDumpOnOutOfMemoryError参数来快速达到溢出效果,并在发生内存溢出时,让虚拟机Dump出当前的内存堆转储快照以便进行事后的分析。

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

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

运行结果

java.lang.OutOfMemoryError: GC overhead limit exceeded
Dumping heap to java_pid16004.hprof ...
Heap dump file created [8930568 bytes in 0.088 secs]
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
    at OOMDemo.main(OOMDemo.java:14)

会提示GC overhead limit exceededGC超过了开销限制。为什么没有提示书中说的Java heap space信息呢?其实这是JVM的一种推断,如果垃圾回收耗费了98%的时间,但是回收的内存还不到2%,那么JVM就会认为即将发生OOM,让程序提前结束。当然我们可以使用-XX:-UseGCOverheadLimit,关掉这个特性。

关掉特性之后就会出现书中的Java heap spave信息了,如下:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid16125.hprof ...
Heap dump file created [9033040 bytes in 0.124 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at OOMDemo.main(OOMDemo.java:14)

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

在HotSpot中,并不区分虚拟机栈和本地方法栈,因此对于HotSpot来说,参数-Xoss是无效的,栈的容量只由-Xss参数设定。

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

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

其实这两种情况有相互重叠的地方:当栈空间无法继续分配时,到底是达到了最大栈深度呢还是无法申请到足够的内存?

模拟StackOverflowError异常

运行Demo时使用-Xss256k参数减少栈内存容量,结果抛出StackOverflowError异常,异常出现时并输出了栈的深度。

public class StackOverFlowDemo {

    private int num = 0;

    private void f() {
        num++;
        f();
    }

    public static void main(String[] args) {
        StackOverFlowDemo demo = new StackOverFlowDemo();

        try {
            demo.f();
        } catch (Throwable e) {
            System.out.println(demo.num);
            throw e;
        }
    }

}

在单个线程中,无论是栈帧太大还是虚拟机栈容量太小,当内存无法分配的时候,虚拟机抛出的都是StackOverflowError异常。

通过不断建立线程的方式倒是可以产生内存溢出异常,但是这种方式产生的内存溢出异常与栈空间是否足够大并不存在任何关系,或者准确的说,在这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。

其实原因不难理解,操作系统分配给每个进程的内存是有限制的,譬如32位的Windows限制为2GB,虚拟机提供参数来控制Java堆和方法区的这两部分的最大值。那么虚拟机栈和本地方法栈占用的内存就是2GB(操作系统限制)减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量),程序计数器消耗内存很小,虚拟机进程本身消耗的内存不计算在内。每个线程消耗的栈容量越大,可以建立的线程数量自然就越少。很好理解,比如电梯限重1000斤(栈容量),一个人如果200斤(线程消耗的栈内存),那么电梯只能上5个人(线程),如果一个人100斤,那么电梯就可以上10个人。

出现StackOverflowError异常时有错误堆栈可以阅读,相对来说,比较容易找到问题的所在。而且,如果使用虚拟机默认参数,栈深度大多情况下达到1000~2000是完全没有问题的,对于正常的方法调用时完全可以满足的。但是如果建立过多的线程导致的内存溢出,在不能减少线程数和更换64位虚拟机的情况下,就只能通过减少最大堆和减少方法区容量来换取更多的线程。

通过不断建立线程导致出现OOM的异常Demo(我没测试,因为可能会死机)

public class ThreadOOM {

    public void stackLeakByThread() {
        while (true) {
            new Thread(() -> {
                while (true) {}
            }).start();
        }
    }

    public static void main(String[] args) {
        new ThreadOOM().stackLeakByThread();
    }

}

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

因为常量池是方法区的一部分,因此这两个区域的溢出测试就放在一起了。

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,对于这些区域的测试,基本思路就是运行时产生大量的类去填满方法区,直到溢出。

很多主流的框架,如Spring、Hibernate,在对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保存动态生成的Class可以载入内存。

方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景常见的还有:大量JSP或动态产生JSP文件的应用(JSON第一次运行时需要编译为Java类)、基于OSGI的引用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

本机直接内存溢出

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

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

摘自: 周志明的《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版

猜你喜欢

转载自www.cnblogs.com/wuqinglong/p/11128448.html
今日推荐