TOC
堆外内存是不受GC控制的内存空间,相对来说灵活度更高。大部分Java开发不会直接用到堆外内存,但对一些框架应用(如Kafka
, Netty
)堆外内存是必须牢牢掌控的一份宝藏,因为它最起码具有以下这些好处:
- 空间不受堆大小限制(但可通过-XX:MaxDirectMemorySize参数控制)
- 可以自定义内存分配和回收策略,不受JVM gc约束
- 可以使用
零拷贝
等高级特性,这在网络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);
}
}