美文网首页netty程序员Java学习笔记
堆外内存 之 DirectByteBuffer 详解

堆外内存 之 DirectByteBuffer 详解

作者: tomas家的小拨浪鼓 | 来源:发表于2017-08-09 23:04 被阅读2677次

    堆外内存

    堆外内存是相对于堆内内存的一个概念。堆内内存是由JVM所管控的Java进程内存,我们平时在Java中创建的对象都处于堆内内存中,并且它们遵循JVM的内存管理机制,JVM会采用垃圾回收机制统一管理它们的内存。那么堆外内存就是存在于JVM管控之外的一块内存区域,因此它是不受JVM的管控。

    在讲解DirectByteBuffer之前,需要先简单了解两个知识点

    java引用类型,因为DirectByteBuffer是通过虚引用(Phantom Reference)来实现堆外内存的释放的。

    PhantomReference 是所有“弱引用”中最弱的引用类型。不同于软引用和弱引用,虚引用无法通过 get() 方法来取得目标对象的强引用从而使用目标对象,观察源码可以发现 get() 被重写为永远返回 null。
    那虚引用到底有什么作用?其实虚引用主要被用来 跟踪对象被垃圾回收的状态,通过查看引用队列中是否包含对象所对应的虚引用来判断它是否 即将被垃圾回收,从而采取行动。它并不被期待用来取得目标对象的引用,而目标对象被回收前,它的引用会被放入一个 ReferenceQueue 对象中,从而达到跟踪对象垃圾回收的作用。
    关于java引用类型的实现和原理可以阅读之前的文章Reference 、ReferenceQueue 详解Java 引用类型简述

    关于linux的内核态和用户态

    • 内核态:控制计算机的硬件资源,并提供上层应用程序运行的环境。比如socket I/0操作或者文件的读写操作等
    • 用户态:上层应用程序的活动空间,应用程序的执行必须依托于内核提供的资源。
    • 系统调用:为了使上层应用能够访问到这些资源,内核为上层应用提供访问的接口。

    因此我们可以得知当我们通过JNI调用的native方法实际上就是从用户态切换到了内核态的一种方式。并且通过该系统调用使用操作系统所提供的功能。

    Q:为什么需要用户进程(位于用户态中)要通过系统调用(Java中即使JNI)来调用内核态中的资源,或者说调用操作系统的服务了?
    A:intel cpu提供Ring0-Ring3四种级别的运行模式,Ring0级别最高,Ring3最低。Linux使用了Ring3级别运行用户态,Ring0作为内核态。Ring3状态不能访问Ring0的地址空间,包括代码和数据。因此用户态是没有权限去操作内核态的资源的,它只能通过系统调用外完成用户态到内核态的切换,然后在完成相关操作后再有内核态切换回用户态。

    DirectByteBuffer ———— 直接缓冲

    DirectByteBuffer是Java用于实现堆外内存的一个重要类,我们可以通过该类实现堆外内存的创建、使用和销毁。



    DirectByteBuffer该类本身还是位于Java内存模型的堆中。堆内内存是JVM可以直接管控、操纵。
    而DirectByteBuffer中的unsafe.allocateMemory(size);是个一个native方法,这个方法分配的是堆外内存,通过C的malloc来进行分配的。分配的内存是系统本地的内存,并不在Java的内存中,也不属于JVM管控范围,所以在DirectByteBuffer一定会存在某种方式来操纵堆外内存。
    在DirectByteBuffer的父类Buffer中有个address属性:

        // Used only by direct buffers
        // NOTE: hoisted here for speed in JNI GetDirectBufferAddress
        long address;
    

    address只会被直接缓存给使用到。之所以将address属性升级放在Buffer中,是为了在JNI调用GetDirectBufferAddress时提升它调用的速率。
    address表示分配的堆外内存的地址。



    unsafe.allocateMemory(size);分配完堆外内存后就会返回分配的堆外内存基地址,并将这个地址赋值给了address属性。这样我们后面通过JNI对这个堆外内存操作时都是通过这个address来实现的了。

    在前面我们说过,在linux中内核态的权限是最高的,那么在内核态的场景下,操作系统是可以访问任何一个内存区域的,所以操作系统是可以访问到Java堆的这个内存区域的。
    Q:那为什么操作系统不直接访问Java堆内的内存区域了?
    A:这是因为JNI方法访问的内存区域是一个已经确定了的内存区域地质,那么该内存地址指向的是Java堆内内存的话,那么如果在操作系统正在访问这个内存地址的时候,Java在这个时候进行了GC操作,而GC操作会涉及到数据的移动操作[GC经常会进行先标志在压缩的操作。即,将可回收的空间做标志,然后清空标志位置的内存,然后会进行一个压缩,压缩就会涉及到对象的移动,移动的目的是为了腾出一块更加完整、连续的内存空间,以容纳更大的新对象],数据的移动会使JNI调用的数据错乱。所以JNI调用的内存是不能进行GC操作的。

    Q:如上面所说,JNI调用的内存是不能进行GC操作的,那该如何解决了?
    A:①堆内内存与堆外内存之间数据拷贝的方式(并且在将堆内内存拷贝到堆外内存的过程JVM会保证不会进行GC操作):比如我们要完成一个从文件中读数据到堆内内存的操作,即FileChannelImpl.read(HeapByteBuffer)。这里实际上File I/O会将数据读到堆外内存中,然后堆外内存再讲数据拷贝到堆内内存,这样我们就读到了文件中的内存。


        static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
            if (var1.isReadOnly()) {
                throw new IllegalArgumentException("Read-only buffer");
            } else if (var1 instanceof DirectBuffer) {
                return readIntoNativeBuffer(var0, var1, var2, var4);
            } else {
                // 分配临时的堆外内存
                ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
    
                int var7;
                try {
                    // File I/O 操作会将数据读入到堆外内存中
                    int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
                    var5.flip();
                    if (var6 > 0) {
                        // 将堆外内存的数据拷贝到堆外内存中
                        var1.put(var5);
                    }
    
                    var7 = var6;
                } finally {
                    // 里面会调用DirectBuffer.cleaner().clean()来释放临时的堆外内存
                    Util.offerFirstTemporaryDirectBuffer(var5);
                }
    
                return var7;
            }
        }
    

    而写操作则反之,我们会将堆内内存的数据线写到对堆外内存中,然后操作系统会将堆外内存的数据写入到文件中。
    ② 直接使用堆外内存,如DirectByteBuffer:这种方式是直接在堆外分配一个内存(即,native memory)来存储数据,程序通过JNI直接将数据读/写到堆外内存中。因为数据直接写入到了堆外内存中,所以这种方式就不会再在JVM管控的堆内再分配内存来存储数据了,也就不存在堆内内存和堆外内存数据拷贝的操作了。这样在进行I/O操作时,只需要将这个堆外内存地址传给JNI的I/O的函数就好了。

    DirectByteBuffer堆外内存的创建和回收的源码解读

    堆外内存分配

        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 {
                // 通过unsafe.allocateMemory分配堆外内存,并返回堆外内存的基地址
                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对象用于跟踪DirectByteBuffer对象的垃圾回收,以实现当DirectByteBuffer被垃圾回收时,堆外内存也会被释放
            cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
            att = null;
        }
    

    Bits.reserveMemory(size, cap) 方法

        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();
    
            // retry while helping enqueue pending Reference objects
            // which includes executing pending Cleaner(s) which includes
            // Cleaner(s) that free direct buffer memory
            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();
                }
            }
        }
    

    该方法用于在系统中保存总分配内存(按页分配)的大小和实际内存的大小。

    其中,如果系统中内存( 即,堆外内存 )不够的话:

            final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
    
            // retry while helping enqueue pending Reference objects
            // which includes executing pending Cleaner(s) which includes
            // Cleaner(s) that free direct buffer memory
            while (jlra.tryHandlePendingReference()) {
                if (tryReserveMemory(size, cap)) {
                    return;
                }
            }
    

    jlra.tryHandlePendingReference()会触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被JVM垃圾回收的DirectBuffer对象的堆外内存释放。
    因为在Reference的静态代码块中定义了:

            SharedSecrets.setJavaLangRefAccess(new JavaLangRefAccess() {
                @Override
                public boolean tryHandlePendingReference() {
                    return tryHandlePending(false);
                }
            });
    

    如果在进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则

            // trigger VM's Reference processing
            System.gc();
    

    System.gc()会触发一个full gc,当然前提是你没有显示的设置-XX:+DisableExplicitGC来禁用显式GC。并且你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。
    所以在后面打代码中,会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给JVM足够的时间去完成full gc操作。如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。



    注意,这里之所以用使用full gc的很重要的一个原因是:System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存.
    DirectByteBuffer对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为冰山对象.
    我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题。( 并且堆外内存多用于生命期中等或较长的对象 )
    如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc – JVM参数DisableExplicitGC)。

    总的来说,Bits.reserveMemory(size, cap)方法在可用堆外内存不足以分配给当前要创建的堆外内存大小时,会实现以下的步骤来尝试完成本次堆外内存的创建:
    ① 触发一次非堵塞的Reference#tryHandlePending(false)。该方法会将已经被JVM垃圾回收的DirectBuffer对象的堆外内存释放。
    ② 如果进行一次堆外内存资源回收后,还不够进行本次堆外内存分配的话,则进行 System.gc()。System.gc()会触发一个full gc,但你需要知道,调用System.gc()并不能够保证full gc马上就能被执行。所以在后面打代码中,会进行最多9次尝试,看是否有足够的可用堆外内存来分配堆外内存。并且每次尝试之前,都对延迟等待时间,已给JVM足够的时间去完成full gc操作。
    注意,如果你设置了-XX:+DisableExplicitGC,将会禁用显示GC,这会使System.gc()调用无效。
    ③ 如果9次尝试后依旧没有足够的可用堆外内存来分配本次堆外内存,则抛出OutOfMemoryError("Direct buffer memory”)异常。

    那么可用堆外内存到底是多少了?,即默认堆外存内存有多大:
    ① 如果我们没有通过-XX:MaxDirectMemorySize来指定最大的堆外内存。则👇
    ② 如果我们没通过-Dsun.nio.MaxDirectMemorySize指定了这个属性,且它不等于-1。则👇
    ③ 那么最大堆外内存的值来自于directMemory = Runtime.getRuntime().maxMemory(),这是一个native方法

    JNIEXPORT jlong JNICALL
    Java_java_lang_Runtime_maxMemory(JNIEnv *env, jobject this)
    {
        return JVM_MaxMemory();
    }
    
    JVM_ENTRY_NO_ENV(jlong, JVM_MaxMemory(void))
      JVMWrapper("JVM_MaxMemory");
      size_t n = Universe::heap()->max_capacity();
      return convert_size_t_to_jlong(n);
    JVM_END
    

    其中在我们使用CMS GC的情况下也就是我们设置的-Xmx的值里除去一个survivor的大小就是默认的堆外内存的大小了。

    堆外内存回收

    Cleaner是PhantomReference的子类,并通过自身的next和prev字段维护的一个双向链表。PhantomReference的作用在于跟踪垃圾回收过程,并不会对对象的垃圾回收过程造成任何的影响。
    所以cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); 用于对当前构造的DirectByteBuffer对象的垃圾回收过程进行跟踪。
    当DirectByteBuffer对象从pending状态 ——> enqueue状态时,会触发Cleaner的clean(),而Cleaner的clean()的方法会实现通过unsafe对堆外内存的释放。




    👆虽然Cleaner不会调用到Reference.clear(),但Cleaner的clean()方法调用了remove(this),即将当前Cleaner从Cleaner链表中移除,这样当clean()执行完后,Cleaner就是一个无引用指向的对象了,也就是可被GC回收的对象。

    thunk方法:


    通过配置参数的方式来回收堆外内存

    同时我们可以通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小,当使用达到了阈值的时候将调用System.gc()来做一次full gc,以此来回收掉没有被使用的堆外内存。

    堆外内存那些事

    使用堆外内存的原因

    • 对垃圾回收停顿的改善
      因为full gc 意味着彻底回收,彻底回收时,垃圾收集器会对所有分配的堆内内存进行完整的扫描,这意味着一个重要的事实——这样一次垃圾收集对Java应用造成的影响,跟堆的大小是成正比的。过大的堆会影响Java应用的性能。如果使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
    • 在某些场景下可以提升程序I/O操纵的性能。少去了将数据从堆内内存拷贝到堆外内存的步骤。

    什么情况下使用堆外内存

    • 堆外内存适用于生命周期中等或较长的对象。( 如果是生命周期较短的对象,在YGC的时候就被回收了,就不存在大内存且生命周期较长的对象在FGC对应用造成的性能影响 )。
    • 直接的文件拷贝操作,或者I/O操作。直接使用堆外内存就能少去内存从用户内存拷贝到系统内存的操作,因为I/O操作是系统内核内存和设备间的通信,而不是通过程序直接和外设通信的。
    • 同时,还可以使用 池+堆外内存 的组合方式,来对生命周期较短,但涉及到I/O操作的对象进行堆外内存的再使用。( Netty中就使用了该方式 )

    堆外内存 VS 内存池

    • 内存池:主要用于两类对象:①生命周期较短,且结构简单的对象,在内存池中重复利用这些对象能增加CPU缓存的命中率,从而提高性能;②加载含有大量重复对象的大片数据,此时使用内存池能减少垃圾回收的时间。
    • 堆外内存:它和内存池一样,也能缩短垃圾回收时间,但是它适用的对象和内存池完全相反。内存池往往适用于生命期较短的可变对象,而生命期中等或较长的对象,正是堆外内存要解决的。

    堆外内存的特点

    • 对于大内存有良好的伸缩性
    • 对垃圾回收停顿的改善可以明显感觉到
    • 在进程间可以共享,减少虚拟机间的复制

    堆外内存的一些问题

    • 堆外内存回收问题,以及堆外内存的泄漏问题。这个在上面的源码解析已经提到了
    • 堆外内存的数据结构问题:堆外内存最大的问题就是你的数据结构变得不那么直观,如果数据结构比较复杂,就要对它进行串行化(serialization),而串行化本身也会影响性能。另一个问题是由于你可以使用更大的内存,你可能开始担心虚拟内存(即硬盘)的速度对你的影响了。

    参考

    http://lovestblog.cn/blog/2015/05/12/direct-buffer/
    http://www.infoq.com/cn/news/2014/12/external-memory-heap-memory
    http://www.jianshu.com/p/85e931636f27
    圣思园《并发与Netty》课程

    相关文章

      网友评论

      • 靈08_1024:您好,有个问题:我最近在看周志明的《深入JVM》,里面对本机内存,即DirectMemroySize的默认大小,说是与Java堆一样大的(Xmx)。而您在上边说,还要去除一个Survivor,是不是记错了?
      • 倪伟_2131:有两个错误
        1. JNI调用的native方法 并不能从用户态切换到内核态。
        2. DirectBuffer 并不能进程间共享。
      • 布吉刀:通过JNI调用的native方法实际上就是从用户态切换到了内核态的一种方式。
        布吉刀:@岁岁年年3 这个
      • myz7656:瞎说

      本文标题:堆外内存 之 DirectByteBuffer 详解

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