美文网首页工作生活
《Netty系列五》- Nio DirectByteBuf堆外内

《Netty系列五》- Nio DirectByteBuf堆外内

作者: 逍遥无极 | 来源:发表于2019-07-03 18:04 被阅读0次

    该部分内容其实和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<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);
            }
    

    主要进行两个操作:

    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<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是在这里被回收的。简单分析一下代码,没有深入研究,如果有问题,还请指出。

    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堆外内

        本文链接:https://www.haomeiwen.com/subject/ajnfhctx.html