Netty 源码分析 —— NIO 基础(五)之零拷贝与其它源码解读


我准备战斗到最后,不是因为我勇敢,是我想见证一切。 --双雪涛《猎人》

[TOC]
Thinking

  1. 一个技术,为什么要用它,解决了那些问题?
  2. 如果不用会怎么样,有没有其它的解决方法?
  3. 对比其它的解决方案,为什么最终选择了这种,都有何利弊?
  4. 你觉得项目中还有那些地方可以用到,如果用了会带来那些问题?
  5. 这些问题你又如何去解决的呢?

本文基于Netty 4.1.45.Final-SNAPSHOT

1、NIO堆外内存与零拷贝

NIO堆外内存

​ 在上述NIO Buffer 讲解中,我们隐约的提到过为什么要使用Direct Buffer小节中提到过直接内存(堆外内存)与堆内存(Non - Direct Buffer)的区别:

这里会涉及到 Java 的内存模型

Direct Buffer:

  • 所分配的内存不在 JVM 堆上, 不受 GC 的管理.(但是 Direct Buffer 的 Java 对象是由 GC 管理的(会将内存地址映射到一个标记上), 因此当发生 GC, 对象被回收时, Direct Buffer 也会被释放)
  • 因为 Direct Buffer 不在 JVM 堆上分配, 因此 Direct Buffer 对应用程序的内存占用的影响就不那么明显(实际上还是占用了这么多内存, 但是 JVM 不好统计到非 JVM 管理的内存.)
  • 申请和释放 Direct Buffer 的开销比较大. 因此正确的使用 Direct Buffer 的方式是在初始化时申请一个 Buffer, 然后不断复用此 buffer, 在程序结束后才释放此 buffer.
  • 使用 Direct Buffer 时, 当进行一些底层的系统 IO 操作时, 效率会比较高, 因为此时 JVM 不需要拷贝 buffer 中的内存到中间临时缓冲区中.

Non-Direct Buffer:

  • 直接在 JVM 堆上进行内存的分配, 本质上是 byte[] 数组的封装.
  • 因为 Non-Direct Buffer 在 JVM 堆中, 因此当进行操作系统底层 IO 操作中时, 会将此 buffer 的内存复制到中间临时缓冲区中. 因此 Non-Direct Buffer 的效率就较低.

总结对比:

  • 之所以使用堆外内存,是为了避免每次使用buffe如对象时,都会将此对象复制到中间林是缓冲区中,因此Non-Direct Buffer效率会非常低下。
  • 堆外内存(直接内存--direct byte buffer)则可以直接使用,避免了对象的复制,提高了效率。

基于上述总结,我们先看一下下面创建Buffer 的两种方法的代码:

    @Test
    public void test01() throws Exception {
        FileInputStream in = new FileInputStream("src/main/resources/data/DirectorBuffer.txt");
        FileOutputStream out = new FileOutputStream("src/main/resources/data/DirectorBuffer-out.txt");

        // 获取文件Channel
        FileChannel inChannel = in.getChannel();
        FileChannel outChannel = out.getChannel();

        // 普通获取Buffer
        ByteBuffer allocate = ByteBuffer.allocate(1024);

        // 获取 堆外内存 Buffer
        ByteBuffer allocateDirect = ByteBuffer.allocateDirect(1024);

        // 从源码 分析两种的区别。
        int count = inChannel.read(allocate);
        while (count != -1) {
            log.info("read :{}", count);
            allocate.flip();

            outChannel.write(allocate);
            allocate.clear();
            // 防止死循环
            count = inChannel.read(allocate);
        }
        inChannel.close();
        outChannel.close();
    }
}
  • ByteBuffer.allocate(1024);跟随进入源码:

  •     public static ByteBuffer allocate(int capacity) {
            if (capacity < 0)
                throw new IllegalArgumentException();
            return new HeapByteBuffer(capacity, capacity);
        }
    
        HeapByteBuffer(int cap, int lim) {            // package-private
    
            super(-1, 0, lim, cap, new byte[cap], 0);
            /*
            hb = new byte[cap];
            offset = 0;
            */
        }
    
  • 该方法是直接new HeapByteBuffer 对象,在堆内存中直接申请字节数组内存空间用于存储数据。

    • 直接在 JVM 堆上进行内存的分配, 本质上是 byte[] 数组的封装.
    • 但是在每次使用时,都会设计到copy操作,性能会低下。

  • ByteBuffer.allocateDirect(1024)创建堆外内存。

  • // Allocates a new direct byte buffer. 分配一个新的直接字节缓冲区
    public static ByteBuffer allocateDirect(int capacity) {
            return new DirectByteBuffer(capacity);
        }
    
        DirectByteBuffer(int cap) {                   // package-private
    
            super(-1, 0, cap, cap);
            boolean pa = VM.isDirectMemoryPageAligned(); // 《1》
            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); // 《2》
            } 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; // 《3》
            }
            cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
            att = null;
        }
    
  • 从源码中看出,其实都是用的NEW关键字,宏观角度上两种方式创建的对象都是在堆内存中的。但是new DirectByteBuffer(capacity)则是基于堆外内存(直接内存 Direct)。在上述源码中导入的包设计到

    import sun.misc.Cleaner;
    import sun.misc.Unsafe;
    import sun.misc.VM;
    

    从这个角度也可以看出,这些以sun开头的类(JDK中为本地方法,非开源的。)

  • 《1》处,VM.isDirectMemoryPageAligned()本地方法的调用。

  • 《2》处:调用Unsafe方法用于分配内存。unsafe.setMemory(base, size, (byte) 0)设置内存。(这些方法都是native 本地方法。)

  • 《3》处:将分配到的内存地址 映射到该标记。(该标记为底层父类Buffer 中维护的一个成员变量 long address --->因为在堆外内存中生成的数据,必须有个映射地址,不然JVM 并不能找到该对象,因为堆外内存并不受JVM管理。)

    • // Used only by direct buffers 只适用于直接缓冲区
      // NOTE: hoisted here for speed in JNI GetDirectBufferAddress ->  static native long getDirectBufferAddress(Buffer var0);
      // 为了提高速度,将其悬挂在JNI GetDirectBufferAddress中
      long address;
      

图解Direct Memory/Non Direct Memory

具体的堆外内存映射关系
  • 上图所示:提到两个问题
    • JVM管理内的堆内存中的对象具体是怎么进行I/O操作的。
    • 为何要引入这种机制,使用堆外内存呢?
    • 那么在ByteBuffer创建的堆外内存对象是否被JVM管理呢?GC是否会回收该类对象呢?

问题

JVM管理内的堆内存中的对象具体是怎么进行I/O操作的。

​ 当我们使用创建对象时,大多是new出来的对象都是存放在堆内存中的,受jvm管理。受GC的管理。

当对内存中的对象进行I/O操作时,JVM会将堆内中的对象数据完整的copy一份到堆外内存(物理内存)中,再由该物理内存中的对象进行具体的I/O操作。

这样一来,在堆内的对象或者数据需要进行I/O操作时,都需要进行一步copy操作。(这里就引入了 NIO中的领copy操作了。后续详解。)

为何要引入这种机制,使用堆外内存呢?

​ 就是为了性能。

  1. 使用堆外内存,减少了垃圾回收机制(GC会暂停其他的工作)
  2. 加快了I/O操作的进度
    1. 堆内在flush到远程时,会先复制到直接内存中,然后在发送。
    2. 而堆外内存(本身就是物理机内存)几乎省略了这步。

那么在ByteBuffer创建的堆外内存对象是否被JVM管理呢?GC是否会回收该类对象呢?

​ 使用ByteBuffer创建的直接缓冲对象实际上是受JVM管理的。其他使用Unsafe创建的堆外内存对象则完全由自己控制。

ByteBuffer allocateDirect = ByteBuffer.allocateDirect(1024);

当这段代码执行会在堆外内存中占用1k的内存,Java堆内只会占用一个对象的指针引用大小。(顶层父类中维护的成员变量 address

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

堆外的这1k的空间只有当bb对象被回收时,才会被回收,这里会发现一个明显的不对称现象,就是堆外可能占用了很多,而堆内没占用多少,导致还没触发GC,那就很容易出现Direct Memory造成物理内存耗光。(物理内存可以扩展到很大很大。这里提及到的只是极端情况。)

*DirectByteBuffer**分配出去的内存其实也是由**GC**负责回收的,而不像**Unsafe**是完全自行管理的***,Hotspot在GC时会扫描DirectByteBuffer对象是否有引用,如没有则同时也会回收其占用的堆外内存。

使用堆外内存与对象池都能减少GC的暂停时间,这是它们唯一的共同点。生命周期短的可变对象,创建开销大,或者生命周期虽长但存在冗余的可变对象都比较适合使用对象池。生命周期适中,或者复杂的对象则比较适合由GC来进行处理。然而,中长生命周期的可变对象就比较棘手了,堆外内存则正是它们的菜。

堆外内存的好处

  1. 可以扩展至更大的内存空间。比如超过1TB甚至比主存还大的空间;

  2. 理论上能减少GC暂停时间;

  3. 可以在进程间共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现;

  4. 它的持久化存储可以支持快速重启,同时还能够在测试环境中重现生产数据

2、零拷贝 zero copy

​ 上面探讨的所有内容,其实已经完整的带出了零拷贝。

ByteBuffer创建的直接缓冲区就是利用零拷贝,来提高性能的。

堆外内存中的数据进行I/O操作时,不用将数据拷贝到堆外内存中去,所以就节省了一次拷贝操作(不用进行拷贝操作),所以成为零拷贝。

Netty 充分的利用此种操作,用来大大的提升了性能与速度。(高性能)


3、内存映射 MappedByteBuffer

​ 用于直接内存映射操作。深入浅出MappedByteBuffer

4、Selector 选择器源码解析

​ 深入浅出NIO之Selector实现原理

//TODO

JNI(Java Native Interface)

引用:

​ JAVA堆内内存、堆外内存

本文仅供笔者本人学习,有错误的地方还望指出,一起进步!望海涵!

转载请注明出处!

欢迎关注我的公共号,无广告,不打扰。不定时更新Java后端知识,我们一起超神。


qrcode.jpg

——努力努力再努力xLg

加油!

你可能感兴趣的:(Netty 源码分析 —— NIO 基础(五)之零拷贝与其它源码解读)