《Netty系列五》- Nio DirectByteBuf堆外内存的回收策略

该部分内容其实和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对象是堆内一个对象,该对象中存在一个地址,该地址代表的是在堆外内存申请的内存的起始地址,如下图所示:


image.png

对堆外内存的操作,实践上都是对堆内存储的起始地址的操作。当堆内对象变的不可达时,顺便回收其对应的堆外内存,那么就存在两个问题:

  1. 如何知道堆对象何时不可达?
  2. 对于一个大的堆外内存对象,在堆内表示是非常小的(其实就address,offset等几个字段值而已,这就是所谓的冰山对象,占用堆内内存非常少,在其背后其实存在一大块堆外内存),如果该堆内对象在经过几次young gc后进入了老年代,即便该对象变为不可达,由于没有触发full gc,也不会触发其回收操作

如何知道对象何时不可达

要想知道Java中堆对象何时被回收,那就有必要学习一下Java中的引用类型。在Java中存在着4中引种类型,强引用,软引用,弱引用,虚引用。

  1. 强引用:就是我们一般使用对象的方式,例如通过new构造一个对象,堆内对象只要还存在强引用指向它,它就不会被JVM回收。
  2. 软引用:相对于强引用较弱,一般在内存不足时才会回收,该类引用指向的对象应该是可有可无的,有会提高程序的效率,没有也不会引起程序故障。因此软引用适用于做缓存对象
  3. 弱引用:当只有弱引用可达对象时,gc会立即回收对象
  4. 虚引用: 对于软引用,弱引用通过get方法都是可以获取到其引用对象的,但是虚引用通过get方法是获取到的永远都是null

在DirectByteBuf中通过虚引用来判断堆内对象是否已经不可达,在JVM中会启动一个专门的线程handler来处理不可达对象,在将不可达对象添加到引用队列前,会判断该对象是否为Cleanner,如果时,则使用Cleaner进行回收工作。
在DirectByteBuf中,是通过其成员变量cleaner进行堆外内存的释放,看下Cleanner类的定义

public class Cleaner extends PhantomReference
 
 

从定义中可以看出,Cleaner类继承了PhantomReference虚引用类,也就是说Cleaner也是一个虚引用对象。满足上面所讲的所有虚引用的特性。因此在Cleaner类内部维护了一个静态的成员变量ReferenceQueue,定义如下:

private static final ReferenceQueue 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);
        }

主要进行两个操作:

  1. 使用unsafe根据堆外内存的起始地址释放堆外内存
  2. 根据当前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 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 q = r.queue;
        if (q != ReferenceQueue.NULL) q.enqueue(r);
        return true;
    }
 
 

从上述的逻辑中,可以发现,执行了Cleaner的clean方法,也就是说DirectByteBuf是在这里被回收的。简单分析一下代码,没有深入研究,如果有问题,还请指出。

  1. 首先看pending是否有值,pending是jvm进行赋值的,当对象可达性变为不可达时会赋值到pending上。
  2. 如果pending有值,则判断是否为Cleaner类型,如果不是则赋值c为null
  3. discoverd应该时下一个不可达的对象,赋值给pending
  4. 如果pending不存在值,等待pending有值
  5. 如果c不为null,说明时Cleanr对象,直接执行clean方法,在该方法中调用了Deallocator任务的run方法,使用unsafe进行对外内存的释放。
  6. 将pending加入到引用队列。

从上面的逻辑中,我们可以发现,在handler线程中执行了Cleaner的clean方法,从而达到了回收的效果。但是并没有用到ReferenceQueue的特性

冰山对象进入老年代无法释放怎么办

如果表示堆外内存的堆内对象一不小心进入了老年代,由于其占用的堆内堆存很少,又可能项目的堆内内存使用比较稳定,没有触发full gc,那么即便该对象已经不可达,但也没有办法释放对应的堆外内存,碰到这种情况应该怎么办?在Java中并没有别的方法,只能调用System.gc去让JVM进行gc了。然而System.gc存在几个问题:

  1. 很多公司为了避免程序员依赖迷信该方法,会禁用System.gc
-XX:+DisableExplicitGC
  1. 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进行返回。在构造方法中

  1. 首先计算一下需要申请的内存大小,这里涉及到是否页对齐(不了解可以先忽略),其中size为真正申请的内存大小,cap为需要申请的内存大小, size>=cap
  2. 在Bits类中记录堆外内存的使用情况,这里稍后再看
  3. 通过unsafe申请size大小的内存,并返回内存的起始地址。如果申请失败,在Bits中减去相应记录
  4. 通过unsafe初始化内存内容,擦除内存上的信息
  5. 根据是否页对齐,重新计算申请内存的其实地址
  6. 创建一个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();
            }
        }
    }

主要做了几件事:

  1. 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进行设置,默认与对大小一样

  1. 如果目前已经达到了堆外内存的上限,则看一下引用队列中有没有对象已经释放了,如果有则进行释放。释放完成之后再次尝试申请
  2. 如果还没有足够的空间,那么就进行System.gc, 建议JVM进行一次gc
  3. 再次尝试申请,如果申请失败,就休眠一段时间,再次申请,休眠的时间依次为1,2,4,8,32,64,128,259毫秒,在经过8次循环之后还没有足够内存的话就抛出OOM

可以发现,当真正的堆外内存不足时,只能寄希望于:

  1. 引用队列中已经有值了,进行堆外内存的释放
  2. 项目进行gc,但是只是建议,即便有无用的对象,但是在规定的sleep时间内,仍然没有进行gc,也会抛出OOM(更何况,如果禁用了System.gc那就等着OOM了)

总结

DirectByteBuf借助了Reference内的守护线程handler处理不可达对象时进行内存的回收,在handler中调用Cleaner的clean方法,间接调用Deallocator的run方法,使用Unsafe进行回收。
DirectByteBuf的回收依赖堆的gc顺带回收,因此如果对象一不小心进入老年代,就只能等待full gc回收,如果申请堆外内存内存不足时,会尝试调用System.gc,但并不一定有效,如果等待一定时间还没有内存可用,则抛出OOM异常

最后

欢迎喜欢技术,喜欢讨论技术,喜欢交流问题的技术宅以及伪技术宅们关注微信公众号


qrcode_for_gh_5580beb3cba1_430.jpg

你可能感兴趣的:(《Netty系列五》- Nio DirectByteBuf堆外内存的回收策略)