更多 Java 虚拟机方面的文章,请参见文集《Java 虚拟机》

为什么需要使用堆外内存

将长期存活的对象(如 Local Cache )移入堆外内存( off-heap,又名直接内存 direct-memory),从而减少 CMS 管理的对象数量, 以降低 Full GC 的次数和频率,达到提高系统响应速度的目的。

加快了复制的速度:堆内在 flush 到远程时,会先复制到直接内存,然后在发送;而堆外内存相当于省略掉了这个工作。

堆外内存不是 JVM 运行时数据区 Runtime Data Area 的一部分,这部分内存区域直接被操作系统管理,JVM 通过 JNI 本地接口操作堆外内存。

堆外内存的使用

在 JDK 1.4以前,对这部分内存访问没有光明正大的做法:只能通过反射拿到 Unsafe 类,然后调用allocateMemory()/freeMemory()来申请/释放这块内存。

1.4 开始新加入了 NIO,它引入了一种基于 Channel 与 Buffer 的 I/O 方式,可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆里面的 DirectByteBuffer 对象作为这块内存的引用进行操作,ByteBuffer 提供了如下常用方法来跟堆外内存打交道:

public static ByteBuffer allocateDirect(int capacity)

分配堆外内存,返回一个 DirectByteBuffer 堆外内存对象 return new DirectByteBuffer(capacity);

public abstract ByteBuffer put(byte b);

向堆外内存中存放一个字节

public abstract byte get();

从堆外内存中读取一个字节

public final ByteBuffer put(byte[] src)

向堆外内存中存放一个字节数组

public ByteBuffer get(byte[] dst)

从堆外内存中读取一个字节数组

public abstract ByteBuffer putInt(int value);

向堆外内存中存放一个 int

public abstract int getInt();

从堆外内存中读取一个 int

public abstract IntBuffer asIntBuffer()

转换为一个 IntBuffer

public abstract ByteBuffer putLong(long value); 同上,以此类推

public abstract boolean isDirect();

判断是否为堆外内存

ByteBuffer 包含了如下的几个属性:

private int mark = -1;:标记位置,记录当前 position 的值

private int position = 0;:当前位置

private int limit;:限制大小

private int capacity;:空间容量

基本关系 mark <= position <= limit <= capacity

示例如下:

public static void main(String[] args) {
ByteBuffer bb = ByteBuffer.allocateDirect(1024);
bb.putChar('A');
bb.putInt(123);
System.out.println("capacity: " + bb.capacity());
System.out.println("limit: " + bb.limit());
System.out.println("position: " + bb.position());
bb.position(0);
System.out.println(bb.getChar());
System.out.println(bb.getInt());
}

输出:

capacity: 1024

limit: 1024

position: 6

A

123

堆外内存的设置

堆外内存的限额默认与堆内内存(由-XMX 设定)相仿,可用 -XX:MaxDirectMemorySize 重新设定。

当使用达到了阈值的时候将调用 System.gc 来做一次 Full GC,以此来回收掉没有被使用的堆外内存。

堆外内存的分配

在 DirectByteBuffer 中,首先向 Bits 类申请额度,Bits 类有一个全局的 totalCapacity 变量,记录着全部 DirectByteBuffer 的总大小,每次申请,都先看看是否超限:

如果已经超限,会主动执行 Sytem.gc(),期待能主动回收一点堆外内存。然后休眠一百毫秒,看看 totalCapacity 降下来没有,如果内存还是不足,就抛出大家最头痛的 OOM 异常。

如果额度被批准,就调用大名鼎鼎的 sun.misc.Unsafe 去分配内存,返回内存基地址,Unsafe 的 C++实现在此,标准的 malloc。然后再调一次 Unsafe 把这段内存给清零。

堆外内存的回收

堆外内存基于 GC 的回收

存在于堆内的 DirectByteBuffer 对象很小,只存着基地址和大小等几个属性,和一个 Cleaner,但它代表着后面所分配的一大段内存,是所谓的冰山对象。

通过前面说的 Cleaner,堆内的 DirectByteBuffer 对象被 GC 时,它背后的堆外内存也会被回收。

这里可以看到一种尴尬的情况,因为 DirectByteBuffer 本身的个头很小,只要熬过了 Young GC,即使已经失效了也能在老生代里舒服的呆着,不容易把老生代撑爆触发 Full GC,如果没有别的大块头进入老生代触发Full GC,就一直在那耗着,占着一大片堆外内存不释放。

这时,就只能靠前面提到的申请额度超限时触发的 System.gc()来救场了。

堆外内存的主动回收

对于 Sun 的 JDK 这其实很简单,只要从 DirectByteBuffer 里取出那个 sun.misc.Cleaner,然后调用它的 clean() 就行。

例如:

((DirectBuffer)bb).cleaner().clean();