参考链接:https://www.leahy.club/archives/netty-zero-copy
关于ZeroCopy底层原理可以参见【网络编程】零拷贝的底层实现原理
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提供了多种实现:
- HeapByteBuf: 直接在堆内存分配
- DirectByteBuf:直接在内存区域分配而不是堆内存
- CompositeByteBuf:组合Buffer
Direct Buffers:
直接在内存区域分配空间,而不是在堆内存中分配。如果使用传统的堆内存分配,当我们需要将数据通过socket发送的时候,就需要从堆内存拷贝到直接内存,然后再由直接内存拷贝到网卡接口层。
Netty提供的直接Buffer,直接将数据分配到内存空间,从而避免了数据的拷贝,实现了零拷贝。
Composite Buffers:
传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,我们需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用Netty提供的组合ByteBuf,就可以避免这样的操作,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
通过wrap操作实现零拷贝:
例如我们有一个 byte 数组, 我们希望将它转换为一个 ByteBuf 对象, 以便于后续的操作, 那么传统的做法是将此 byte 数组拷贝到 ByteBuf 中, 即:
byte[] bytes = ...
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytes);
显然这样的方式也是有一个额外的拷贝操作的, 我们可以使用 Unpooled 的相关方法, 包装这个 byte 数组, 生成一个新的 ByteBuf 实例, 而不需要进行拷贝操作. 上面的代码可以改为:
byte[] bytes = ...
ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
可以看到, 我们通过 Unpooled.wrappedBuffer
方法来将 bytes 包装成为一个 UnpooledHeapByteBuf 对象, 而在包装的过程中, 是不会有拷贝操作的. 即最后我们生成的生成的 ByteBuf 对象是和 bytes 数组共用了同一个存储空间, 对 bytes 的修改也会反映到 ByteBuf 对象中.
通过slice操作实现零拷贝:
slice 操作和 wrap 操作刚好相反, Unpooled.wrappedBuffer
可以将多个 ByteBuf 合并为一个, 而 slice 操作可以将一个 ByteBuf 切片
为多个共享一个存储区域的 ByteBuf 对象.
ByteBuf 提供了两个 slice 操作方法:
public ByteBuf slice();
public ByteBuf slice(int index, int length);
不带参数的 slice
方法等同于 buf.slice(buf.readerIndex(), buf.readableBytes())
调用, 即返回 buf 中可读部分的切片. 而 slice(int index, int length)
方法相对就比较灵活了, 我们可以设置不同的参数来获取到 buf 的不同区域的切片.
下面的例子展示了 ByteBuf.slice
方法的简单用法:
ByteBuf byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);
用 slice
方法产生 header 和 body 的过程是没有拷贝操作的, header 和 body 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已. 即:
对于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对于Byte[]可以使用wrap和slice方法来实现零拷贝。
-
Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题。
参考链接: