Java是怎么管理堆外内存的

2,441 阅读5分钟

这是我参与8月更文挑战的第22天,活动详情查看:8月更文挑战

JVM内存划分

image.png

  1. java中的对象都是在堆内分配的,即,我们常说的JVM内存,JVM内存完全被java虚拟机管理,所以,对于java程序员来说,完全不用关系对象的回收问题,java虚拟机通过对内存进行划分成年轻代和老年代,并使用不同的垃圾回收算法对我们废弃的java对象进行回收。

  2. 除了JVM内存外,还有一种内存即为JVM堆外内存。堆外内存的使用和释放都要通过调用操作系统API,堆外内存不受java虚拟机管理,所以我们使用后,需要进行手动的释放,否则会造成内存泄漏问题。

堆外内存的作用

  1. 两个进程的堆外内存指向同一块地址即可实现进程间通讯。

  2. 当进行网络IO操作,文件读写时,如果使用堆内内存,那么首先要将堆内内存拷贝到堆外,然后操作系统才能对数据进行读写,但是使用堆外内存的话,操作系统可以直接操作,减少了一次内存拷贝,增加我们程序的性能。

  3. 因为堆外内存使我们编程人员手动进行申请和释放的,所以,在一定程度上也可以减轻JVM的GC压力。

Unsafe类申请/释放内存

我们可以通过调用Unsafe的API对堆外内存进行申请和释放

  1. Unsafe构造方法私有化,我们不能直接创建Unsafe实例,只能通过反射获取
Class<Unsafe> unsafeClass = Unsafe.class;
Field unsafeField = unsafeClass.getDeclaredField("theUnsafe");
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
  1. 申请/回收内存
// 分配一个10M大小的堆外内存,并返回内存的起始地址
long address = unsafe.allocateMemory(1024 * 1024 * 10);
// 手动调用释放内存API
unsafe.freeMemory(address);

一般情况下,不会直接使用这个API进行操作,而是使用java的NIO中自带的ByteBuffer帮助我们操作堆外内存。

ByteBuffer堆外内存管理

java中可以通过使用ByteBuffer申请堆外内存

申请一个10M大小的堆外内存

ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024 * 10);

通过查看该方法的源码可以看出,内部是调用了Unsafe类,除了申请了一块堆外内存外,该堆外内存还关联了一个Cleaner对象。

DirectByteBuffer(int cap) {                  

    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

堆外内存和Cleaner对象,ByteBuffer对象的关系

image.png

ByteBuffer对堆外内存的回收策略

  1. 我们创建的ByteBuffer对象可能有时候会常驻内存,这样在经过若干次GC后,分代年龄到了晋升到老年代的条件,就会被转移到老年代,以为老年代很少进行FUllGC,所以会导致堆外内存迟迟不回被回收。进而出现类似内存泄漏的问题。

  2. 所以,我们尽量在启动JVM实例时,通过配置-XX:MaxDirectMemorySize指定堆外堆内存大小,防止堆外内存无限使用,当堆外内存分配不足时,就会触发一次FUllGC进行垃圾清理。如果FUllGC后,还是无法满足需要新分配的内存大小,程序就会抛OOM

  3. ByteBuffer.allocateDirect执行过程中,也会如果堆外内存不足,也会通过调用Bits.unreserveMemory(size, cap);内部会调用System.gc()强制执行FUllGC,但是生产环境中,一般都会配置-XX:+DisableExplicitGC关闭System.gc()的作用。

Cleaner对象回收堆外内存原理

Java对象中,有四种引用关系,分别是强引用软引用弱引用虚引用。Cleaner对象就是一个虚引用对象。 虚引用的对象不能单独使用,需要配合引用队列使用,它并不影响对象的生命周期。在java中用java.lang.ref.PhantomReference类表示。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。要注意的是,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

public class Cleaner extends PhantomReference<Object> {
    private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
    private static Cleaner first = null;
    private Cleaner next = null;
    private Cleaner prev = null;
    private final Runnable thunk;
}

image.png

  1. first 是 Cleaner 类中的静态变量,Cleaner 对象在初始化时会加入 Cleaner 链表中。
  2. DirectByteBuffer 对象包含堆外内存的地址、大小以及 Cleaner 对象的引用。
  3. ReferenceQueue 用于保存需要回收的 Cleaner 对象。

当ByteBuffer被回收后,Cleaner对象不再有任何强引用,此时,Cleaner对象会被加入到引用队列中,执行clean方法。

public void clean() {
    if (remove(this)) {
        try {
            this.thunk.run();
        } catch (final Throwable var2) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    if (System.err != null) {
                        (new Error("Cleaner terminated abnormally", var2)).printStackTrace();
                    }

                    System.exit(1);
                    return null;
                }
            });
        }

    }
}
public void run() {
    if (address == 0) {
        // Paranoia
        return;
    }
    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);
}

clean方法主要做了两件事情

  1. 将自己从链表中移除。
  2. 调用unsafe方法,回收堆外内存。这时,堆外内存就被成功回收了。