前言
写这篇文章的契机是前段时间在Flink社区大群里详细解答了一个问题。
我们每天都会与JVM堆打交道(之前哪篇文章的开头也是这个来着)。但作为大数据工程师,我们对JVM的堆外内存(off-heap memory,英文资料中也常称为native memory)应该也是非常熟悉的,Spark、Flink、Kafka等这些鼎鼎大名的大数据组件都会积极地使用堆外内存,更底层的Netty之类就更不必说了。
使用堆外内存的好处主要有以下两个:
- 避免堆内内存Full GC造成的stop-the-world延迟,当然也可以降低OOM风险;
- 绕过用户态到内核态的切换,实现高效数据读写,如零拷贝和内存映射。
以Flink为例,打开正在运行的Flink作业Web UI中某个TaskManager的Metrics页,就可以看到堆外内存的使用情况,如“Outside JVM”一块所示。其中Direct即直接内存,对应NIO中的DirectByteBuffer;Mapped即映射内存,对应NIO中的MappedByteBuffer。
堆外内存毕竟也是内存,而服务器的内存量总是有限的,所以堆外内存也面临着回收的问题,并且不像堆内内存一样有垃圾收集器负责GC,而是需要自己实现。本文就以DirectByteBuffer为例探究堆外内存是如何回收的,当然在回收之前,先看看是如何分配的。
分配堆外内存
我们通过调用ByteBuffer.allocateDirect()方法分配堆外内存。
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
主要逻辑位于DirectByteBuffer类的构造方法中。
DirectByteBuffer(int cap) {
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)) {
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
att = null;
}
该方法的执行流程如下:
- 调用VM.isDirectMemoryPageAligned()方法获取是否需要进行页对齐,在JDK 7及以后默认不需要。
- 调用Bits.pageSize()方法获取内存页的大小,同样是为了对齐使用。
- 调用Bits.reserveMemory()方法试图预留指定大小的内存的配额,如果能够预留,就继续执行,否则直接抛出OOM。
- 调用Unsafe.allocateMemory()方法正式地分配内存,查看HotSpot的native代码,容易发现是调用了C语言的malloc()函数。
- 调用Unsafe.setMemory()方法将分配到的内存区域初始化为全0,自然是对应C语言的memset()函数。
- 对内存基地址进行可能的对齐操作。
- 调用Cleaner.create()方法创建一个sun.misc.Cleaner实例(其中包含有DirectByteBuffer的内部类Deallocator),该实例具体负责后面的堆外内存回收,后面细说。
Bits.reserveMemory()方法比较重要,值得看一眼。
static void reserveMemory(long size, int cap) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
if (tryReserveMemory(size, cap)) {
return;
}
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
System.gc();
boolean interrupted = false;
try {
long sleepTime = 1;
int sleeps = 0;
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) {
Thread.currentThread().interrupt();
}
}
}
不再逐行解释,只说三个重要的点:
- JVM能使用的最大堆外内存量可以由参数-XX:MaxDirectMemorySize显式指定。如果没有指定,翻HotSpot代码可以得知,默认堆外内存大小是-Xmx减去一个Survivor区的内存量,翻代码的过程就略去了。
- 如果首次调用tryReserveMemory()方法未能申请到指定大小的预留内存,就会主动调用System.gc()方法来通知进行GC,试图释放一些内存。因此在堆外内存使用频繁的场合,不要擅自开启-XX:+DisableExplicitGC开关进行“优化”,废掉System.gc()可能会适得其反。
- 首次申请未成功的话,就会循环调用tryReserveMemory()重试申请,一共会尝试MAX_SLEEPS(常量,为9)次,按指数退避规则从1ms开始设定重试的延迟。也就是说,Bits.reserveMemory()方法在最终失败的情况下,最多过511ms(约半秒)就抛出OOM。
回收堆外内存
DirectByteBuffer是一个轻量级的对象,主要的信息都维护在静态内部类Deallocator中,包括内存基地址、大小等。Cleaner是单独维护的。由以下代码可见,Deallocator还实现了Runnable接口,当它被执行时,会调用Unsafe.freeMemory()方法(对应C语言的free()函数)释放掉它持有的堆外内存。
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;
}
public void run() {
if (address == 0) {
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
}
private final Cleaner cleaner;
public Cleaner cleaner() {
return cleaner;
}
DirectByteBuffer的东西虽然不多(也就是占用堆空间是很少的),但是它背后可能是一大片不受JVM直接控制的堆外内存,因此JVM必须保证在DirectByteBuffer对象实例被GC掉时,它背后的堆外内存也同步被回收。这个机制就靠Cleaner来实现,因为它本质上是个虚引用(Phantom Reference)。借用阿里开发手册中的图来回忆一下Java中的四种引用。
Java中不同级别的引用实际上代表了GC根搜索机制中不同的可达性(reachability)。虚引用是最弱的一级引用,只有一个作用,就是跟踪对象的回收。也就是说对一个对象而言,如果除了与它关联的PhantomReference之外再无其他引用,那么在GC触发时,该对象就马上准备被回收。
来看下Cleaner这个类的源码,不长。
public class Cleaner extends PhantomReference {
private static final ReferenceQueue dummyQueue = new ReferenceQueue();
static private Cleaner first = null;
private Cleaner
next = null,
prev = null;
private static synchronized Cleaner add(Cleaner cl) {
if (first != null) {
cl.next = first;
first.prev = cl;
}
first = cl;
return cl;
}
private static synchronized boolean remove(Cleaner cl) {
if (cl.next == cl)
return false;
if (first == cl) {
if (cl.next != null)
first = cl.next;
else
first = cl.prev;
}
if (cl.next != null)
cl.next.prev = cl.prev;
if (cl.prev != null)
cl.prev.next = cl.next;
cl.next = cl;
cl.prev = cl;
return true;
}
private final Runnable thunk;
private Cleaner(Object referent, Runnable thunk) {
super(referent, dummyQueue);
this.thunk = thunk;
}
public static Cleaner create(Object ob, Runnable thunk) {
if (thunk == null)
return null;
return add(new Cleaner(ob, thunk));
}
public void clean() {
if (!remove(this))
return;
try {
thunk.run();
} catch (final Throwable x) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null)
new Error("Cleaner terminated abnormally", x)
.printStackTrace();
System.exit(1);
return null;
}});
}
}
}
该类中虽然有一个引用队列ReferenceQueue,但只是PhantomReference的强制需求,实际上并未用到。在今后有空的时候会详细说说ReferenceQueue的。
Cleaner类的实现非常聪明,除了使用虚引用之外,还有一点:使用双向链表维护所有Cleaner,防止Cleaner本身在它们对应的DirectByteBuffer之前被回收。而传入的Runnable就相当于回调函数,在Cleaner被GC掉时调用,因此在Deallocator.run()方法中释放掉堆外内存,就可以随着DirectByteBuffer的清理而清理了。
那么Cleaner.clean()方法是何时被调用的呢?我们需要简单看看ReferenceHandler,它本质上是Java引用的超类Reference的内部类,并且是一个线程。Reference类中有一个pending队列,用于保存已经注册到引用队列但尚未加入的引用,而ReferenceHandler就负责将这些引用加入不同的ReferenceQueue中,来看部分代码。
private static Reference<Object> pending = null;
private static class ReferenceHandler extends Thread {
// 部分略去...
public void run() {
while (true) {
tryHandlePending(true);
}
}
}
static boolean tryHandlePending(boolean waitForNotify) {
Reference<Object> r;
Cleaner c;
try {
synchronized (lock) {
if (pending != null) {
r = pending;
c = r instanceof Cleaner ? (Cleaner) r : null;
pending = r.discovered;
r.discovered = null;
} else {
if (waitForNotify) {
lock.wait();
}
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
Thread.yield();
return true;
} catch (InterruptedException x) {
return true;
}
if (c != null) {
c.clean();
return true;
}
ReferenceQueue<? super Object> q = r.queue;
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}
tryHandlePending()方法对pending队列的操作是加锁的,这是考虑到GC线程可能会并发执行,比如CMS。这段代码的细节就先不提,从中我们可以看出,如果判断出引用的类型为Cleaner,就会特殊处理一下,调用它的clean()方法并直接返回。如果不为Cleaner,就加入ReferenceQueue,用户代码进而可以对这些引用采取替代Finalizer的操作,这就是后话了。
结束了?
还差点事儿。根据JVM堆的分代GC机制,DirectByteBuffer这种小对象在经过-XX:MaxTenuringThreshold次的Young GC之后,很容易晋升到老生代。如果堆内内存的状况良好,余量充足,没有超大对象进入,那么可能很久都不会触发Full GC,造成堆外内存迟迟不被回收。为了避免这种情况,在前面的tryReserveMemory()方法中才会主动调用System.gc()方法。重要的话再说一遍:在堆外内存使用频繁的场合,不要擅自开启-XX:+DisableExplicitGC开关进行“优化”。
当然,我们也可以不等JVM,而是主动调用DirectByteBuffer.getCleaner().clean()方法,就可以在我们认为合适的时机回收堆外内存。Netty用了这种打法。
累了,晚安。