☕【JVM原理探索】让你完全攻克内存溢出(OOM)这一难题

809 阅读8分钟

每日一句

只有经历地狱般的磨练,才能创造出天堂般的力量

堆(Heap)内存不足

报错信息:

java.lang.OutOfMemoryError: Java heap space

导致原因

  1. 代码中可能存在大对象分配

  2. 可能存在内存泄露,导致在多次GC之后,还是无法找到一块足够大的内存容纳当前对象

  3. 业务场景会剧增对象数据,应该提升内存空间

解决方法

  1. 检查是否存在大对象的分配,最有可能的是大数组分配

  2. 通过jmap命令,把堆内存dump下来,使用mat工具分析一下,检查是否存在内存泄露的问题

  3. 如果没有找到明显的内存泄露,使用 -Xms/-Xmx 加大堆内存

  4. 还有一点容易被忽略,检查是否有大量的自定义的 Finalizable 对象,也有可能是框架内部提供的,考虑其存在的必要性

方法区溢出

报错信息:

java.lang.OutOfMemoryError: PermGen space
java.lang.OutOfMemoryError: Metaspace

导致原因

  • JDK8之前,永久代是HotSot 虚拟机对方法区的具体实现,存放了被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等。

  • JDK8后,元空间替换了永久代,元空间使用的是本地内存,还有其它细节变化:

    • 字符串常量由永久代转移到堆中
    • 和永久代相关的JVM参数已移除
  • 出现永久代或元空间的溢出的原因可能有如下几种:

    1. 在Java7之前,频繁的错误使用String.intern方法。
    2. 生成了大量的代理类,导致方法区被撑爆,无法卸载。
    3. 应用长时间运行,没有重启。

解决方法

  • 永久代/元空间 溢出的原因比较简单,解决方法有如下几种

    1. 检查是否永久代空间或者元空间设置的过小。

    2. 检查代码中是否存在大量的反射操作或者class加载操作以及生产class字节码。

    3. dump之后通过mat检查是否存在大量由于反射生成的代理类

    4. 放大招,重启JVM

GC overhead limit exceeded

报错信息

java.lang.OutOfMemoryError:GC overhead limit exceeded

导致原因

这个是JDK6新加的错误类型,一般都是堆太小导致的

Sun 官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常

解决方法

  1. 检查项目中是否有大量的死循环或有使用大内存的代码,优化代码

  2. 添加参数-XX:-UseGCOverheadLimit 禁用这个检查,其实这个参数解决不了内存问题,只是把错误的信息延后,最终出现 java.lang.OutOfMemoryError: Java heap space

  3. dump内存,检查是否存在内存泄露,如果没有,加大内存

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

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

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

  • 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。

这里把异常分成两种情况看似更加严谨,但却存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已

虚拟机的 StackOverflowError 异常

-Xss参数减小栈内存的容量,然后不断调用方法造成栈溢出,StackOverflowError 异常。

public class JVMStackSOF {
    private int stacklength = 1;   // 记录栈深度

    // 调用这个递归方法以造成栈溢出
    public void stackPush(){
        stacklength++;
        stackPush();
    }
    public static void main(String[] args) throws Throwable{
        JVMStackSOF sof = new JVMStackSOF();
        try{
            sof.stackPush();
        }catch(Throwable e){
            System.out.println("stack length = " + sof.stacklength);
            throw e;
        }
    }
}
openjdk@ubuntu:~$ java -Xss256k -cp
/home/openjdk/NetBeansProjects/JavaApplication1/build/classes test_JVMStackSOF.JVMStackSOF
stack length = 1888
Exception in thread "main" java.lang.StackOverflowError
   at test_JVMStackSOF.JVMStackSOF.stackPush(JVMStackSOF.java:17)
   at test_JVMStackSOF.JVMStackSOF.stackPush(JVMStackSOF.java:18)

-Xss256K:设置参数栈内存容量为256K

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

使用-Xss参数减少栈内存容量。结果:抛出StackOverflowError异常,异常出现时输出的栈深度相应缩小。

定义了大量的本地变量,增加此方法帧中本地变量表的长度。结果:抛出StackOverflowError异常时输出的栈深度相应缩小。


虚拟机栈隔离的,每个线程都有自己独立的虚拟机栈。

在 Java 虚拟机规范中,对虚拟机栈这个区域规定了两种异常状况:

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

  2. 如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),在扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。

虚拟机的 OutOfMemoryError 异常

通过-Xss2M参数增大栈内存的容量,然后不断开启新的线程,抛出OutOfMemoryError 异常

public class JVMStackOOM {

    private void dontStop() {
        while (true) {
        }
    }

    public static void main(String[] args) {
        // 不断开启新的线程消耗虚拟机栈空间
        while (true) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            }).start();
        }
    }
}

原理

  • 主要是因为-Xss参数设置的是一个线程的栈大小前面已经说过虚拟机栈是线程私有的,即每个线程都有一个自己的栈

操作系统分配给每个进程的内存是有限制的,譬如 32 位的 Windows 限制为 2GB。Java虚拟机提供了参数来控制 Java 堆和方法区的这两部分内存的最大值

2GB(操作系统限制的内存大小)减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗内存很小,可以忽略掉。如果虚拟机进程本身耗费的内存不计算在内,剩下的内存就由虚拟机栈和本地方法栈“瓜分”了

所以每个线程分配到的栈容量越大,可以建立的线程数量自然就越少,建立线程时就越容易把剩下的内存耗尽。第一例中把栈空间占满而抛出 StackOverflowError 异常,第二例中把内存消耗完而抛出 OutOfMemoryError 异常


方法栈溢出(从属于虚拟机栈的异常)

报错信息

java.lang.OutOfMemoryError : unable to create new native Thread

导致原因

出现这种异常,基本上都是创建的了大量的线程导致的,以前碰到过一次,通过jstack出来一共8000多个线程

解决方法

  1. 通过 -Xss 降低的每个线程栈大小的容量

  2. 线程总数也受到系统空闲内存和操作系统的限制,检查是否该系统下有此限制

/proc/sys/kernel/pid_max
/proc/sys/kernel/thread-max
max_user_process(ulimit -u)
/proc/sys/vm/max_map_count

非常规溢出

下面这些OOM异常,可能大部分的同学都没有碰到过,但还是需要了解一下

分配超大数组

报错信息

java.lang.OutOfMemoryError: Requested array size exceeds VM limit

这种情况一般是由于不合理的数组分配请求导致的,在为数组分配内存之前,JVM 会执行一项检查。要分配的数组在该平台是否可以寻址(addressable),如果不能寻址(addressable)就会抛出这个错误。

解决方法就是检查你的代码中是否有创建超大数组的地方

swap区溢出

报错信息 :

java.lang.OutOfMemoryError: Out of swap space

这种情况一般是操作系统导致的,可能的原因有:

  1. swap 分区大小分配不足;
  2. 其他进程消耗了所有的内存。

解决方案

  1. 其它服务进程可以选择性的拆分出去
  2. 加大swap分区大小,或者加大机器内存大小

本地方法溢出

报错信息 :

java.lang.OutOfMemoryError: stack_trace_with_native_method

本地方法在运行时出现了内存分配失败,和之前的方法栈溢出不同,方法栈溢出发生在 JVM 代码层面,而本地方法溢出发生在JNI代码或本地方法处

本机直接内存溢出

  • 直接内存可以通过:-XX:MaxDirectMemorySize 来设置大小,如果不设置,默认和堆在最大值-Xmx一样大。

  • 设置本机直接内存的原则就是,各种内存大小+本机直接内存大小<机器物理内存。

下面程序利用 DirectByteBuffe 模拟直接内存溢出的情况

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectBufferOom {
  public static void main(String[] args) {
    final int _1M = 1024 * 1024;
    List<ByteBuffer> buffers = new ArrayList<>();
    int count = 1;
    while (true) {
      ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);
      buffers.add(byteBuffer);
      System.out.println(count++);
    }
  }
}

在命令行运行 java -XX:MaxDirectMemorySize=10M DirectBufferOom ,很快控制台就会出现异常

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 DirectBufferOom.main(DirectBufferOom.java:12)

其实它并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常。下 面的程序利用 Unsafe 类模拟直接内存溢出

import sun.misc.Unsafe;

import java.lang.reflect.Field;

public class UnsafeOom {
  private static final int _1M = 1024 * 1024;

  public static void main(String[] args) throws IllegalAccessException, NoSuchFieldException {
    Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
    unsafeField.setAccessible(true);
    Unsafe unsafe = (Unsafe) unsafeField.get(null);
    while (true) {
      unsafe.allocateMemory(_1M);
    }
  }
}

在命令行运行 java -XX:MaxDirectMemorySize=10M UnsafeOom ,结果如下

Exception in thread"main"java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at org.fenixsoft.oom.DMOOM.main(DMOOM.java:20

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