详解Java堆外内存

2,650 阅读5分钟

临近春节,最近有点时间,准备顺着上篇专栏的思路写下去,建议先阅读: juejin.cn/post/684490…

武汉那几个吃野味的傻[],请藏好你们的妈

正文开始

在运行Java程序时,java虚拟机需要使用内存来存放各式各样的数据。java虚拟机规范把这些内存区域叫做运行时数据区:

而堆外内存,是指分配在java堆外的内存区域,其不受jvm管理,不会影响gc。

本文将以java.nio.DirectByteBuffer为例,来剖析堆外内存。

    // Primary constructor
    //
    DirectByteBuffer(int cap) {                   // package-private

        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;
    }

预定内存

从DirectByteBuffer的构造方法中可以看出,堆外内存的分配的开始在 Bits.reserveMemory(size, cap);中。

进入Bits类,先看几个和堆外内存相关的成员属性:

    private static volatile long maxMemory = VM.maxDirectMemory();
    private static final AtomicLong reservedMemory = new AtomicLong();
    private static final AtomicLong totalCapacity = new AtomicLong();
    private static final AtomicLong count = new AtomicLong();
    private static volatile boolean memoryLimitSet = false;

maxMemory

用户设置的堆外内存最大分配量,由jvm参数-XX:MaxDirectMemorySize=配置。

reservedMemory

已使用堆外内存的大小。使用AtomicLong来保证多线程下的安全性。

totalCapacity

总容量。同样使用AtomicLong。

count

记录分配堆外内存的总份数。

memoryLimitSet

一个标记变量,有volatile关键字。用来记录maxMemory字段是否已初始化。


在分配堆外内存前,jdk使用tryReserveMemory方法实现了一个乐观锁,来保证实际分配的堆外内存总数不会大于设计的上限。

private static boolean tryReserveMemory(long size, int cap) {

        // -XX:MaxDirectMemorySize limits the total capacity rather than the
        // actual memory usage, which will differ when buffers are page
        // aligned.
        long totalCap;
        while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
            if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
                reservedMemory.addAndGet(size);
                count.incrementAndGet();
                return true;
            }
        }

        return false;
    }

在tryReserveMemory中的逻辑也比较简单,使用while循环+CAS来保证有足够的剩余空间,并更新总空间,剩余空间,和堆外内存数。

可以看出,如果CAS失败,但还有足够的容量,while循环会进入下一轮CAS更新尝试,直到更新成功或容量不足。

下面的代码段中,注释中写的很清楚:将pending状态下的引用入队并重试,如果引用中包含对应的Cleaner的话,会帮助释放堆外内存。

        final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();

        // retry while helping enqueue pending Reference objects
        // which includes executing pending Cleaner(s) which includes
        // Cleaner(s) that free direct buffer memory
        while (jlra.tryHandlePendingReference()) {
            if (tryReserveMemory(size, cap)) {
                return;
            }
        }

在tryHandlePendingReference方法中,代码只有4行:

        SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
            public boolean tryHandlePendingReference() {
                return Reference.tryHandlePending(false);
            }
        });

相信看过上一篇讲解虚引用的专栏的读者到这里已经明白这里是怎样做的堆外内存释放了:

jlra.tryHandlePendingReference()实际上调用方法与jdk中处理pending状态引用Reference-handler线程调用的是同一个方法。

关于Reference-handler线程,详见:juejin.cn/post/684490…

随后,jdk会主动调用一次System.gc();

在reserveMemory方法中,只是先将堆外内存相关的属性设值,但并没有真正的分配内存。

分配内存

在预定堆外内存成功后,jdk会调用unsafe中的方法去做堆外内存分配。

    base = unsafe.allocateMemory(size);

allocateMemory方法是一个native方法,用于分配堆外内存。 在unsafe.cpp中,可以看到他的源码:

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
  size_t sz = (size_t)size;

  sz = align_up(sz, HeapWordSize);
  void* x = os::malloc(sz, mtOther);

  return addr_to_java(x);
} UNSAFE_END

调用了malloc函数去分配内存,并返回地址。

释放堆外内存

我们知道,jvm中java对象是采用gcRoot做可达性分析来确定是否回收的,而堆外内存是与gcRoot不关联的,那如何知道在何时应该回收堆外内存呢?

理想方案是:在对应的DirectByteBuffer对象实例被回收时,同步回收堆外内存。

这时应该有同学想到了finalize方法。这或许是一个java向c群体妥协的一个方法,在对象将要被回收时,由gc调用。看上去有点像c中的析构方法,But,该方法的调用是不可靠的,并不能保证对象在被回收前gc一定会调用该方法。

在jdk中,是采用的虚引用的方式去释放堆外内存。 在DirectByteBuffer的构造方法中,有一行如下代码:

    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));

DirectByteBuffer中的cleaner属性就是一个虚引用。

在Deallocator中,同样是使用unsafe中的native方法来释放堆外内存。

    unsafe.freeMemory(address);
    address = 0;
    Bits.unreserveMemory(size, capacity);```
UNSAFE_ENTRY(void, Unsafe_FreeMemory0(JNIEnv *env, jobject unsafe, jlong addr)) {
  void* p = addr_from_java(addr);

  os::free(p);
} UNSAFE_END

cleaner的调用点位于Reference类的Reference-handler线程中。

在引用对象的可达性发生变化,引用状态变为pending状态时,会在tryHandlePending方法中判断当前引用是否为Cleaner实例,如果是的话,则调用其clean方法,完成堆外内存回收。

其他

在预定内存时,为什么要主动调用System.gc

以下引用自寒泉子的博客(lovestblog.cn/blog/2015/0…):

既然要调用System.gc,那肯定是想通过触发一次gc操作来回收堆外内存,不过我想先说的是堆外内存不会对gc造成什么影响(这里的System.gc除外),但是堆外内存的回收其实依赖于我们的gc机制,首先我们要知道在java层面和我们在堆外分配的这块内存关联的只有与之关联的DirectByteBuffer对象了,它记录了这块内存的基地址以及大小,那么既然和gc也有关,那就是gc能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。DirectByteBuffer对象在创建的时候关联了一个PhantomReference,说到PhantomReference它其实主要是用来跟踪对象何时被回收的,它不能影响gc决策,但是gc过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里,在gc完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,而DirectByteBuffer关联的PhantomReference是PhantomReference的一个子类,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块

参考资料

《自己动手写java虚拟机》

lovestblog.cn/blog/2015/0…

openJdk源码&注释