专栏首页开发架构二三事jvm 堆外堆内浅析

jvm 堆外堆内浅析

buffer一般是用于字节缓存的,先buffer一段,然后再flush出去。一般在网络交互中用的比较多,比如java的nio框架,如netty等。

java的堆内与堆外buffer

HeapByteBuffer与DirectByteBuffer,在原理上,前者可以看出分配的buffer是在heap区域的,其实真正flush到远程的时候会先拷贝得到直接内存,再做下一步操作 (考虑细节还会到OS级别的内核区直接内存),其实发送静态文件最快速的方法是通过OS级别的send_file,只会经过OS一个内核拷贝,而不会来回拷贝;在NIO的框架下,很多框架会采用 DirectByteBuffer来操作,这样分配的内存不再是在java heap上,而是在C heap上,经过性能测试,可以得到非常快速的网络交互,在大量的网络交互下,一般速度会比HeapByteBuffer 要快速好几倍。

  • java中分配HeapByteBuffer的方法是:
ByteBuffer.allocate(int capacity);参数大小为字节的数量

这种方式内存是在Java Heap中分配的。

  • java中分配DirectByteBuffer的方法是:
ByteBuffer.allocateDirect(int capacity);

堆外内部最终分配内存是通过 Unsafe.allocateMemory() 来实现的,不是在Java Heap中分配的。这个unsafe默认情况下java代码是没有能力可以调用到的,不过你可以通过反射的手段得到实例进而做操作, 当然你需要保证的是程序的稳定性,既然叫unsafe的,就是告诉你这不是安全的,其实并不是不安全,而是交给程序员来操作,它可能会因为程序员的能力而导致不安全,而并非它本身不安全。java9好像移除了Sun.misc.Unsafe API。

  • Netty Java版本用Java参照jemalloc的思想 在 Unsafe.allocateMemory() 的基础上自己实现的。Netty中用的最多的是ByteBufAllocator实现。作为一个跨很多平台的,兼容性高的底层项目,Netty甚至会考虑到目标平台根本就没有 Unsafe.allocateMemory 功能,这个时候就是用 Java Heap了。

堆外快还是堆内快

普遍的说法是堆外内存会快一些,原因主要有:

  • 直接内存 可以禁掉GC
  • 在java进行IO读写的时候 java的bytes需要做一个copy copy到c堆的bytes 直接内存没有这一步(注意这个copy不是 用户态和内核态的那个,java堆是-Xmx指定的,C堆是jvm的)
  • 堆外内存优势在 IO 操作上,对于网络 IO,使用 Socket 发送数据时,能够节省堆内存到堆外内存的数据拷贝,所以性能更高。看过 Netty 源码的同学应该了解,Netty 使用堆外内存池来实现零拷贝技术。对于磁盘 IO 时,也可以使用内存映射,来提升性能。

堆外内存的回收

堆外最底层是通过malloc方法申请的,但是这块内存需要进行手动释放,JVM并不会进行回收,幸好Unsafe提供了另一个接口freeMemory可以对申请的堆外内存进行释放,可以使用 -XX:MaxDirectMemorySize 参数指定堆外内存最大大小。

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));
        //在这个方法内会校验是否指定了最大堆外内存,以及是否有足够可用的堆外内存空间,如果不足会进行full gc
        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 = Cleaner.create(this, new Deallocator(base, size, cap));
        att = null;
    }

初始化时会创建cleaner,这个cleaner是一个PhantomReference类型的,会在System.gc()时进行回收,这个cleaner内部提供有供堆外内存回收的clean方法,通过这个方法可以手动进行堆外内存回收,是堆外内存回收的关键。

System.gc发生部分

Bits.reserveMemory方法内会判断是否需要执行System.gc()即full gc:

// These methods should be called whenever direct memory is allocated or
    // freed.  They allow the user to control the amount of direct memory
    // which a process may access.  All sizes are specified in bytes.
    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();
        ..........

Cleaner

代码:

关于Cleaner我们主要从这几点来理解:

1.继承自PhantomReference,关于PhantomReference类型我们就得来聊一聊java中的引用类型了:

jvm有四种引用: strong soft weak phantom final主要的区别体现在gc上的处理:

  • Strong类型,也就是正常使用的类型,不需要显示定义,只要没有任何引用就可以回收
  • SoftReference类型,如果一个对象只剩下一个soft引用,在jvm内存不足的时候会将这个对象进行回收
SoftReference<Object> soft = new SoftReference<>(new Object());
System.gc();
System.out.println(soft.get());

结果为: java.lang.Object@73ae9565

soft类型由于内存还充足,不会被回收。

  • WeakReference类型,如果对象只剩下一个weak引用,那gc的时候就会回收。和SoftReference都可以用来实现cache
WeakReference<Object> weak = new WeakReference<>(new Object());
WeakReference<String> weakString = new WeakReference<>("abc");
System.gc();
System.out.println(weak.get());
System.out.println(weakString.get());

结果为: 
     null
     abc

weak类型在发生gc时就进行回收。weakString没被回收是引用常量池持有对"abc"的引用。

  • PhantomReference类型,可以用来实现类似Object.finalize功能
PhantomReference<Object> phantom = new PhantomReference<>(new Object(), new ReferenceQueue<>());
System.gc();
System.out.println(phantom.get());

结果为:null

PhantomReference的get方法总是返回null,因此无法访问对应的引用对象;其意义在于说明一个对象已经进入finalization阶段,可以被gc回收。

  • FinalReference类型,主要使用在finalize方法使用上,我们知道在调用System.gc时可能会调用该对象的finalize方法,这里不再展开,见笨神的说明:https://www.infoq.cn/article/jvm-source-code-analysis-finalreference/

2.Cleaner定义成PhantomReference类型的目的是什么(引用自:http://lovestblog.cn/blog/2015/05/12/direct-buffer/)?

  • 上面我们知道,在申请堆外内存不足时会进行System.gc,既然要调用System.gc,那肯定是想通过触发一次gc操作来回收堆外内存,不过我想先说的是堆外内存不会对gc造成什么影响(这里的System.gc除外),但是堆外内存的回收其实依赖于我们的gc机制。
  • 首先我们要知道在java层面和我们在堆外分配的这块内存关联的只有与之关联的DirectByteBuffer对象了,它记录了这块内存的基地址以及大小,那么既然和gc也有关,那就是gc能通过操作DirectByteBuffer对象来间接操作对应的堆外内存了。
  • DirectByteBuffer对象在创建的时候关联了一个PhantomReference,说到PhantomReference它其实主要是用来跟踪对象何时被回收的,它不能影响gc决策,但是gc过程中如果发现某个对象除了只有PhantomReference引用它之外,并没有其他的地方引用它了,那将会把这个引用放到java.lang.ref.Reference.pending队列里。
  • 在gc完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理,而DirectByteBuffer关联的PhantomReference是PhantomReference的一个子类,在最终的处理里会通过Unsafe的free接口来释放DirectByteBuffer对应的堆外内存块。

JDK里ReferenceHandler的实现:

private static class ReferenceHandler extends Thread {

        ReferenceHandler(ThreadGroup g, String name) {
            super(g, name);
        }

        public void run() {
            for (;;) {

                Reference r;
                synchronized (lock) {
                    if (pending != null) {
                        r = pending;
                        Reference rn = r.next;
                        pending = (rn == r) ? null : rn;
                        r.next = r;
                    } else {
                        try {
                            lock.wait();
                        } catch (InterruptedException x) { }
                        continue;
                    }
                }

                // Fast path for cleaners
                if (r instanceof Cleaner) {
                    ((Cleaner)r).clean();
                    continue;
                }

                ReferenceQueue q = r.queue;
                if (q != ReferenceQueue.NULL) q.enqueue(r);
            }
        }
    }

可见如果pending为空的时候,会通过lock.wait()一直等在那里,其中唤醒的动作是在jvm里做的,当gc完成之后会调用jvm方法VMGCOperation::doit_epilogue(),在方法末尾会调用lock的notify操作,至于pending队列什么时候将引用放进去的,其实是在gc的引用处理逻辑中放进去的。

3. Cleaner的ReferenceQueue属性和clean方法 从代码上可以看出,在调用create方法创建Cleaner时会将Cleaner的引用放入ReferenceQueue,在调用Cleaner的clean方法时会将自身引用从ReferenceQueue中移除,同时回收堆外内存。

这里可以思考一个问题,如果一直不调用Cleaner的clean方法,系统内存不是会爆?

其实通过上面的分析之后,这个问题我们不难回答,上面有讲过在创建DirectByteBuffer时会调用Bits.reserveMemory方法,该方法内部会判断内存情况,不足时会调用System.gc进行full gc操作。full gc时会扫描所有引用,包括DirectByteBuffer引用,查到DirectByteBuffer引用只有Cleaner引用指向它时(Cleaner又是PhantomReference类型的)会按照上面讲到的方式进行回收Cleaner和堆外内存。当然,这些需要满足一个前提条件是jvm没有添加-XX:+DisableExplicitGC参数来禁用System.gc。

堆外内存回收

  1. System.gc会对新生代的老生代都会进行内存回收,这样会比较彻底地回收DirectByteBuffer对象以及他们关联的堆外内存,我们dump内存发现DirectByteBuffer对象本身其实是很小的,但是它后面 可能关联了一个非常大的堆外内存,因此我们通常称之为『冰山对象』,我们做ygc的时候会将新生代里的不可达的DirectByteBuffer对象及其堆外内存回收了,但是无法对old里的DirectByteBuffer对象 及其堆外内存进行回收,这也是我们通常碰到的最大的问题,如果有大量的DirectByteBuffer对象移到了old,但是又一直没有做cms gc或者full gc,而只进行ygc,那么我们的物理内存可能被慢慢耗光, 但是我们还不知道发生了什么,因为heap明明剩余的内存还很多(前提是我们禁用了System.gc)。关于System.gc参考:http://lovestblog.cn/blog/2015/05/07/system-gc/
  2. 在没有禁用Syste.gc时,gc扫描DirectByteBuffer引用,查看有无引用指向它,如果只有Cleaner(PhantomReference类型)指向时会将会把这个引用放到java.lang.ref.Reference.pending队列里, 在gc完毕的时候通知ReferenceHandler这个守护线程去执行一些后置处理(具体见上文)
  3. 堆外内存常配合使用System GC使用,如果这些DirectByteBuffer对象大部分都移到了old,但是一直没有触发CMS GC或者Full GC,那么悲剧将会发生,因为你的物理内存被他们耗尽了,因此为了避免这种悲剧的发生,通过-XX:MaxDirectMemorySize来指定最大的堆外内存大小, 当使用达到了阈值的时候将调用System.gc来做一次full gc,以此来回收掉没有被使用的堆外内存。
  4. 其他情况引发的full gc对堆外内存回收也是有用的,使用Syste.gc只是为是更好地配合堆外回收。
  5. 手动回收,通过调用Cleaner的clean方法。DirectByteBuffer继承了DirectBuffer,在DirectBuffer中提供了获取cleaner的方法。

堆外的其他问题

关于堆外的介绍,先看一段R大在知乎上的解释(https://www.zhihu.com/question/57374068/answer/152691891):

Java NIO中的direct buffer(主要是DirectByteBuffer)其实是分两部分的:

Java        |      native
                   |
 DirectByteBuffer  |     malloc'd
 [    address   ] -+-> [   data    ]
                   |

其中 DirectByteBuffer 自身是一个Java对象,在Java堆中;而这个对象中有个long类型字段address,记录着一块调用 malloc() 申请到的native memory。所以回到题主的问题:

  1. DirectBuffer 属于堆外存,那应该还是属于用户内存,而不是内核内存?DirectByteBuffer自身是(Java)堆内的,它背后真正承载数据的buffer是在(Java)堆外——native memory中的。这是 malloc() 分配出来的内存,是用户态的。
  2. FileChannel 的read(ByteBuffer dst)函数,write(ByteBuffer src)函数中,如果传入的参数是HeapBuffer类型,则会临时申请一块DirectBuffer,进行数据拷贝, 而不是直接进行数据传输,这是出于什么原因?

题主看的是OpenJDK的 sun.nio.ch.IOUtil.write(FileDescriptor fd, ByteBuffer src, long position, NativeDispatcher nd) 的实现对不对:

static int write(FileDescriptor fd, ByteBuffer src, long position,NativeDispatcher nd)throws IOException
{
    if (src instanceof DirectBuffer)
        return writeFromNativeBuffer(fd, src, position, nd);

    // Substitute a native buffer
    int pos = src.position();
    int lim = src.limit();
    assert (pos <= lim);
    int rem = (pos <= lim ? lim - pos : 0);
    ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
    try {
        bb.put(src);
        bb.flip();
        // Do not update src until we see how many bytes were written
        src.position(pos);

        int n = writeFromNativeBuffer(fd, bb, position, nd);
        if (n > 0) {
            // now update src
            src.position(pos + n);
        }
        return n;
    } finally {
        Util.offerFirstTemporaryDirectBuffer(bb);
    }
}

这里其实是在迁就OpenJDK里的HotSpot VM的一点实现细节。HotSpot VM里的GC除了CMS之外都是要移动对象的,是所谓“compacting GC”。如果要把一个Java里的 byte[] 对象的引用传给native代码,让native代码直接访问数组的内容的话,就必须要保证native代码在访问的时候这个 byte[] 对象不能被移动,也就是要被“pin”(钉)住。可惜HotSpot VM出于一些取舍而决定不实现单个对象层面的object pinning,要pin的话就得暂时禁用GC——也就等于把整个Java堆都给pin住。HotSpot VM对JNI的Critical系API就是这样实现的。这用起来就不那么顺手。所以 Oracle/Sun JDK / OpenJDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的I/O可能是一个很慢的操作。于是它就先把 HeapByteBuffer 背后的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的native memory去,这个拷贝会涉及 sun.misc.Unsafe.copyMemory() 的调用,背后是类似 memcpy() 的实现。这个操作本质上是会在整个拷贝过程中暂时不允许发生GC的,虽然实现方式跟JNI的Critical系API不太一样。(具体来说是 Unsafe.copyMemory() 是HotSpot VM的一个intrinsic方法,中间没有safepoint所以GC无法发生)。然后数据被拷贝到native memory之后就好办了,就去做真正的I/O,把 DirectByteBuffer 背后的native memory地址传给真正做I/O的函数。这边就不需要再去访问Java对象去读写要做I/O的数据了。就这样。

注意事项

  • 使用堆外内存时,尽量多进行手动回收方式,依赖System.gc毕竟会引发full gc,对系统影响还是比较大的。Netty 中的堆外内存池就是使用反射来实现手动回收方式进行回收的。
  • 在使用一些第三方的使用堆外内存的框架时,不要使用-XX:+DisableExplicitGC参数禁用System.gc
  • 堆外内存泄漏的问题在很多时候很难避免,需要慎用。

参考

  • R大知乎解释:https://www.zhihu.com/question/57374068
  • 笨神对DirectBuffer的解释:http://lovestblog.cn/blog/2015/05/12/direct-buffer/
  • 笨神对System.gc的解释:http://lovestblog.cn/blog/2015/05/07/system-gc/
  • 笨神对finalreference的解释:https://www.infoq.cn/article/jvm-source-code-analysis-finalreference/

本文分享自微信公众号 - 开发架构二三事(gh_d6f166e26398),作者:两个小灰象

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-09-20

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Elasticsearch源码分析四之JNA与swap浅析

    来看一段org.elasticsearch.bootstrap.Bootstrap#setup中的代码:

    开发架构二三事
  • 浅析内存屏障以及在java中的应用

    程序在运行时内存实际的访问顺序和程序代码编写的访问顺序不一定一致,这就是内存乱序访问。内存乱序访问行为出现的理由是为了提升程序运行时的性能。这种内存乱序问题主要...

    开发架构二三事
  • shiro实战之常见问题整理

    在调用subject.login时会调用UserRealm的doGetAuthenticationInfo方法。

    开发架构二三事
  • 从0到1起步-跟我进入堆外内存的奇妙世界

    堆外内存一直是Java业务开发人员难以企及的隐藏领域,究竟他是干什么的,以及如何更好的使用呢?那就请跟着我进入这个世界吧。

    小程故事多
  • android内存优化

    刚入门的童鞋肯能都会有一个疑问,Java不是有虚拟机了么,内存会自动化管理,我们就不必要手动的释放资源了,反正系统会给我们完成。其实Java中没有指针的概念,但...

    xiangzhihong
  • 服务优化指南

    架构师小秘圈
  • 服务器又报错了?教你如何优雅排查!

    可以从以下几个方面监控CPU的信息: (1)中断; (2)上下文切换; (3)可运行队列; (4)CPU 利用率。

    黄泽杰
  • App性能优化浅谈

    用户1130025
  • 一篇超实用的服务异常处理指南

    可以从以下几个方面监控CPU的信息: (1)中断; (2)上下文切换; (3)可运行队列; (4)CPU 利用率。

    lyb-geek
  • 【JS】324- JS中的内存管理(中高级前端必备)

    像C语言这样的底层语言一般都有底层的内存管理接口,比如 malloc()和free()用于分配内存和释放内存。而对于JavaScript来说,会在创建变量(对象...

    pingan8787

扫码关注云+社区

领取腾讯云代金券