Java 与零拷贝

零拷贝是由操作系统实现的,使用 Java 中的零拷贝抽象类库在支持零拷贝的操作系统上运行才会实现零拷贝,如果在不支持零拷贝的操作系统上运行,并不会提供零拷贝的功能。

简述内核态和用户态

Linux 的体系结构分为内核态(内核空间)和用户态(用户空间),我们知道一台计算器拥有 CPU、网卡、内存和磁盘等硬件资源,内核态相当于 Linux Core,它是一种特殊的软件程序,也可以看成操作系统本身,它控制着计算机的所有硬件资源,并给用户态的进程分配所需的资源,用户态的进程不能直接访问硬件资源,它需要通过内核态体提供的接口来间接操作硬件资源。两者的关系图如下:
Java 与零拷贝_第1张图片
JVM 就是一个用户态进程。

Linux 的 IO 发展史

传统 IO

在 Java 程序中,你要从磁盘读取文件,然后将文件发送到网络中,在这个场景下,看看传统 IO 数据在操作系统中的流转情况如下:
Java 与零拷贝_第2张图片

  1. 开发人员调用 InputStream.read() 方法,InputStream.read() 底层调用了操作系统的 read() 接口来从磁盘读取数据,此时发生了一次上下文切换(用户态->内核态),操作系统将数据从磁盘拷贝到 read buffer,此时发生了一次数据拷贝。总共发生了一次上下文切换和一次数据拷贝。
  2. 操作系统将数据从 read buffer 拷贝到应用进程(JVM),即操作系统 read() 接口的返回,此时发生了一次数据拷贝和一次上下文切换(内核态->用户态),总共发生了一次上下文切换和一次数据拷贝。
  3. 开发人员调用 SocketOutputStream.write() 方法将数据发送到网络中,数据被从应用进程(JVM)拷贝到内核态的 Socket buffer 中,此时发生了一次数据拷贝一次上下文切换(用户态->内核态)此时在用户态,总共发生了一次上下文切换和一次数据拷贝。
  4. 内核态(操作系统)调用底层接口将数据从 Socket buffer 中拷贝到网络接口中,然后底层接口返回写入的结果(写入字节数等)到应用进程的方法 SocketOutputStream.write(),此时发生了一次数据拷贝和一次下文切换(内核态->用户态),总共发生了一次上下文切换和一次数据拷贝。

整个流程可以分为四步,总共需要经过四次数据拷贝和四次上下文切换。其中从硬件到内核态的拷贝称为 DMA copy,它使用了 DMA(Direct Memory Access,直接内存存取)控制器,DMA 的引入可以减少 CPU 的负担,现代磁盘基本都支持 DMA 了,从内核态到用户态的拷贝称为 CPU 拷贝,它需要 CPU 来进行拷贝,而零拷贝针对的是 CPU copy,即在整个 IO 过程中将 CPU copy 将为 0 次就叫做零拷贝,而 DMA copy 是不可避免的。

Linux 操作系统为了提升 IO 的速度,对 IO 做了一些系列的优化,其中就是以减少 CPU copy 和上下文切换为开发目的的。

mmap 内存映射

最先出现的是 mmap 内存映射,使用它之后整个 IO 流程如下:
Java 与零拷贝_第3张图片
mmap 使用了虚拟内存技术,即内核态和用户态不直接操作物理内存,而是操作虚拟内存,虚拟内存映射到物理内存,在 mmap 中将内核态的虚拟内存和用户态的虚拟内存映射到了同一块物理内存中,这样数据在被拷贝到内核态之后就不需要再拷贝到用户态了,用户态通过虚拟内存来和内核态操作同一块内存,整个流程如下:

  1. 开发人员调用 InputStream.read() 方法,InputStream.read() 底层调用了操作系统的 read() 接口来从磁盘读取数据,此时发生了一次上下文切换(用户态->内核态),操作系统将数据从磁盘拷贝到 read buffer,此时发生了一次数据拷贝,总共发生了一次上下文切换和一次数据拷贝。
  2. InputStream.read() 方法返回,此时发生了上下文切换(内核态->用户态),但是数据不需要再拷贝到用户态,用户态中的应用进程(JVM)通过虚拟内存技术和内核态共用一块物理内存,用户态对内存的操作会直接反映到内核态,总共发生了一次上下文切换。
  3. 开发人员调用 SocketOutputStream.write() 准备发送数据到网络中,此时发生了一次上下文切换(用户态->内核态),然后内核态将与用户态共享的那块内存拷贝到内核态的 socket buffer 中,总共发生了一次上下文切换和一次数据拷贝。
  4. 内核态将数据从 socket buffer 中拷贝到网络接口中,然后 SocketOutputStream.write() 方法返回写入结果,此时发生了一次上下文切换(内核态->用户态),总共发生了一次上下文切换和一次数据拷贝。

整个流程分为了四步,总共需要经过三次数据拷贝和四次上下文切换,,其中一次 CPU copy 和 两次 DMA copy。和传统 IO 相比减少了一次 CPU copy,提高了 IO 的性能以及减少了 CPU 的负载。

sendfile

Linux 2.1 出现了 sendfile 技术,使用它之后整个 IO 流程如下:
Java 与零拷贝_第4张图片
sendfile 和 mmap 有点类似,相比 mmap,它取消了内存映射的部分,这也导致了用户态的进程无法操作要发送的数据(磁盘文件),但是在不需要操作数据的场景中比 mmap 的性能更好。整个流程如下:

  1. 开发人员调用 FileChannel.transferTo() 方法,该方法底层调用内核态接口,此时发生了一次上下文切换(用户态->内核态),内核态将数据从磁盘拷贝到 kernel buffer,总共发生了一次上下文切换和一次数据拷贝。
  2. 内核态将数据从 kernel buffer 拷贝到 socket buffer,此时发生了一次数据拷贝,总共发生了一次数据拷贝。
  3. 内核态将数据从 socket buffer 拷贝到 网络接口,此时发生了一次数据拷贝,总共发生了一次数据拷贝。
  4. FileChannel.transferTo() 方法返回,此次 IO 结束,此时发生了一次上下文切换(内核态->用户态),总共发生了一次上下文切换和一次数据拷贝。

整个流程分为了四步,总共需要经过三次数据拷贝和两次上下文切换,,其中一次 CPU copy 和 两次 DMA copy。和传统 IO 相比减少了一次 CPU copy 和 两个上下文切换,和 mmap 相比减少了两次上下文切换。

mmap vs sendfile

如前所述,mmap 和 sendfile 最大的不同就是用户态进程是否可以操作数据,mmap 通过虚拟内存映射技术,是开发人员在 IO 的过程中可以修改数据,比如在发送文件之前要修改文件中第一行的数据,就必须使用 mmap,如果你的需求是直接将文件发送到网络接口中,那么推荐使用 sendfile,因为在该场景中它比 mmap 更快。

Linux 除了这两种 IO 技术还有其他的 IO 技术,如 splice,但是 Java 只支持这两种 IO 优化技术,而且这两种也是最常见的,所以这里只介绍了这两种 IO 优化技术。其实 mmap 和 sendfile 都不算真正的零拷贝,因为零拷贝的概念是整个 IO 过程中零次的 CPU 拷贝,mmap 和 sendfile 都需要一次 CPU 拷贝。

Java 中的抽象

在 Java 中,mmap 技术和 sendfile 技术的实现,都抽象在了 FileChannel 类中。下面介绍通过 FileChannel 来使用这两种 IO 技术的方式。

mmap

FileChannel 通过返回 MappedByteBuffer 来操作磁盘文件。

// 打开文件并创建 FileChannel
RandomAccessFile file = new RandomAccessFile("yourfile.txt", "rw");
FileChannel channel = file.getChannel();
// 创建 MappedByteBuffer
MappedByteBuffer mappedBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, file.length());
// 读取数据
byte data = mappedBuffer.get();
// 写入数据
mappedBuffer.put((byte) 42);
// 关闭通道
channel.close();
file.close();

你可以像操作普通的字节数组一样,通过 MappedByteBuffer 来操作文件的数据,读取和写入操作都会直接影响到文件。mmap 技术的优势在于你将文件内容直接映射到内存中,避免了复制数据的开销,从而提高了文件 IO 操作的性能。这对于大型文件和需要频繁读写的文件非常有用。

需要注意的是,MappedByteBuffer 的大小不能超过文件的大小,并且文件的更改会立即反映到映射中,这可能会影响到其他访问同一个文件的程序。在多线程或多进程环境中使用 mapp 时要格外小心,确保同步访问。

使用 MappedByteBuffer 将文件发送到网络接口

// 创建 SocketChannel
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("remote-host", port));
// 创建 MappedByteBuffer
RandomAccessFile file = new RandomAccessFile("yourfile.txt", "r");
FileChannel channel = file.getChannel();
MappedByteBuffer mappedBuffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, file.length());
// 将数据写入 SocketChannel
socketChannel.write(mappedBuffer);
// 关闭资源
socketChannel.close();
channel.close();
file.close();

由上可知,MappedByteBuffer 可以用来简单的修改磁盘文件内容,这在大文件场景下非常拥有。

sendfile

FileChannel#transferTo 和 FileChannel#transferFrom 方法就是 Java 对 sendfile 的抽象。

使用 FileChannel.transferTo 方法发送文件到客户端:

FileChannel fileChannel = new FileInputStream("example.txt").getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 12345));

// 将文件内容发送到客户端
long transferred = 0;
long size = fileChannel.size();
while (transferred < size) {
    transferred += fileChannel.transferTo(transferred, size - transferred, socketChannel);
}

fileChannel.close();
socketChannel.close();

在上面的示例中,transferTo 方法将文件内容直接从文件通道发送到套接字通道,避免了数据的复制。

使用 FileChannel.transferFrom 方法接收客户端发送的文件:

SocketChannel socketChannel = ServerSocketChannel.open().accept();
FileChannel fileChannel = new FileOutputStream("received.txt").getChannel();

// 接收客户端发送的文件并保存到本地
long transferred = 0;
long size = Long.MAX_VALUE; // 你需要知道文件的大小
while (transferred < size) {
    transferred += fileChannel.transferFrom(socketChannel, transferred, size - transferred);
}

fileChannel.close();
socketChannel.close();

在这个示例中,transferFrom 方法将文件内容直接从套接字通道接收到文件通道,也避免了数据的复制。

后话

如果你在之前看了很多其他讲解 Java 实现零拷贝的博文,可能有很多博文会提到 Channel 对应操作系统内核态缓存,这句话是有问题的,我们看看 ChatGPT 怎么说:
Java 与零拷贝_第5张图片

参考:

https://springboot.io/t/topic/4843
https://zhuanlan.zhihu.com/p/78869158
https://blog.csdn.net/cringkong/article/details/80274148
https://www.jianshu.com/p/497e7640b57c
https://chat.openai.com/

你可能感兴趣的:(java,零拷贝)