☕【Java 深层系列】「技术盲区」让我们一起探索一下 Netty(Java) 底层的“零拷贝 Zero-Copy”技术(上)
Netty 的零拷贝
Netty 中的零拷贝与我们传统理解的零拷贝不太一样。
传统的零拷贝指的是数据传输过程中,不需要 CPU 进行数据的拷贝。主要是数据在用户空间与内核中间之间的拷贝。
传统意义的零拷贝
Zero-Copy describes computer operations in which the CPU does not perform the task of copying data from one memory area to another.
在发送数据的时候,传统的实现方式是:
File.read(bytes)
Socket.send(bytes)
这种方式需要四次数据拷贝和四次上下文切换:
数据从磁盘读取到内核的 read buffer
数据从内核缓冲区拷贝到用户缓冲区
数据从用户缓冲区拷贝到内核的 socket buffer
数据从内核的 socket buffer 拷贝到网卡接口的缓冲区
明显上面的第二步和第三步是没有必要的,通过 java 的 FileChannel.transferTo 方法,可以避免上面两次多余的拷贝(当然这需要底层操作系统支持)。
调用 transferTo,数据从文件由 DMA 引擎拷贝到内核 read buffer
接着 DMA 从内核 read buffer 将数据拷贝到网卡接口 buffer
上面的两次操作都不需要 CPU 参与,所以就达到了零拷贝。
Netty 中的零拷贝
Netty 中也用到了 FileChannel.transferTo 方法,所以 Netty 的零拷贝也包括上面将的操作系统级别的零拷贝,除此之外,在 ByteBuf 的实现上,Netty 也提供了零拷贝的一些实现。
关于 ByteBuffer,Netty 提供了两个接口:
ByteBuf
ByteBufHolder
对于 ByteBuf,Netty 提供了多种实现:
Heap ByteBuf:直接在堆内存分配
Direct ByteBuf:直接在内存区域分配而不是堆内存
CompositeByteBuf:组合 Buffer
Direct Buffers(直接内存)
直接在内存区域分配空间,而不是在堆内存中分配。
如果使用传统的堆内存分配,当我们需要将数据通过 socket 发送的时候,就需要从堆内存拷贝到直接内存,然后再由直接内存拷贝到网卡接口层。
Netty 提供的直接 Buffer,直接将数据分配到内存空间,从而避免了数据的拷贝,实现了零拷贝。
堆外内存
如果在 JVM 内部执行 I/O 操作时,必须将数据拷贝到堆外内存,才能执行系统调用。VM 语言都会存在的问题,那么为什么操作系统不能直接使用 JVM 堆内存进行 I/O 的读写呢?
主要有两点原因:
操作系统并不感知 JVM 的堆内存,而且 JVM 的内存布局与操作系统所分配的是不一样的,操作系统并不会按照 JVM 的行为来读写数据。
同一个对象的内存地址随着 JVM GC 的执行可能会随时发生变化,例如 JVM GC 的过程中会通过压缩来减少内存碎片,这就涉及对象移动的问题了。
Netty 在进行 I/O 操作时都是使用的堆外内存,可以避免数据从 JVM 堆内存到堆外内存的拷贝。
JDK 告诉我们,NIO 操作并不适合直接在堆上操作。由于 heap 受到 GC 的直接管理,在 IO 写入的过程中 GC 可能会进行内存空间整理,这导致了一次 IO 写入的内存地址不完整。
JNI(Java Native Inteface)在调用 IO 操作的 C 类库时,规定了写入时地址不能失效,这就导致了不能在 heap 上直接进行 IO 操作。在 IO 操作的时候禁止 GC 也是一个选项,如果 IO 时间过长,那么则可能会引起堆空间溢出。
Composite Buffers
传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个 size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
FileChannel.transferTo 的使用
Netty 中使用了 FileChannel 的 transferTo 方法,该方法依赖于操作系统实现零拷贝。
总结
Netty 的零拷贝体现在三个方面:
Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。
如果使用传统的堆内存(HEAP BUFFERS)进行 Socket 读写,JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入 Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。
Netty 的文件传输采用了 transferTo 方法,它可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环 write 方式导致的内存拷贝问题。
关于堆外内存的回收
堆外内存的回收其实依赖于我们的 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 对应的堆外内存块。
为什么要主动调用 System.gc
System.gc()会对新生代的老生代都会进行内存回收,这样会比较彻底地回收,DirectByteBuffer 对象以及他们关联的堆外内存.
DirectByteBuffer 对象本身其实是很小的,但是它后面可能关联了一个非常大的堆外内存,因此我们通常称之为冰山对象。
做 ygc 的时候会将新生代里的不可达的 DirectByteBuffer 对象及其堆外内存回收了,但是无法对 old 里的 DirectByteBuffer 对象及其堆外内存进行回收,这也是我们通常碰到的最大的问题.
如果有大量的 DirectByteBuffer 对象移到了 old,但是又一直没有做 cms gc 或者 full gc,而只进行 ygc,那么我们的物理内存可能被慢慢耗光,但是我们还不知道发生了什么,因为 heap 明明剩余的内存还很多。
资源学习
版权声明: 本文为 InfoQ 作者【浩宇天尚】的原创文章。
原文链接:【http://xie.infoq.cn/article/8df6e2803f279541c12d45e1e】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论