解读java中的零拷贝
一、在IO过程中,哪些步骤进行了拷贝?哪些地方零拷贝?
当数据从一个地方传输到另一个地方时,通常会涉及到多个步骤,其中包含了数据拷贝的过程,这些步骤被称为IO过程。在传输数据的过程中,CPU需要从某个内存区域复制数据到另一个特定区域。这种复制数据的过程,也就是数据拷贝,会占用CPU周期和内存带宽,因此会影响IO过程的效率。
零拷贝(英语:Zero-copy)技术是一种优化IO过程效率的解决方案,它可以在数据传输时减少或者避免数据拷贝过程,从而提高数据传输效率。零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率,同时减少了上下文切换所带来的开销。
在IO过程中,哪些步骤进行了拷贝,哪些地方零拷贝?我们相信大家在以往的学习中,或多或少听说过以下这些组件、框架中的零拷贝技术:Kafka、Netty、RocketMQ和Nginx等。
二、Linux I/O机制及零拷贝介绍
IO中断与DMA
- IO中断
当程序执行IO操作(例如读取文件或从网络接收数据)时,它需要等待这些操作完成。这种等待过程就被称为IO中断,因为程序的执行被中断了。在这个过程中,CPU需要响应和参与,从而降低了程序的效率。
IO中断- DMA
当用户进程需要读取磁盘数据时,需要发起CPU中断并发起IO请求。每次IO中断都会导致CPU的上下文切换,给CPU带来负担。
为了解决这个问题,出现了 ——DMA。
DMA(Direct Memory Access,直接内存存取)是所有现代电脑的重要特性。它允许不同速度的硬件设备进行通信,而不需要依赖CPU进行大量的中断操作。DMA控制器接管了数据读写请求,减轻了CPU的负担,让CPU能够更高效地工作。许多硬件系统都使用DMA,包括硬盘控制器、绘图显卡、网卡和声卡等设备。
DMA IODMA IO
DMA的IO流程:
- DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区。
- 用户进程将内核缓冲区的数据复制到用户空间。
这两个过程都是阻塞的。
三、Linux支持的(常见)零拷贝
1. 减少数据拷贝次数(mmap + write)
DMA加载磁盘数据到kernel buffer后,应用程序缓冲区(application buffers)和内核缓冲区(kernel buffer)进行映射,数据再应用缓冲区和内核缓存区的改变就能省略。
mp_buf = mmap(file_fd, len);
write(socket_fd, tmp_buf, len);
mmap+write
基于 mmap + write 系统调用的零拷贝方式,整个拷贝过程会发生 4 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝。用户程序读写数据的流程如下:
1. 用户进程通过 mmap() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换到内核态(kernel space)。
2. 将用户进程的内核空间的读缓冲区(read buffer)与用户空间的缓存区(user buffer)进行内存地址映射。
3. CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
4. 上下文从内核态(kernel space)切换回用户态(user space),mmap 系统调用执行返回。
5. 用户进程通过 write() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换到内核态(kernel space)。
6. CPU 将读缓冲区(read buffer)中的数据拷贝到网络缓冲区(socket buffer)。
7. CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
8. 上下文从内核态(kernel space)切换回用户态(user space),write 系统调用执行返回。
mmap 主要用于提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB。一个 5 KB 的文件将会映射占用 8 KB 内存,从而浪费 3 KB 内存。尽管 mmap 拷贝减少了 1 次 CPU 拷贝,提高了效率,但也存在一些隐藏的问题。当 mmap 一个文件时,如果这个文件被另一个进程截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止。SIGBUS 默认会杀死进程并产生一个 coredump,从而可能终止服务器。
2. 减少数据拷贝次数(sendfile)
sendfile 系统调用在 Linux 内核版本 2.1 中被引入,目的是简化通过网络在两个通道之间进行的数据传输过程。sendfile 系统调用的引入,不仅减少了 CPU 拷贝的次数,还减少了上下文切换的次数,它的伪代码如下:
sendfile(socket_fd, file_fd, len);
通过 sendfile 系统调用,数据可以直接在内核空间内部进行 I/O 传输,从而省去了数据在用户空间和内核空间之间的来回拷贝。与 mmap 内存映射方式不同的是, sendfile 调用中 I/O 数据对用户空间是完全不可见的。也就是说,这是一次完全意义上的数据传输过程。
sendfile基于 sendfile 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,1 次 CPU 拷贝和 2 次 DMA 拷贝,用户程序读写数据的流程如下:
1.用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
2.CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
3.CPU 将读缓冲区(read buffer)中的数据拷贝到网络缓冲区(socket buffer)。
4.CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
5.上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。
相比较于 mmap 内存映射的方式,sendfile 少了 2 次上下文切换,但是仍然有 1 次 CPU 拷贝操作。sendfile 存在的问题是用户程序不能对数据进行修改,而只是单纯地完成了一次数据传输过程。
3. 减少数据拷贝次数(sendfile + DMA gather copy)
Linux 2.4 版本的内核对 sendfile 系统调用进行修改,为 DMA 拷贝引入了 gather 操作。它将内核空间(kernel space)的读缓冲区(read buffer)中对应的数据描述信息(内存地址、地址偏移量)记录到相应的网络缓冲区( socket buffer)中,由 DMA 根据内存地址、地址偏移量将数据批量地从读缓冲区(read buffer)拷贝到网卡设备中,这样就省去了内核空间中仅剩的 1 次 CPU 拷贝操作,sendfile 的伪代码如下:
sendfile(socket_fd, file_fd, len);
在硬件的支持下,sendfile 拷贝方式不再从内核缓冲区的数据拷贝到 socket 缓冲区,取而代之的仅仅是缓冲区文件描述符和数据长度的拷贝,这样 DMA 引擎直接利用 gather 操作将页缓存中数据打包发送到网络中即可,本质就是和虚拟内存映射的思路类似。
sendfile+DMA gather copy基于 sendfile + DMA gather copy 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换、0 次 CPU 拷贝以及 2 次 DMA 拷贝。用户程序读写数据的流程如下:
1.用户进程通过 sendfile() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
2.CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
3.CPU 把读缓冲区(read buffer)的文件描述符(file descriptor)和数据长度拷贝到网络缓冲区(socket buffer)。
4.基于已拷贝的文件描述符(file descriptor)和数据长度,CPU 利用 DMA 控制器的 gather/scatter 操作直接批量地将数据从内核的读缓冲区(read buffer)拷贝到网卡进行数据传输。
5.上下文从内核态(kernel space)切换回用户态(user space),sendfile 系统调用执行返回。
sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
4. 减少数据拷贝次数(splice)
sendfile 只适用于将数据从文件拷贝到 socket 套接字上,同时需要硬件的支持,这也限定了它的使用范围。Linux 在 2.6.17 版本引入 splice 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。splice 的伪代码如下:
splice(fd_in, off_in, fd_out, off_out, len, flags);
splice 系统调用可以在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline),从而避免了两者之间的 CPU 拷贝操作。
splice基于 splice 系统调用的零拷贝方式,整个拷贝过程会发生 2 次上下文切换,0 次 CPU 拷贝以及 2 次 DMA 拷贝,用户程序读写数据的流程如下:
1.用户进程通过 splice() 函数向内核(kernel)发起系统调用,上下文从用户态(user space)切换为内核态(kernel space)。
2.CPU 利用 DMA 控制器将数据从主存或硬盘拷贝到内核空间(kernel space)的读缓冲区(read buffer)。
3.CPU 在内核空间的读缓冲区(read buffer)和网络缓冲区(socket buffer)之间建立管道(pipeline)。
4.CPU 利用 DMA 控制器将数据从网络缓冲区(socket buffer)拷贝到网卡进行数据传输。
5.上下文从内核态(kernel space)切换回用户态(user space),splice 系统调用执行返回。
splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。
参考: 阿里二面:什么是mmap?
四、Java支持哪些零拷贝?
Linux提供的领拷贝技术,Java并不全都支持,目前只支持两种:内存映射(mmap)和发送文件(sendfile)。
1. NIO提供的内存映射 MappedByteBuffer
首先需要说明的是,在Java NIO中,Channel(通道)相当于操作系统中的内核缓冲区,可能是读缓冲区,也可能是网络缓冲区。而Buffer则相当于操作系统中的用户缓冲区。
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
.getChannel()
.map(FileChannel.MapMode.READ_ONLY, 0, len);
NIO中的FileChannel.map()方法是一个非常有用的方法,它采用了操作系统中的内存映射方式,底层实现是调用Linux mmap()。该方法将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射,这种方式适合读取大文件。此外,该方法还能够对文件内容进行更改,因此也可以用于写入大文件。
需要注意的是,虽然该方法可以实现零拷贝,但如果要通过SocketChannel发送,则仍然需要CPU进行数据拷贝。此外,MappedByteBuffer只能通过调用FileChannel的map()方法来获取,没有其他方式。FileChannel.map()是一个抽象方法,在 FileChannelImpl.c 中有具体的实现,它的map0()方法是通过调用Linux内核的mmap API实现的。
在使用MappedByteBuffer时,需要注意一些细节。首先,mmap的文件映射会在进行full gc时才会被释放,因此需要谨慎使用。同时,在调用close()方法时需要手动清除内存映射文件,可以反射调用sun.misc.Cleaner方法。
2. NIO提供的sendfile
- FileChannel.transferTo() 方法可以直接将当前通道内容传输到另一个通道,而无需涉及到Buffer的任何操作。在NIO中,Buffer可以是JVM堆内存,也可以是堆外内存。但无论哪种方式,它们都是操作系统内核空间的内存。
- transferTo() 方法的实现方式是通过系统调用 sendfile(),当然这是 Linux 中的系统调用。
//使用sendfile:读取磁盘文件,并网络发送
FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open(sa);
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);
ZeroCopyFile实现文件复制
class ZeroCopyFile {
public void copyFile(File src, File dest) {
try (FileChannel srcChannel = new FileInputStream(src).getChannel();
FileChannel destChannel = new FileInputStream(dest).getChannel()) {
srcChannel.transferTo(0, srcChannel.size(), destChannel);
} catch (IOException e) {
e.printStackTrace();
}
}
}
注意:Java NIO提供的FileChannel.transferTo和transferFrom并不能保证一定能使用零拷贝。实际上,能否使用零拷贝取决于操作系统是否提供了sendfile等零拷贝系统调用。如果操作系统提供这样的系统调用,则这两个方法可以通过它们来充分利用零拷贝的优势;否则,它们本身无法实现零拷贝。
3. Kafka中的零拷贝
在 Kafka 中,生产者将数据存到 Broker,消费者从 Broker 读取数据。这两个过程都使用了操作系统层面的零拷贝技术。具体来说,生产者将数据通过 mmap 文件映射实现顺序的快速写入到 Broker 中。而消费者则通过 sendfile 技术,将磁盘文件读取到操作系统内核缓冲区中,然后直接将其转移到 socket buffer 中进行网络发送。
RocketMQ和Kafka的比较
- RocketMQ选择了mmap+write这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输。这种方式将磁盘文件直接映射到内存中,从而避免了不必要的数据拷贝。RocketMQ的实现方式使其在小型消息传递场景中表现出色。
- Kafka则采用了sendfile这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。这种方式通过将磁盘文件直接传输到网络套接字缓冲区中,从而避免了不必要的数据拷贝。Kafka的实现方式使其在大型消息传递场景中表现出色。
值得注意的是,Kafka的索引文件使用的是mmap+write方式,数据文件使用的是sendfile方式。这种混合实现方式可以在同时具有高性能和高吞吐量的情况下,提供更好的存储效率。
消息队列 | 零拷贝方式 | 优点 | 缺点 |
---|---|---|---|
RocketMQ | mmap + write | 适用于小块文件传输;如果调用频繁,效率很高 | 不能很好的利用DMA方式,会比sendfile多消耗CPU,内存安全性控制复杂,需要避免JVM Crash问题 |
Kafka | sendfile | 可以利用DMA方式,消耗CPU较少,大块文件传输效率高,无内存安全问题 | 小块文件效率低于mmap方式,只能是BIO传输,不能使用NIO方式 |
4. Netty中的零拷贝
Netty中的Zero-copy与我们之前提到的OS层面的Zero-copy略有不同,Netty的Zero-copy完全是在用户态(Java层面)实现的,它更多地偏向于优化数据操作。相比于操作系统层面的零拷贝技术,Netty的Zero-copy技术更多地使用在应用层面,通过减少数据拷贝来提高数据操作的效率。
在Netty中,Zero-copy技术体现在以下几个方面:
- Netty提供了CompositeByteBuf类,它可以将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝。这种方式类似于将分散的多个ByteBuf通过引用连接起来,从而形成一个逻辑上的大区域。在实际数据读取时,还是会去各自每一小块上读取,但是通过这种方式可以减少数据拷贝,从而提高数据操作的效率。
- 通过wrap操作,我们可以将byte[]数组、ByteBuf、ByteBuffer等包装成一个Netty ByteBuf对象,从而避免了拷贝操作。这个操作比较简单,通过Unpooled.wrappedBuffer方法将bytes包装成为一个UnpooledHeapByteBuf对象,在包装的过程中是不会有拷贝操作的。因此,最后生成的ByteBuf对象和bytes数组共用了同一个存储空间,对bytes的修改也会反映到ByteBuf对象中。
- ByteBuf支持slice操作,可以将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝。slice恰好是将一整块区域划分成逻辑上独立的小区域,在读取每个逻辑小区域时,实际会去按slice(int index, int length) index和length去读取原内存buffer的数据。这种方式可以在一定程度上减少数据拷贝,提高数据操作的效率。
- 通过FileRegion包装的FileChannel.tranferTo实现文件传输,可以直接将文件缓冲区的数据发送到目标Channel,避免了传统的循环write方式导致的内存拷贝问题。这种操作是操作系统级别的零拷贝,可以进一步减少数据拷贝,提高数据操作的效率。
👉 Netty中的 CompositeByteBuf 可以实现零拷贝。
Netty提供的 CompositeByteBuf 类型是一种可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf 的工具。它类似于用一个链表将分散的多个 ByteBuf 通过引用连接起来,从而形成一个逻辑上的大区域。在实际数据读取时,仍然会去各自的每一小块上读取,但是通过这种方式可以减少数据拷贝,从而提高数据操作的效率。
使用 CompositeByteBuf 时,我们可以通过 addComponents()
方法将多个子缓冲区添加到 CompositeByteBuf 中。第一个参数 true
指定是否要将所有子缓冲区的内容合并到一个连续的内存块中(在这里我们将其设置为 true
)。如果设置为 false
,则子缓冲区的内容将保持原样。
最后,我们将 CompositeByteBuf 写入网络套接字中。这将自动将所有子缓冲区的内容写入套接字,而无需将它们复制到一个连续的内存块中。
使用 CompositeByteBuf 的好处是它可以减少不必要的数据拷贝,从而提高数据操作的效率。它是一种非常有用的工具,特别是在处理大规模数据时,可以显著提高性能。
// 创建两个ByteBuf
ByteBuf buf1 = Unpooled.buffer(10);
ByteBuf buf2 = Unpooled.buffer(20);
// 写入一些数据到buf1和buf2中
// 创建CompositeByteBuf并将buf1和buf2添加到其中
CompositeByteBuf compBuf = Unpooled.compositeBuffer();
compBuf.addComponents(true, buf1, buf2);
// 将CompositeByteBuf写入网络套接字中
Channel channel = ...; // 获取网络套接字Channel
channel.write(compBuf);
👉 b. Netty中使用wrap机制实现的零拷贝
"wrap"机制是Netty中实现零拷贝的一种方式。它允许将一个原始的字节数组或ByteBuffer包装成一个ByteBuf,而无需将数据复制到新的缓冲区中。这种技术可以在网络传输时大大减少数据的复制,从而提高性能和减少内存使用。
下面是一个简单的使用wrap机制的示例,它将一个字节数组包装成一个ByteBuf并将其写入套接字中:
byte[] data = ...; // 获取待发送的数据
Channel channel = ...; // 获取网络套接字Channel
// 将字节数组包装成一个ByteBuf
ByteBuf buf = Unpooled.wrappedBuffer(data);
// 将ByteBuf写入网络套接字中
channel.write(buf);
在上面的代码中,我们使用Unpooled.wrappedBuffer()方法将字节数组包装成一个ByteBuf。这不会创建一个新的缓冲区,而是返回一个包装了原始字节数组的ByteBuf。然后我们将这个ByteBuf写入网络套接字中,而无需将数据复制到一个新的缓冲区中。
需要注意的是,使用wrap机制的一个限制是,在原始字节数组或ByteBuffer被修改时,它所包装的ByteBuf也会被修改。因此,在使用wrap机制时需要确保原始字节数组或ByteBuffer不会被修改,或者在修改它们时确保对应的ByteBuf也被正确地更新。
fastJson2 中使用warp机制实现的的零拷贝
/*
* Package private constructor which shares value array for speed.
* this constructor is always expected to be called with share==true.
* a separate constructor is needed because we already have a public
* String(char[]) constructor that makes a copy of the given char[].
*/
String(char[] value, boolean share) {
// assert share : "unshared not supported";
this.value = value;
}
对比平时使用的构造方法
/**
* Allocates a new {@code String} so that it represents the sequence of
* characters currently contained in the character array argument. The
* contents of the character array are copied; subsequent modification of
* the character array does not affect the newly created string.
*
* @param value
* The initial value of the string
*/
public String(char value[]) {
this.value = Arrays.copyOf(value, value.length);
}
👉 c. Netty中使用slice机制实现零拷贝
在Netty中,通过slice
操作也可以实现零拷贝。
slice
操作可以从一个大的ByteBuf
中创建一个新的ByteBuf
,该新的ByteBuf
仍然引用原始的ByteBuf
的内存,但是它只表示原始ByteBuf
的一部分数据。这意味着在使用slice
操作时不需要将数据复制到一个新的缓冲区中。
以下是一个具体的示例代码,它使用slice
操作将一个大的ByteBuf
切分成多个小的ByteBuf
,而不涉及数据的复制:
ByteBuf bigBuf = ...; // 获取大的ByteBuf
int chunkSize = 1024; // 每个小的ByteBuf的大小
// 将大的ByteBuf切分成多个小的ByteBuf
List<ByteBuf> chunks = new ArrayList<>();
for (int i = 0; i < bigBuf.capacity(); i += chunkSize) {
int length = Math.min(chunkSize, bigBuf.capacity() - i);
ByteBuf chunk = bigBuf.slice(i, length);
chunks.add(chunk);
}
// 将小的ByteBuf写入网络套接字中
Channel channel = ...; // 获取网络套接字Channel
for (ByteBuf chunk : chunks) {
channel.write(chunk);
}
在上面的代码中,我们将一个大的ByteBuf
切分成多个小的ByteBuf
,每个小的ByteBuf
的大小为chunkSize
。我们使用slice
操作从大的ByteBuf
中创建每个小的ByteBuf
。这不会复制数据,而是返回一个新的ByteBuf
,它与原始ByteBuf
共享相同的内存块。
最后,我们将所有小的ByteBuf
写入网络套接字中,这样就可以在不涉及数据复制的情况下进行零拷贝的网络传输。需要注意的是,使用slice
操作时需要确保原始ByteBuf
的生命周期不会比切分出的小的ByteBuf
更短,否则可能会导致内存泄漏等问题。
👉 d. Netty中使用FileRegion实现零拷贝
在Netty中,通过使用FileRegion
可以实现零拷贝的文件传输。
FileRegion
是Netty中一个专门用于进行文件传输的接口。它可以在传输文件时使用操作系统提供的零拷贝机制,从而避免在用户空间和内核空间之间进行数据的复制。
以下是一个具体的示例代码,它使用FileRegion
将一个文件写入到网络套接字中,而不涉及数据的复制:
File file = ...; // 获取待发送的文件
Channel channel = ...; // 获取网络套接字Channel
// 创建一个DefaultFileRegion,用于将文件传输到网络套接字中
FileRegion region = new DefaultFileRegion(file.getChannel(), 0, file.length());
// 将文件传输到网络套接字中
channel.write(region);
在上面的代码中,我们使用DefaultFileRegion
类将一个文件传输到网络套接字中。DefaultFileRegion
类的构造函数需要一个FileChannel
对象和文件的起始位置和长度,它将使用操作系统提供的零拷贝机制将文件传输到网络套接字中。最后,我们将FileRegion
对象写入网络套接字中。
需要注意的是,使用FileRegion
时需要确保文件的生命周期不会比FileRegion
对象更短,否则可能会导致内存泄漏等问题。此外,使用FileRegion
的限制是,它只能用于文件传输,而不能用于传输内存中的数据。
网友评论