谈谈JDK堆外内存的创建和回收

堆外内存的优势在于IO操作,相比堆内存可以减少一次copy和gc的次数。下面通过源码去了解堆外内存的分配和回收。一般分配堆外内存通过ByteBuffer allocateDirect(int capacity)方法,其内部是通过如下构造函数来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
DirectByteBuffer(int cap) {               
super(-1, 0, cap, cap);// mark, pos, lim, 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)) { // 修改内存起始地址
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}

首先调用父类的构造方法初始化ByteBuffer的四个基本属性,接下来reserveMemory方法是判断堆外剩余内存是否满足。这里的剩余并不是系统真是的剩余内存,参数-XX:MaxDirectMemorySize指定JVM最多可用的堆外内存。

如果堆外内存不足,则触发System.gc,这里有些难已理解,明明是堆外内存不足,System.gc的作用是建议VM进行full gc,再怎么说也是堆内存的回收。这里先保留这个疑问,继续往下看。

根据VM参数判断是否内存页对齐计算真实分配内存的大小,由-XX:+PageAlignDirectMemory控制,默认为false。allocateMemory是真正分配内存如果失败则回收内存。setMemory为填充内存。

接下来根据是否内存页对齐来计算内存的起始地址。我们知道HeapByteBuffer是基于byte数组来实现,不需要我们去考虑回收由JVM去处理。但是堆外内存JVM无法想堆内存那样回收,因此就有了Cleaner和Deallocator的存在。

每一个DirectBytebuffer都对应一个Deallocator和Cleaner对象,而Deallocator是Cleaner的一个属性。Deallocator继承了Runnable接口,当然run方法内部是释放内存的逻辑。

1
2
3
4
5
6
7
public void run() {
// 释放
unsafe.freeMemory(address);
address = 0;
// 修改堆外内存的占用大小
Bits.unreserveMemory(size, capacity);
}

在分析Cleaner之前我们先复习下PhantomReference(虚引用)

虚引用,正如其名,对一个对象而言,这个引用形同虚设,有和没有一样。如果一个对象与GC Roots之间仅存在虚引用,则称这个对象为虚可达(phantom reachable)对象。
当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在垃圾回收后,将这个虚引用加入引用队列,可以通过检查引用队列中是否有相应的虚引用来判断对象是否已经被回收了。

Cleaner继承自PhantomReference,在谈谈Java Reference的原理中介绍了Reference框架的大体逻辑,在PendingHandlerThread会把Pending list的引用对象移入Reference Queue,这个过程中如果Reference是Cleaner类型,那么会执行clean方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void clean() {
if (remove(this)) {
try {
this.thunk.run();
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}
System.exit(1);
return null;
}
});
}
}
}

thunk是Deallocator类型,也就是说它run方法最终是由PendingHandlerThread线程执行的。这就是JDK的自动回收堆外内存。

thunk是Deallocator类型,也就是说它run方法最终是由PendingHandlerThread线程执行的。这就是JDK的自动回收堆外内存。

总结一下:DirectByteBuffer对象指向堆外的内存,它保存了一块内存的基本属性和Cleaner和Deallocator对象等。占用的空间相比堆外内存只是冰山一角,当DirectByteBuffer对象被回收,Cleaner对象也就是虚引用被加入到Pending list,PendingHandlerThread线程执行Cleaner的clean方法,最终释放堆外内存。这也就解释了为什么执行gc可以回收堆外内存了。也可以手动释放,首先拿到DirectByteBuffer的Cleaner对象,执行它的clean方法。

由于cleaner是private访问权限,所以自然想到使用反射来实现。
DirectByteBuffer实现了DirectBuffer接口,这个接口有cleaner方法可以获取cleaner对象

参考地址

如果大家喜欢我的文章,可以关注个人订阅号。欢迎随时留言、交流。如果想加入微信群的话一起讨论的话,请加管理员简栈文化-小助手(lastpass4u),他会拉你们进群。

简栈文化服务订阅号