[简介]Java堆外内存

Wednesday, February 5, 2020

TOC

堆外内存是不受GC控制的内存空间,相对来说灵活度更高。大部分Java开发不会直接用到堆外内存,但对一些框架应用(如Kafka, Netty)堆外内存是必须牢牢掌控的一份宝藏,因为它最起码具有以下这些好处:

  1. 空间不受堆大小限制(但可通过-XX:MaxDirectMemorySize参数控制)
  2. 可以自定义内存分配和回收策略,不受JVM gc约束
  3. 可以使用零拷贝等高级特性,这在网络IO是极大的优势

构造方法

DirectByteBuffer的构造方法如下,

DirectByteBuffer(int cap) {
    // 按照ByteBuffer的抽象定义初始化对应的属性, 这些属性是跟DirectBuffer
    // 的实现无关
    super(-1, 0, cap, cap); // mark, position, limit, capacity
    // 根据是否页对齐 和 申请内存大小cap 来判断堆外内存是否足够分配
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    // 最少创建size=1
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap); // 核心方法1. 检查堆外内存空间

    long base = 0;
    try {
        base = unsafe.allocateMemory(size); // 核心方法2. 分配堆外内存空间,返回内存地址
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    // 初始化内存
    unsafe.setMemory(base, size, (byte) 0);
    // 计算得到Buffer使用的内存地址,这一套算法看不太懂
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    // 核心方法3. 构造Cleaner,用于回收堆外内存
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

JVM的可用堆外内存空间跟实际的堆外内存空间(物理内存空间)不同。逻辑上由Bits来统计当前JVM的堆外内存使用情况,而实际分配物理内存的工作留给unsafe

逻辑空间申请

首先通过Bits.reserveMemory(size, cap)方法判断逻辑上的堆外空间是否足够,不足时会休眠等待重试(设有超时时间约0.5秒)。

static void reserveMemory(long size, int cap) {

    // 从VM读取当前JVM可分配的最大堆外内存, 这个值可以通过'-XX:MaxDirectMemorySize=<size>'配置
    if (!memoryLimitSet && VM.isBooted()) {
        maxMemory = VM.maxDirectMemory();
        memoryLimitSet = true; // maxMemory在启动时确定, JVM运行过程中不会改变
    }

    // 先直接判断是否满足
    if (tryReserveMemory(size, cap)) {
        return;
    }

    final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    // 通过jlra可以主动触发其他堆外内存Cleaner的回收方法, 返回true表示有成功回收堆外内存
    while (jlra.tryHandlePendingReference()) {
        // 回收堆外内存后重新判断
        if (tryReserveMemory(size, cap)) {
            return;
        }
    }

    // 触发系统gc
    System.gc();

    boolean interrupted = false;
    try {
        long sleepTime = 1;
        int sleeps = 0;
        // 循环等待堆外内存空间是否足够, 每次等待的时间翻倍, MAX_SLEEPS=9
        while (true) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
            if (sleeps >= MAX_SLEEPS) {
                break;
            }
            if (!jlra.tryHandlePendingReference()) {
                try {
                    Thread.sleep(sleepTime);
                    sleepTime <<= 1;
                    sleeps++;
                } catch (InterruptedException e) {
                    interrupted = true;
                }
            }
        }

        // 仍然无法分配足够的堆外内存, 抛出异常
        throw new OutOfMemoryError("Direct buffer memory");

    } finally {
        if (interrupted) {
            // don't swallow interrupts
            Thread.currentThread().interrupt();
        }
    }
}

private static boolean tryReserveMemory(long size, int cap) {
    /**
     * maxMemory: 从VM读取当前JVM可分配的最大堆外内存, 这个值可以通过'-XX:MaxDirectMemorySize=<size>'配置
     * totalCapacity:AtomicLong : 当前JVM已经分配出去的堆外内存大小
    **/

    long totalCap;

    // cap < maxMemory - totalCap, 未超过JVM允许的堆外内存最大值. 
    while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
        // 改变内存容量
        if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
            reservedMemory.addAndGet(size);
            count.incrementAndGet();
            return true;
        }
    }
    return false;
}

物理空间申请

在确定逻辑上可以分配足够多的堆外内存空间之后,使用unsafe工具类真正地去申请物理堆外内存。如果unsafe无法分配足够多的内存空间时(OOM)需要通过Bits回收逻辑上分配出去的内存空间。

long base = 0;
try {
    base = unsafe.allocateMemory(size);
} catch (OutOfMemoryError x) {
    Bits.unreserveMemory(size, cap);
    throw x;
}

创建堆外内存回收器Cleaner

由于堆外内存不受gc控制,所以需要在堆外内存使用完毕后主动释放。不同于数据库连接这样的堆外资源常用的通过try-finally代码块显示释放资源的做法,DirectByteBuffer采用sun.misc.Cleaner来实现自动的资源回收。

Cleaner继承了PhantomReference(虚引用),虚引用类似于finalize()方法的升级版,可以在垃圾回收器回收虚引用所指的对象(即DirectByteBuffer对象)之前,通过引用队列主动执行一些资源释放的操作(在这里就是执行Deallocator#run()方法)。关于虚引用的细节可以参考这篇文章

private static class Deallocator implements Runnable {

    private static Unsafe unsafe = Unsafe.getUnsafe();

    private long address;
    private long size;
    private int capacity;

    private Deallocator(long address, long size, int capacity) {
        assert (address != 0);
        this.address = address;
        this.size = size;
        this.capacity = capacity;
    }

    // 在回收DirectByteBuffer对象前会回调此方法
    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        // 释放物理堆外内存
        unsafe.freeMemory(address);
        address = 0;
        // 释放逻辑堆外内存
        Bits.unreserveMemory(size, capacity);
    }

}