贾哇技术指南

V1

2022/04/08阅读:68主题:默认主题

Java各种内存溢出异常实践

Java各种内存溢出异常实践

1.前言

在生产环境运行中,我们可能见到过各种内存溢出异常,本篇文章我们来实践下可能出现的内存异常。一是可以验证运行时数据区域中存储的内容;二是我们在遇到内存溢出异常时,根据异常信息可以快速得知是哪个区域的内存溢出,可以帮助快速定位问题,以及出现这些异常后知道应该如何处理。

2.回顾运行时数据区

我的上一篇文章:《详解JVM运行时数据区》中讲过JVM运行时数据区主要包含以下内容:

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • 方法区

除了程序计数器不会内存溢出外,其他数据区都有可能产生内存溢出异常。 下面我们就模拟下这些区域的溢出异常。

声明:我的JDK版本是:java version "1.8.0_301",操作系统是Mac OS

2.1 堆

/**
 * 启动参数: -Xms10m -Xmx10m -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
 */

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

异常信息:

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)

说明:这里将堆的初始值和最大值都设置为10m,list实例对象存放在堆中,不断的向list中添加实例,直到堆内存 放不下,抛出OOM异常。由于启动参数里加了-XX:+HeapDumpOnOutOfMemoryError,在出现OOM时会自动 dump内存。 这里生成了文件: ,我们可以通过线下工具:JprofilerMAT等来分析 dump文件。也可以通过在线网站比如:https://gceasy.io/ 来上传dump文件进行分析。由于生成环境的dump文件 很大,所以不推荐使用在线分析工具。下面演示用Jprofiler怎么来定位问题。

首先打开Jprofiler,点击工具栏session,选择Open Snapshot

这里会让我们选择文件,我们选择刚才OOM时生成的文件即可。 选择文件后,依次点击Heap WalkerCurrent Object Set,然后选择Classes按照对象大小倒序排序:

可以看到最大的对象是HeapOOMDemo,选中这一行,右键,选择Use Selected Objects

References中选择Incoming references

点击OK,在下面的页面中点击show more,就能看到具体报错的的行数:

这一行就是代码:

list.add(new HeapOOMDemo());

所在的行,这样我们就定位到了具体出问题的代码。

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

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

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

2.2.1 栈的StackOverflowError异常

关于栈的StackOverflowError异常,我们只需要写一个无限递归,就肯定能让栈深度大于虚拟机所允许的最大深度,代码如下:

public class StackOverFlowDemo {
    public static void main(String[] args) {
        main(args);
    }
}

异常信息如下:

Exception in thread "main" java.lang.StackOverflowError
	at com.jts.jvm.gc.oom.StackOverFlowDemo.main(StackOverFlowDemo.java:10)

main()方法中还能调main()方法。

ps: main方法除了做了程序的入口外,和其他方法并没有什么区别

2.2.2 栈的OOM异常

关于栈的OOM异常这里对《深入理解Java虚拟机》中的代码做了点优化,不用等到机器假死就能看到抛出的异常,优化后的代码如下:

/**
 * 启动参数: -Xss2m
 */

public class StackOOMDemo {
    public static void main(String[] args) {
        while (true) {
            new Thread(() -> {
                while (true) {
                    try {
                        Thread.sleep(10000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }
}

异常信息:

Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)
	at com.jts.jvm.gc.oom.StackOOMDemo.main(StackOOMDemo.java:18)

可以看到抛出了unable to create new native thread异常信息。这里设置的每个线程栈的大小为2m, 我之前的文章《Java线程是如何启动的》 有讲到过在我们调用了Thread.start()方法之后,会真正的创建线程,代码在jvm.cpp里面,下面我们来分析下主要代码:

JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
      // 是否应该抛出异常标志      
      bool throw_illegal_thread_state = false;
      {
          // 栈大小,我们设置的为2m
          jlong size = java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
          size_t sz = size > 0 ? (size_t) size : 0;
          // 创建线程
          native_thread = new JavaThread(&thread_entry, sz);
      }
      // 如果创建线程为NULL,说明没有足够的内存能被分配
      if (native_thread->osthread() == NULL) {
          delete native_thread;
          // 抛出OOM异常
          THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
                  "unable to create new native thread");
      }
      Thread::start(native_thread);
      JVM_END

可以看到,创建线程的时候传递了栈大小的参数,如果栈剩余的内存不够创建新的线程,就会抛出java_lang_OutOfMemoryError(), 错误信息就是"unable to create new native thread"

3. 方法区溢出

首先我们需要了解方法区都有什么:

  • class定义信息、class常量池
  • 运行时常量池 (类加载后将class常量池中的信息加载到运行时常量池中)
  • 字符串常量池(JDK7及其之后字符串常量池虽然逻辑上属于方法区,但是在物理上已经被划分到堆中了)
  • 。。。

首先利用Cglib来动态创建类,类信息就会一直往方法区中添加,直到达到方法区大小,就会抛出异常了。代码如下:

/**
 * 启动参数: -XX:MaxMetaspaceSize=20m
 */

public class JavaMethodAreaOOM {
    public static void main(String[] args) {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(JavaMethodAreaOOM.class);
            enhancer.setUseCache(false);
            enhancer.setCallback((MethodInterceptor) (obj, method, arg, proxy) -> proxy.invokeSuper(obj, arg));
            enhancer.create();
        }
    }
}

报错信息:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

上面我们提到:字符串常量池在JDK6及其之前是在方法区中的,在JDK7及其之后被移到了堆中。这个比较特殊,可以理解为,虽然它在逻辑上是被划分到方法区,但是它在物理上其实是在中。

所以下面的代码并不会直接抛出方法区的异常:

/**
 * 启动参数: -XX:MaxMetaspaceSize=20m -Xms20m -Xmx20m -XX:-UseGCOverheadLimit 
 */

public class RuntimeConstantPoolOOM {
  public static void main(String[] args) {
    // 保持字符串常量池引用,不让其进行垃圾回收
    List<String> list = new ArrayList<>();
    int i = 0;
    while (true) {
      list.add(String.valueOf(i++).intern());
    }
  }
}

异常信息:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
	at java.lang.Integer.toString(Integer.java:401)
	at java.lang.String.valueOf(String.java:3099)
	at com.jts.jvm.gc.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)

java.lang.OutOfMemoryError: GC overhead limit exceeded这个异常的含义是:超过98%的时间用来做GC 并且回收了不到2%的堆内存则会抛出此异常。 我们可以添加启动参数:-XX:-UseGCOverheadLimit禁用此检查,添加后再运行结果如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.lang.Integer.toString(Integer.java:403)
	at java.lang.String.valueOf(String.java:3099)
	at com.jts.jvm.gc.oom.RuntimeConstantPoolOOM.main(RuntimeConstantPoolOOM.java:12)

可以看到这个时候就是报的死堆内存溢出异常,就可以验证字符串常量池并不是在存放在方法区了,这是个反例。

4.直接内存溢出

直接内存的容量大小可以通过 -XX:MaxDirectMemorySize=20m 来指定,如果不指定,则默认与Java堆最大值一致。 《深入理解Java虚拟机》中的例子我并没有试验成功,代码如下:


// 启动参数: -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);
    }
  }
}

启动参数也一致,但是并没有重现异常。

查询资料的结果:

  1. 如果通过反射的方式拿到Unsafe的实例,然后用Unsafe的allocateMemory方法分配堆外内存. 确实不受-XX:MaxDirectMemorySize这个JVM参数的限制 . 所以限制的内存大小为操作系统的内存.
  2. 如果使用Java自带的 ByteBuffer.allocateDirect(size) 或者直接 new DirectByteBuffer(capacity) , 这样受-XX:MaxDirectMemorySize 这个JVM参数的限制. 其实底层都是用的Unsafe#allocateMemory,区别是对大小做了限制. 如果超出限制直接OOM. 原文链接:https://blog.csdn.net/zhanglong_4444/article/details/116701143

所以我用下面代码去测试,就成功出现了OOM异常:

public class DirectMemoryOOM {
    // 分配直接内存大小为 20M
    private static final int size = 1024 * 1024*20;

    // 启动参数: -Xmx20M -XX:MaxDirectMemorySize=10M -XX:+HeapDumpOnOutOfMemoryError
    public static void main(String[] args) throws Exception {
        ByteBuffer.allocateDirect(size);
    }
}

异常信息:

Exception in thread "main" java.lang.OutOfMemoryError: Direct buffer memory
	at java.nio.Bits.reserveMemory(Bits.java:695)
	at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123)
	at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:311)
	at com.jts.jvm.gc.oom.DirectMemoryOOM.main(DirectMemoryOOM.java:16)

虽然在启动时加了-XX:+HeapDumpOnOutOfMemoryError参数,但是在OOM的时候,并没有dump文件。这点也是和《深入理解Java虚拟机》中不一致的地方。猜测可能和操作系统有关,不过我用windows系统也没有得到和书上一样的结果。

总结

通过上面的案例,我们能更清楚什么样的代码在哪块区域会产生内存溢出异常了,在出现异常时能有大致的分析方向,然后利用工具或者查看错误日志就能精准定位问题了。

参考:

  1. 《深入理解Java虚拟机》
  2. 堆外内存不受 -XX:MaxDirectMemorySize 限制

好了,这篇文章就到这里了,感谢大家的观看!如有错误,请及时指正!欢迎大家关注我的公众号:贾哇技术指南

分类:

后端

标签:

Java

作者介绍

贾哇技术指南
V1