什么是零拷贝?
- 所谓的零拷贝(Zero-copy)就是在操作数据时,不需要将数据 buffer 从一个内存区域拷贝到另一个内存区域,因此少了一次内存的拷贝,CPU 的效率就得到了提升
- 从 OS 层面上的零拷贝通常指避免 用户态(User-space) 与 内核态(Kernel-space) 之间来回拷贝数据。例如 Linux 提供的 mmap 系统调用,它可以将一段用户空间内存映射到内核空间,当映射成功后,用户对这块内存区域的修改可以直接反应到内核空间,同样,内核空间对这块区域的修改也直接反映到用户空间。正因为有这样的映射关系,我们就不需要再 用户态(User-space) 与 内核态(Kernel-space) 之间拷贝数据,提高数传输的效率
- 不过,Netty 中的零拷贝与上面所提到的 OS 层面的零拷贝不太一样,Netty 的零拷贝完全是再用户态(Java 层面)的,它的零拷贝更偏向于优化数据操作的概念
-
Netty 中的零拷贝主要体现在如下几方面:
- Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf,避免各个 ByteBuf 之间的拷贝
- 通过 wrap 操作,可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象,进而避免了拷贝操作
- ByteBuf 支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一存储区域的 ByteBuf,避免内存拷贝
- 通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统循环 write 方式导致的内存拷贝问题
通过 CompositeByteBuf 实现零拷贝
- 假设有一份协议的数据,它由头部和消息体组成,而头部和消息体分别存放在两个 ByteBuf 中,即:
ByteBuf header = ...
ByteBuf body = ...
- 我们在处理代码中,通常希望将 header 和 body 合并为一个 ByteBuf 方便处理,那么通常的做法是:
ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);
- 可以看到,我们将 header 和 body 都拷贝到了新的 allBuf 中,这无形的增加了两次额外的数据拷贝操作。下面看一下 CompositeByteBuf 是如何实现这样的需求的:
ByteBuf header = ...
ByteBuf body = ...
CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
compositeByteBuf.addComponents(true, header, body);
- 上面代码中,定义了一个 CompositeByteBuf 对象然后调用 addComponents 方法将 header 与 body 合并为一个逻辑上的 ByteBuf
public CompositeByteBuf addComponents(boolean increaseWriterIndex, ByteBuf... buffers);
CompositeByteBuf
通过 wrap 操作实现零拷贝
- 假设我们有一个 byte 数组,希望把它转换为 ByteBuf 对象以便后续操作,那么传统的做法时将 byte 数组写入到 ByteBuf 中,即:
byte[] bytes = ...
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytes);
- 显然这种方式也是有一个额外的拷贝操作,我们可以使用 Unpooled 的相关方法,包装这个 byte 数组来生成一个新的 ByteBuf 实例,而不需要进行拷贝操作
byte[] bytes = ...
ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
通过 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 byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);
- slice 方法产生的 header 和 body 的过程都是没有拷贝操作的,header 和 body 对象再内部其实是共享了 ByteBuf 的存储空间的不同部分而已
slice
通过 FileRegion 实现零拷贝
- 在 Netty 中使用 FileRegion 实现文件传输的零拷贝,不过在底层是依赖于 Java NIO 的 FileChannel.transfer 的零拷贝功能
- 弱使用最基础的 JAVA IO 实现一个文件的拷贝功能,那么使用传统的方式代码如下
public static void copyFile(String srcFile, String destFile) throws Exception {
byte[] temp = new byte[1024];
FileInputStream in = new FileInputStream(srcFile);
FileOutputStream out = new FileOutputStream(destFile);
int length;
while ((length = in.read(temp)) != -1) {
out.write(temp, 0, length);
}
in.close();
out.close();
}
- 上面是一个典型的读写二进制文件的代码实现,上面的代码中不断从源文件中读取指定长度的数据到 temp 数组中,然后再将 temp 中的内容写入目的文件,这样的拷贝操作对于大文件时,频繁的内存拷贝操作会消耗大量系统资源
- 下面看一下 JavaNIO 中的 FileChannel 如何实现零拷贝
public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
FileChannel srcFileChannel = srcFile.getChannel();
RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
FileChannel destFileChannel = destFile.getChannel();
long position = 0;
long count = srcFileChannel.size();
srcFileChannel.transferTo(position, count, destFileChannel);
}
- 可以看到使用 FileChannel 后可以直接将源文件的内容拷贝到目的地文件中,不需要再借助一个临时的 buffer,避免了不必要的内存操作
- 最后看下 Netty 中是怎么使用 FileRegion 来实现零拷贝传输一个文件的
@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
RandomAccessFile raf = null;
long length = -1;
try {
// 1. 通过 RandomAccessFile 打开一个文件.
raf = new RandomAccessFile(msg, "r");
length = raf.length();
} catch (Exception e) {
ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
return;
} finally {
if (length < 0 && raf != null) {
raf.close();
}
}
ctx.write("OK: " + raf.length() + '\n');
if (ctx.pipeline().get(SslHandler.class) == null) {
// SSL not enabled - can use zero-copy file transfer.
// 2. 调用 raf.getChannel() 获取一个 FileChannel.
// 3. 将 FileChannel 封装成一个 DefaultFileRegion
ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
} else {
// SSL enabled - cannot use zero-copy file transfer.
ctx.write(new ChunkedFile(raf));
}
ctx.writeAndFlush("\n");
}
- 可以看到第一步是通过 RandomAccessFile 打开了一个文件,然后 Netty 使用了 DefaultFileRegion 来封装一个 FileChannel,即:
new DefaultFileRegion(raf.getChannel(), 0, length)
- 当有了 FileRegion 后就可以通过它将文件的内容直接写入 Channel 中,而不需要像传统的做法,拷贝文件内容到临时 buffer,再将 buffer 写入 Channel,零拷贝操作对传输大文件有着很大的帮助
网友评论