1. 何谓堆外内存

在之前的文章里面讲了JVM结构,着重讲了JVM堆结构,这里的堆外内存就是相对于JVM堆而言的,主要包含jvm本身在运行过程中分配的内存,codecache,jni里分配的内存,DirectByteBuffer分配的内存等等。

堆内内存相对应,堆外内存就是把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。

日常开发中主要接触到的是java.nio.DirectByteBuffer在创建的时候分配内存,这篇文章也主要讲述DirectByteBuffer。

2. 堆外内存创建

先来看看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;
    
    
    
        }    

首先通过 Bits.reserveMemory(size, cap); 申请内存,如果申请成功则通过base = unsafe.allocateMemory(size);来实际分配内存空间,如果分配失败会抛出OOM异常。

来分析下Bits.reserveMemory(size, cap)的代码:

        // These methods should be called whenever direct memory is allocated or
        // freed.  They allow the user to control the amount of direct memory
        // which a process may access.  All sizes are specified in bytes.
        static void reserveMemory(long size, int cap) {
            synchronized (Bits.class) {
                if (!memoryLimitSet && VM.isBooted()) {
                    maxMemory = VM.maxDirectMemory();
                    memoryLimitSet = true;
                }
                // -XX:MaxDirectMemorySize limits the total capacity rather than the
                // actual memory usage, which will differ when buffers are page
                // aligned.
                if (cap <= maxMemory - totalCapacity) {
                    reservedMemory += size;
                    totalCapacity += cap;
                    count++;
                    return;
                }
            }
    
            System.gc();
            try {
                Thread.sleep(100);
            } catch (InterruptedException x) {
                // Restore interrupt status
                Thread.currentThread().interrupt();
            }
            synchronized (Bits.class) {
                if (totalCapacity + cap > maxMemory)
                    throw new OutOfMemoryError("Direct buffer memory");
                reservedMemory += size;
                totalCapacity += cap;
                count++;
            }
    
        }

代码中可以看出,申请的直接内存大小受到 -XX:MaxDirectMemorySize 的限制,如果申请的内存获得批准,则返回至上层,继续申请内存;如果申请的内存大小,已经超出限制,则会调用System.gc()以期望触发垃圾回收,并将当前线程sleep 100毫秒,之后再尝试申请,如果此时申请失败,则抛出OOM报错。

当额度批准之后就调用unsafe.allocateMemory(size) 分配内存,返回内存基地址,标准的malloc(源代码)。

之后调用unsafe.setMemory(base, size, (byte) 0);将这段内存全部清零。

最后,创建一个Cleaner,并把代表清理动作的Deallocator类绑定 -- 降低Bits里的totalCapacity,并调用Unsafe调free去释放内存。


其中first是Cleaner类的静态变量,Cleaner对象在初始化时会被添加到Cleaner链表中,和first形成引用关系,ReferenceQueue是用来保存需要回收的Cleaner对象。
额度申请时,System.gc()的作用
额度申请时,如果堆外内存不足,则会调用System.gc()强制执行GC,以清理堆外内存。不过很多线上环境的JVM参数有-XX:+DisableExplicitGC,导致了System.gc()等于一个空函数,根本不会触发GC,从而导致线上内存不足的问题。因为DirectByteBuffer本身size很小,只要熬过了young gc,即使失效也会一直保留在老年代中,难以触发full gc,这时候就需要System.gc()来触发Full GC,从而实现回收。

堆外内存申请成功之后,该如何回收这些内存呢。

3. 堆外内存的回收

在DirectByteBuffer被GC回收之后,只有Cleaner对象唯一保存了堆外内存的数据(开始地址、大小和容量),在下一次GC时,把该Cleaner对象放入到ReferenceQueue中,并触发clean方法。

Cleaner对象的clean方法主要有两个作用:

1、把自身从Clener链表删除,从而在下次GC时能够被回收

2、释放堆外内存

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

4. 堆外内存框架

堆外内存的使用,除了在Netty等网络框架中,在本地缓存中也有大量应用:

  • Ehcache 3.0:3.0基于其商业公司一个非开源的堆外组件的实现。
  • Chronical Map:OpenHFT包括很多类库,使用这些类库很少产生垃圾,并且应用程序使用这些类库后也很少发生Minor GC。类库主要包括:Chronicle Map,Chronicle Queue等等。
  • OHC:来源于Cassandra 3.0, Apache v2。
  • Ignite: 一个规模宏大的内存计算框架,属于Apache项目。

References

JVM源码分析之堆外内存完全解读
Netty之Java堆外内存扫盲贴
JVM源码分析之SystemGC完全解读
DirectByteBuffer内存释放
记一次java native memory增长问题的排查
JAVA使用堆外内存导致swap飙高
如何查看jvm堆外内存使用情况
堆内内存还是堆外内存?
堆外内存的回收机制分析