该部分内容其实和Netty关系不大,但是在讲解Netty对堆外内存的回收策略之前,我们有必须来了解一下Java是如何处理堆外内存的
问题由来
在学习Netty的过程中,不免会将Java中Nio的ByteBuffer与Netty的ByteBuf混淆,在对于堆外内存的回收策略中找不到两者的边界,不能明确的区分Java与Netty对堆外内存是如何回收堆外内存的。这篇文章主要是来讲解Java对于堆外内存的回收策略
堆外内存
在谈及堆外内存的回收策略之前,我们先来连接一下堆外内存是什么?
Java中有自己的内存模型,大家熟悉的就是堆栈,堆栈中存储的对象的生命周期是由Java的JVM来进行管理的,也就是说,我们不需要关心对象回收的问题。(当然了解JVM是如何gc定位及其回收垃圾对于程序员来说还是很重要的)。
由于JVM在进行gc的时候会对对象的内存地址进行移动(比如标记复制/标记整理的gc算法),导致操作系统不能直接操作JVM中的内存对象,因为操作系统在操作堆内内存对象的时候,如果发生了gc,被操作的对象在Java堆上的位置就发生了变化,而操作系统是无法感知这个变化的,就会导致操作系统处理堆内内存失败。为了解决这个问题,当需要与操作系统进行数据交换时,Java会主动的将内存中的对象拷贝到堆外内存,让操作系统直接操作堆外内存就不会存在这样的问题。但是这样也引带来了数据拷贝的开销。
堆外内存,就是非Java管理的一块操作系统的内存空间,Java可能通过Unsafe类中的native方法进行操作,由于JVM不管理堆外内存,因此在堆外内存上开辟的内存空间,当对象生命周期结束时,需要我们主动的去释放这份内存空间,然而Java还是想要达到自动内存管理的效果,并没有让程序员人为的手动释放内存,而是借助JVM的gc顺带回收堆外内存的思想。
堆外内存在堆内的表示
通过下面一行简单的代码,就可以申请一快堆外内存空间
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
其中byteBuffer对象是堆内一个对象,该对象中存在一个地址,该地址代表的是在堆外内存申请的内存的起始地址,如下图所示:
对堆外内存的操作,实践上都是对堆内存储的起始地址的操作。当堆内对象变的不可达时,顺便回收其对应的堆外内存,那么就存在两个问题:
- 如何知道堆对象何时不可达?
- 对于一个大的堆外内存对象,在堆内表示是非常小的(其实就address,offset等几个字段值而已,这就是所谓的冰山对象,占用堆内内存非常少,在其背后其实存在一大块堆外内存),如果该堆内对象在经过几次young gc后进入了老年代,即便该对象变为不可达,由于没有触发full gc,也不会触发其回收操作
如何知道对象何时不可达
要想知道Java中堆对象何时被回收,那就有必要学习一下Java中的引用类型。在Java中存在着4中引种类型,强引用,软引用,弱引用,虚引用。
- 强引用:就是我们一般使用对象的方式,例如通过new构造一个对象,堆内对象只要还存在强引用指向它,它就不会被JVM回收。
- 软引用:相对于强引用较弱,一般在内存不足时才会回收,该类引用指向的对象应该是可有可无的,有会提高程序的效率,没有也不会引起程序故障。因此软引用适用于做缓存对象
- 弱引用:当只有弱引用可达对象时,gc会立即回收对象
- 虚引用: 对于软引用,弱引用通过get方法都是可以获取到其引用对象的,但是虚引用通过get方法是获取到的永远都是null
在DirectByteBuf中通过虚引用来判断堆内对象是否已经不可达,在JVM中会启动一个专门的线程handler来处理不可达对象,在将不可达对象添加到引用队列前,会判断该对象是否为Cleanner,如果时,则使用Cleaner进行回收工作。
在DirectByteBuf中,是通过其成员变量cleaner进行堆外内存的释放,看下Cleanner类的定义
public class Cleaner extends PhantomReference<Object>
从定义中可以看出,Cleaner类继承了PhantomReference虚引用类,也就是说Cleaner也是一个虚引用对象。满足上面所讲的所有虚引用的特性。因此在Cleaner类内部维护了一个静态的成员变量ReferenceQueue,定义如下:
private static final ReferenceQueue<Object> dummyQueue = new ReferenceQueue();
当创建DirectByteBuf对象时,就会创建Cleaner对象,创建DirectByteBuf的代码在下文中会讲,这里简单看下Cleaner对象的创建:
cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
创建Cleaner的时候,会创建一个Deallocator,该类才是真正的释放内存的类,看下其实现:
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) {
// Paranoia
return;
}
unsafe.freeMemory(address);
address = 0;
Bits.unreserveMemory(size, capacity);
}
主要进行两个操作:
- 使用unsafe根据堆外内存的起始地址释放堆外内存
- 根据当前DirectByte的size与cap修改在Bits中的统计信息,Bits类相关下文中会讲,主要就是统计当前堆外内存的分配情况。
看完Deallocator类之后,再看一下Cleaner的create操作,该操作就是将DirectByteBuf对象包装成虚引用,并扔到引用队列中,实现如下:
public static Cleaner create(Object var0, Runnable var1) {
return var1 == null?null:add(new Cleaner(var0, var1));
}
private Cleaner(Object var1, Runnable var2) {
super(var1, dummyQueue);
this.thunk = var2;
}
private static synchronized Cleaner add(Cleaner var0) {
if(first != null) {
var0.next = first;
first.prev = var0;
}
first = var0;
return var0;
}
可以看到,除了Cleaner内部自己的引用队列外,Cleaner对象会自己维护一个静态链表,每次新创建的DirectByteBuf对应的Cleaner对象放到链表头。
那么问题来了:是谁,在什么时候,调用了Cleaner的clean方法?
接下来我们看一下Cleaner的父类Reference中的逻辑
在Reference类的静态方法中启动了一个handler线程,实现如下:
static {
ThreadGroup tg = Thread.currentThread().getThreadGroup();
for (ThreadGroup tgn = tg;
tgn != null;
tg = tgn, tgn = tg.getParent());
Thread handler = new ReferenceHandler(tg, "Reference Handler");
/* If there were a special system-only priority greater than
* MAX_PRIORITY, it would be used here
*/
handler.setPriority(Thread.MAX_PRIORITY);
handler.setDaemon(true);
handler.start();
// provide access in SharedSecrets
SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
@Override
public boolean tryHandlePendingReference() {
return tryHandlePending(false);
}
});
}
在该静态方法中,首先创建handler线程类,然后设置优先级,设置为守护线程,然后启动。
下面看一下handler线程在做什么,代码如下:
public void run() {
while (true) {
tryHandlePending(true);
}
}
可以看到该线程在一个死循环中一致处理tryHandlerPending方法,下面看一下该方法:
static boolean tryHandlePending(boolean waitForNotify) {
Reference<Object> r;
Cleaner c;
try {
synchronized (lock) {
if (pending != null) {
r = pending;
// 'instanceof' might throw OutOfMemoryError sometimes
// so do this before un-linking 'r' from the 'pending' chain...
c = r instanceof Cleaner ? (Cleaner) r : null;
// unlink 'r' from 'pending' chain
pending = r.discovered;
r.discovered = null;
} else {
// The waiting on the lock may cause an OutOfMemoryError
// because it may try to allocate exception objects.
if (waitForNotify) {
lock.wait();
}
// retry if waited
return waitForNotify;
}
}
} catch (OutOfMemoryError x) {
// Give other threads CPU time so they hopefully drop some live references
// and GC reclaims some space.
// Also prevent CPU intensive spinning in case 'r instanceof Cleaner' above
// persistently throws OOME for some time...
Thread.yield();
// retry
return true;
} catch (InterruptedException x) {
// retry
return true;
}
// Fast path for cleaners
if (c != null) {
c.clean();
return true;
}
ReferenceQueue<? super Object> q = r.queue;
if (q != ReferenceQueue.NULL) q.enqueue(r);
return true;
}
从上述的逻辑中,可以发现,执行了Cleaner的clean方法,也就是说DirectByteBuf是在这里被回收的。简单分析一下代码,没有深入研究,如果有问题,还请指出。
- 首先看pending是否有值,pending是jvm进行赋值的,当对象可达性变为不可达时会赋值到pending上。
- 如果pending有值,则判断是否为Cleaner类型,如果不是则赋值c为null
- discoverd应该时下一个不可达的对象,赋值给pending
- 如果pending不存在值,等待pending有值
- 如果c不为null,说明时Cleanr对象,直接执行clean方法,在该方法中调用了Deallocator任务的run方法,使用unsafe进行对外内存的释放。
- 将pending加入到引用队列。
从上面的逻辑中,我们可以发现,在handler线程中执行了Cleaner的clean方法,从而达到了回收的效果。但是并没有用到ReferenceQueue的特性
冰山对象进入老年代无法释放怎么办
如果表示堆外内存的堆内对象一不小心进入了老年代,由于其占用的堆内堆存很少,又可能项目的堆内内存使用比较稳定,没有触发full gc,那么即便该对象已经不可达,但也没有办法释放对应的堆外内存,碰到这种情况应该怎么办?在Java中并没有别的方法,只能调用System.gc去让JVM进行gc了。然而System.gc存在几个问题:
- 很多公司为了避免程序员依赖迷信该方法,会禁用System.gc
-XX:+DisableExplicitGC
- System.gc只是建议JVM去gc,但是JVM到底执行不执行,JVM说了算。
下面我们看一下DirectByteBuf的构造函数源码:
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}
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;
}
通过调用静态方法allocateDirect方法,直接构造一个DirectByteBuffer进行返回。在构造方法中
- 首先计算一下需要申请的内存大小,这里涉及到是否页对齐(不了解可以先忽略),其中size为真正申请的内存大小,cap为需要申请的内存大小, size>=cap
- 在Bits类中记录堆外内存的使用情况,这里稍后再看
- 通过unsafe申请size大小的内存,并返回内存的起始地址。如果申请失败,在Bits中减去相应记录
- 通过unsafe初始化内存内容,擦除内存上的信息
- 根据是否页对齐,重新计算申请内存的其实地址
- 创建一个cleaner,用于在对象销毁时,使用cleaner进行堆外内存的回收
大家不用纠结页对齐,不理解可以忽略,我简单说下我的理解,不一定准确。页对齐与字节对齐的思想应该是一致的,即一条记录,如果在当前页上放不下的话,那就从下一个页开始存储,主要是防止在获取一条记录的时候,多加载页。考虑如果一条记录跨了两个页,那么加载这条记录需要加载两个页的数据,如果该记录干脆就在新的页上存储,加载该条记录时只需要加载一个页就可以了,但是当前的处理器一般在加载的时候都会同时加载相邻的页,所以页对齐的参数默认为false。
看完DirectByteBuf后,我们看一下Bits类中统计堆外内存大小时,做了什么事情,下面看一下Bits.reserveMemory方法的源码:
static void reserveMemory(long size, int cap) {
if (!memoryLimitSet && VM.isBooted()) {
maxMemory = VM.maxDirectMemory();
memoryLimitSet = true;
}
// optimist!
if (tryReserveMemory(size, cap)) {
return;
}
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
while (jlra.tryHandlePendingReference()) {
if (tryReserveMemory(size, cap)) {
return;
}
}
// trigger VM's Reference processing
System.gc();
// a retry loop with exponential back-off delays
// (this gives VM some time to do it's job)
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;
}
}
}
// no luck
throw new OutOfMemoryError("Direct buffer memory");
} finally {
if (interrupted) {
// don't swallow interrupts
Thread.currentThread().interrupt();
}
}
}
主要做了几件事:
- tryReserveMemory方法,尝试申请内存,这里只是与最大的堆外内存设置进行比对而已,看看还能不能申请,如果可以,则直接返回
private static boolean tryReserveMemory(long size, int cap) {
// -XX:MaxDirectMemorySize limits the total capacity rather than the
// actual memory usage, which will differ when buffers are page
// aligned.
long totalCap;
while (cap <= maxMemory - (totalCap = totalCapacity.get())) {
if (totalCapacity.compareAndSet(totalCap, totalCap + cap)) {
reservedMemory.addAndGet(size);
count.incrementAndGet();
return true;
}
}
return false;
}
注意的是这里比较的是cap,并非size,因为cap,即便size才是真正申请的物理内存空间大小,但是记录的时候是按照用户申请的cap大小进行比较的。堆外内存的大小可以通过-XX:MaxDirectMemorySize进行设置,默认与对大小一样。
- 如果目前已经达到了堆外内存的上限,则看一下引用队列中有没有对象已经释放了,如果有则进行释放。释放完成之后再次尝试申请
- 如果还没有足够的空间,那么就进行System.gc, 建议JVM进行一次gc
- 再次尝试申请,如果申请失败,就休眠一段时间,再次申请,休眠的时间依次为1,2,4,8,32,64,128,259毫秒,在经过8次循环之后还没有足够内存的话就抛出OOM
可以发现,当真正的堆外内存不足时,只能寄希望于:
- 引用队列中已经有值了,进行堆外内存的释放
- 项目进行gc,但是只是建议,即便有无用的对象,但是在规定的sleep时间内,仍然没有进行gc,也会抛出OOM(更何况,如果禁用了System.gc那就等着OOM了)
总结
DirectByteBuf借助了Reference内的守护线程handler处理不可达对象时进行内存的回收,在handler中调用Cleaner的clean方法,间接调用Deallocator的run方法,使用Unsafe进行回收。
DirectByteBuf的回收依赖堆的gc顺带回收,因此如果对象一不小心进入老年代,就只能等待full gc回收,如果申请堆外内存内存不足时,会尝试调用System.gc,但并不一定有效,如果等待一定时间还没有内存可用,则抛出OOM异常
最后
欢迎喜欢技术,喜欢讨论技术,喜欢交流问题的技术宅以及伪技术宅们关注微信公众号