传统拷贝的流程
首先要了解零拷贝需要先了解传统拷贝的一个过程。附图下
(考虑到Java多了一个堆外内存)
一般会经历以下步骤:
1:用户空间到内核空间:应用程序发起系统调用,操作系统将数据从磁盘读取到内核空间的缓冲区(DMA 搬运)。
2:内核空间到用户空间:操作系统把内核缓冲区的数据拷贝到用户空间的缓冲区,此时应用程序才能访问这些数据。
3:用户空间到内核空间(socket 缓冲区):应用程序将用户空间缓冲区的数据再次拷贝到内核空间的 socket 缓冲区。
4:内核空间到网络设备:最后,操作系统将 socket 缓冲区的数据发送到网络设备,通过网络传输。
可以发现:
在这个过程中,如果不考虑用户态的内存拷贝和物理设备到驱动的数据拷贝,我们会发现,这其中会涉及4次数据拷贝。同时也会涉及到4次进程上下文的切换。这种方式存在多次上下文切换和数据拷贝,效率较低。上下文切换会消耗 CPU 资源,多次数据拷贝也会增加内存带宽的占用和时间开销。但是对于零拷贝来说,是通过各种方式减少拷贝的次数或者cpu的拷贝次数实现的。
常见的零拷贝方式有mmap,sendfile,dma,directI/O等。
SendFile:
本质就是只做文件传输,而不通过用户态进行干预。。来减少拷贝次数,有局限性,属于纵向优化了。
FileChannel
是 Java NIO 中用于文件操作的通道类,transferTo()
和 transferFrom()
方法是基于操作系统的 sendfile
机制实现的。sendfile
允许在内核空间直接将文件数据从磁盘读取到内核缓冲区,然后直接将内核缓冲区的数据发送到网络设备,避免了数据在用户空间的拷贝。
数据拷贝变成了2次,上下文切换减少到了2次。
以下是一个demo
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;public class ZeroCopyTransferToExample {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream("source.txt");
FileChannel fileChannel = fis.getChannel();
SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 8080))) {// 将文件通道的数据传输到套接字通道
long transferred = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("Transferred bytes: " + transferred);} catch (IOException e) {
e.printStackTrace();
}
}
}
对于 UDP 数据传输也可以实现SendFIie,可以使用 DatagramChannel
结合 FileChannel.transferTo()
实现零拷贝。同样是利用 transferTo()
方法的特性,直接在内核空间完成数据从文件到网络的传输。
import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.DatagramChannel;
import java.nio.channels.FileChannel;public class ZeroCopyUDPExample {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream("source.txt");
FileChannel fileChannel = fis.getChannel();
DatagramChannel datagramChannel = DatagramChannel.open()) {// 绑定本地地址
datagramChannel.bind(new InetSocketAddress(9090));// 设置目标地址
InetSocketAddress targetAddress = new InetSocketAddress("localhost", 9091);// 将文件通道的数据传输到 UDP 通道
long transferred = fileChannel.transferTo(0, fileChannel.size(), datagramChannel);
System.out.println("Transferred bytes: " + transferred);} catch (IOException e) {
e.printStackTrace();
}
}
}
mmap
将内核态和用户态的内存映射到一起,避免来回拷贝,实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。
MappedByteBuffer
是 Java NIO 中用于内存映射文件的类,它使用 mmap
系统调用将文件映射到用户空间的地址空间,这样内核和用户空间可以共享这块内存,避免了从内核空间到用户空间的一次拷贝。
数据拷贝变成了2次,上下文切换减少到了2次。
以下是demo
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;public class ZeroCopyMappedByteBufferExample {
public static void main(String[] args) {
try (FileInputStream fis = new FileInputStream("source.txt");
FileChannel inChannel = fis.getChannel();
FileOutputStream fos = new FileOutputStream("destination.txt");
FileChannel outChannel = fos.getChannel()) {// 获取文件大小
long fileSize = inChannel.size();// 将输入文件映射到内存
MappedByteBuffer mappedByteBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);// 将映射的内存数据写入输出文件
outChannel.write(mappedByteBuffer);} catch (IOException e) {
e.printStackTrace();
}
}
}
直接IO
之前的mmap可以让用户态和内核态共用一个内存空间来减少拷贝,其实还有一个方式,就是硬件数据不经过内核态的空间,直接到用户态的内存中,这种方式就是Direct I/O。换句话说,Direct I/O不会经过内核态,而是用户态和设备的直接交互,用户态的写入就是直接写入到磁盘,不会再经过操作系统刷盘处理。这样确实拷贝次数减少,读取速度会变快,但是因为操作系统不再负责缓存之类的管理,这就必须交由应用程序自己去做,譬如MySql就是自己通过Direct I/O完成的,同时MySql也有一套自己的缓存系统同时,虽然direct I/O可以直接将文件写入磁盘中,但是文件相关的元信息还是要通过fsync缓存到内核空间中。
Java并没有这方面的api,需要调用c底层的代码来实现。也跟操作系统底层设计有关系。不同操作系统实现的方式不一样。考虑的点比较多。很麻烦。。。
这种方式的数据拷贝变成了1次,上下文切换减少到了2次。
DMA外部实现
DMA 是一种允许外部设备(如磁盘、网卡等)直接与系统内存进行数据传输,而DMA 控制器可以直接控制数据在内存和外部设备之间的传输,CPU 只需要发起 DMA 传输请求,之后就可以去处理其他任务,当 DMA 传输完成后会通过中断通知 CPU。面从原理和不同场景下的实现方式来详细介绍。主要有俩种方式:网络到网络的拷贝,磁盘到网络的拷贝
磁盘到网络的拷贝:这种方式本质是调用sendfile机制 + DMA控制器即可,绕过了cpu处理。
网络到网络的拷贝:本质也是通过DMA直接将数据写入到内核缓冲区中,并有DMA进行转发和读,也会绕过cpu处理。