阅读 10
37-大厂面试题:什么是内存溢出?在哪些区域会发生内存溢出?请解释下直接内存OOM

37-大厂面试题:什么是内存溢出?在哪些区域会发生内存溢出?请解释下直接内存OOM

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动

欢迎关注公众号OpenCoder,来和我一起做朋友吧~😍😊😘

首先我们先回顾下JVM内存模型:

按照JVM规范,JAVA虚拟机在运行时会管理以下的内存区域:

  • 程序计数器:当前线程执行的字节码的行号指示器,线程私有
  • JAVA虚拟机栈:Java方法执行的内存模型,每个Java方法的执行对应着一个栈帧的进栈和出栈的操作。
  • 本地方法栈:类似“ JAVA虚拟机栈 ”,但是为native方法的运行提供内存环境。
  • JAVA堆:对象内存分配的地方,内存垃圾回收的主要区域,所有线程共享。可分为新生代,老生代。
  • 方法区:用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。Hotspot中的“永久代”。1.8称为Metaspace元空间
  • 运行时常量池:方法区的一部分,存储常量信息,如各种字面量、符号引用等。
  • 直接内存:并不是JVM运行时数据区的一部分, 可直接访问的内存, 比如NIO会用到这部分。

按照JVM规范,除了程序计数器不会抛出OOM外,其他各个内存区域都可能会抛出OOM。

最常见的OOM情况有以下三种:

  • java.lang.OutOfMemoryError: Java heap space ------>java堆内存溢出,此种情况最常见,一般由于内存泄露或者堆的大小设置不当引起。 对于内存泄露,需要通过内存监控软件查找程序中的泄露代码,而堆大小可以通过虚拟机参数-Xms,-Xmx等修改。
  • java.lang.OutOfMemoryError: PermGen/Metaspace space ------>java永久代/元空间溢出,即方法区溢出了,一般出现于大量Class或者jsp页面,或者采用cglib等反射机制的情况,因为上述情况会产生大量的Class信息存储于方法区。 此种情况可以通过更改方法区的大小来解决,使用类似-XX:MetaspaceSize=512m -XX:MaxMetaspaceSize=512m的形式修改。另外,过多的常量尤其是字符串也会导致方法区溢出(JDK1.7之前,1.7之后StringTable挪到堆内存中)。
  • java.lang.StackOverflowError ------> 不会抛OOM error,但也是比较常见的Java内存溢出。JAVA虚拟机栈溢出,一般是由于程序中存在死循环或者深度递归调用造成的,栈大小设置太小也会出现此种溢出。可以通过虚拟机参数-Xss来设置栈的大小。

我们后续将重点围绕以上三块区域进行重点讲解以及分析,一步一图彻底掌握。

这里我们先将直接内存的概念以及直接内存为什么会发生OOM给大家做一个解释普及。

JVM内存模型之直接内存

直接内存(Direct Memory) 并不是虚拟机运行时数据区的一部分, 也不是《Java虚拟机规范》 中定义的内存区域。 但是这部分内存也被频繁地使用, 而且也可能导致OutOfMemoryError异常出现, 所以我们放到这里一起讲解。

在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。

这里简单给大家回顾下NIO的知识:

我们不使用ByteBuff的方式读取本地磁盘文件,是通过先将磁盘文件读取到系统内存中的缓冲区,而系统中的缓存区Java代码是无法直接操作的,需要借助于本地函数进行copy到 Java内存的缓冲区byte[] 中。

直接内存的划分相当于就是直接在系统内存中开辟出了一块空间,直接供Java代码使用,避免了数据拷贝带来的效率问题

显然, 本机直接内存的分配不会受到Java堆大小的限制, 但是, 既然是内存, 则肯定还是会受到本机总内存(包括物理内存、 SWAP分区或者分页文件) 大小以及处理器寻址空间的限制, 一般服务器管理员配置虚拟机参数时, 会根据实际内存去设置-Xmx等参数信息, 但经常忽略掉直接内存, 使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制) , 从而导致动态扩展时出现 OutOfMemoryError异常。

因此我们对直接内存做一个小结:

  • 本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制
  • 直接内存也可以由 -XX:MaxDirectMemorySize 指定
  • 直接内存申请空间耗费更高的性能
  • 直接内存IO读写的性能要优于普通的堆内存,常见于NIO操作时,用于数据缓冲区

当我们需要频繁访问大的内存而不是申请和释放空间时,通过使用直接内存可以提高性能。

Java创建直接内存

Java中ByteBuffer用于创建内存缓存,其类的继承关系如下:

其中HeapByteBuffer用于创建JVM堆内缓存区,DirectByteBuffer用于创建Native缓存区。通过调用ByteBuffer的静态方法allocateallocateDirect方法分别创建两种缓存区。

MappedByteBuffer是NIO提供的文件内存映射的实现方案,可以把整个文件或文件段映射到Native堆内存。MappedByteBuffer是抽象类,DirectByteBuffer才是提供实际功能的其直接子类,他同时实现了DirectBuffer接口,此接口提供了cleaner用于GC管理。

代码演示

这里我们通过以下代码来演示Java在直接本机内存中进行分配对象与回收:

public class ByteBufferDemo {
    static int _1Gb=1024*1024*1024;

    public static void main(String[] args) throws IOException {

        System.out.println("准备申请直接内存前");
        System.in.read();

        //拿到 DirectByteBuffer 这个对象来操作直接内存1G
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配直接内存1G完毕");

        System.in.read();

        System.out.println("开始释放1G直接内存");
        byteBuffer = null;

        System.out.println("开始准备垃圾回收");
        System.in.read();
        //手动触发Full GC
        System.gc();

        System.out.println("垃圾回收完毕");
        System.in.read();
    }
}
复制代码

1) 程序运行起来,准备申请直接内存前

​ 我们看下我们目前的内存使用:

目前该Java类运行起来后只占据了17.5M

2)通过ByteBuff申请1G直接内存

​ 代码执行:ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb); 后内存如下:

瞬间吃掉了内存1G

3)byteBuffer = null;

​ 控制台打印如下:

准备申请直接内存前

分配直接内存1G完毕

开始释放1G直接内存
开始准备垃圾回收
复制代码

注意这一步仅仅只是将 byteBuff 置为了 null ,内存还并没未被释放!

4)System.gc();

手动触发垃圾回收:

可以看到该行代码执行完毕后,直接内存才真正被释放完毕,回收掉byteBuffer对象

直接内存溢出测试-DirectByteBuffer

DirectByteBuffer在对内存进行操作的时候是提供了对堆外内存的自动回收的

测试代码如下,运行时添加参数 -verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M

/**
 * vm参数:-verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M
 */
public class ByteBufferOOMTest {
    static int _1Gb=1024*1024*1024;
    public static void main(String[] args) {
        while(true){
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
        }
    }
}
复制代码

控制台输出 OOM : Direct buffer memory!!!

DirectByteBuffer源码分析

通过查看DirectByteBuffer源码可以发现,其实内部底层使用的是 Unsafe类来进行操作的:

通过借助于一个 cleaner线程对象进行垃圾回收,里面提供了一个clean()方法:

该方法用来开启线程的执行,对应的run方法中调用了 UNSAFE的freeMemory() 方法对直接内存进行释放

如果仔细查看Cleaner这个对象 你发现其实该对象使用了虚引用的技术:

public class Cleaner extends PhantomReference<Object{
     private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
复制代码

在之前讲解对象引用的时候我们专门说过虚引用:

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

直接内存溢出测试-Unsafe

因此我们可以直接使用Unsafe来进行直接内存的申请:

/**
 * vm参数:-verbose:gc -XX:+PrintGCDetails -XX:MaxDirectMemorySize=20M
 */
public class UnsafeOOMTest {

    private static final int _1M = 1024 * 1024;

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
        // 通过反射获取rt.jar下的Unsafe类
        Field theUnsafeInstance = Unsafe.class.getDeclaredFields()[0];
        theUnsafeInstance.setAccessible(true);
        // (Unsafe) theUnsafeInstance.get(null);是等价的
        Unsafe unsafe = (Unsafe) theUnsafeInstance.get(Unsafe.class);

        while(true) {
            unsafe.allocateMemory(_1M);
        }
    }
}
复制代码

运行后依然爆出OOM异常:

注意 :使用JDK内部的Unsafe类直接使用堆外内存,JVM是不会自动进行内存回收的! 需要配合unsafe.freeMemory(point); 方法手动进行回收释放

直接内存OOM小结:

当我们想在堆外内存中直接申请空间进行分配时,可以使用 DirectByteBuffer来进行操作,当遇到JVM FullGC的时候会自动进行堆外内存的回收,或者我们手动调用 System.GC() 进行主动回收。

由于堆外内存并没有 JVM 协助管理内存,需要我们自己来管理堆外内存,为了防止内存溢出,避免一直没有 Full GC,最终导致物理内存被耗完,建议大家在配置JVM参数的时候,通过:-XX:MaxDirectMemerySize来指定,当达到阈值的时候,调用system.gc来进行一次full gc,把那些没有被使用的直接内存回收掉。

欢迎关注公众号OpenCoder,来和我一起做朋友吧~😍😊😘

文章分类
后端
文章标签