零拷贝是网络编程的关键,很多性能优化都离不开。在 Java 程序中,常用的零拷贝有 mmap(memory map,内存映射) 和 sendFile。那么它们在 OS(操作系统) 中,到底是怎么样的一个的设计?另外我们看下NIO 中如何使用零拷贝?
1 概念介绍
- DMA(Direct Memory Access,直接存储器访问): DMA传输方式无需CPU直接控制传输,也没有中断处理方式那样保留现场和恢复现场的过程,通过硬件为RAM与I/O设备开辟一条直接传送数据的通路,使CPU的效率大为提高。
- 内核态:cpu可以访问
内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。 - 用户态:只能
受限的访问内存,且不允许访问外围设备,占用cpu的能力被剥夺,cpu资源可以被其他程序获取。
所有用户程序都是运行在用户态的,但是有时候程序确实需要做一些内核态的事情,例如从硬盘读取数据,或者从键盘获取输入等,而唯一可以做这些事情的就是操作系统,所以此时程序就需要先操作系统请求以程序的名义来执行这些操作。
2 传统IO数据读写操作分析
下面是 Java 传统 IO 和 网络编程的一段代码,我们分析一下它经过了多少次用户态、内核态之间的切换,以及发生了几次文件拷贝。
这段代码调用 read 方法读取 test.txt 的内容, 变成字节数组,然后调用 write() 方法,将 test.txt 字节流写到 socket 中。
File file = new File("test.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
// 将文件内荣读入数组
byte[] arr = new byte[(int) file.length()];
raf.read(arr);
// 通过输出流传输文件
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);
下面的图展示了在OS底层该过程的逻辑,上半部分表示用户态和内核态的上下文切换,下半部分表示数据复制操作。

- 调用
read()导致用户态到内核态的一次变化,同时,第一次复制开始:DMA引擎从磁盘读取 test.txt 文件,并将数据放入到内核缓冲区; - 发生
第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换; - 调用
write()方法,发生第三次数据拷贝,即系统将用户缓冲区的数据拷贝到Socket 缓冲区。同时,又发生了一次用户态到内核态的上下文切换; 第四次拷贝,数据异步的从Socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎(protocol engine)。这一段,不需要进行上下文切换。write()方法返回,再次从内核态切换到用户态。
通过分析我们可以看到,仅仅是一次简单的读写操作,就发生了三次 用户态 到 内核态 的切换,四次 拷贝,显然代价是非常高的。
3 零拷贝优化
这里我们首先明确一下:零拷贝并不是完全没有拷贝,从操作系统角度来看,零拷贝是没有 cpu 拷贝。
3.1 优化一:mmap(不是真正的零拷贝)
mmap 通过内存映射,将文件映射到 内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。

如上图,user buffer 和 kernel buffer 共享 test.txt。如果你想把硬盘的 test.txt 传输到网络中,就不用先拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区了。只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但上下文切换次数依旧没有减少。
3.2 优化二:sendFile(也不是真正的零拷贝)
Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。

系统调用 sendFile 函数时,数据被 DMA 引擎从文件复制到 内核缓冲区,然后调用 write 方法时,从内核缓冲区进入到 Socket 缓冲区,这时是没有上下文切换的,因为在一个用户空间。最后,数据从 Socket 缓冲区进入到 协议栈。此时,数据经过了 3 次拷贝,3 次上下文切换。
3.3 优化三:sendFile 2.0(真正的零拷贝)
Linux 在 2.4 版本中,做了一些修改,避免了从 内核缓冲区 拷贝到 Socket buffer 的操作,直接拷贝到网络协议栈,从而再一次减少了数据拷贝。

现在,text.txt 要从文件进入到 网络协议栈,只需 2 次拷贝:
- 第一次使用 DMA 引擎从文件拷贝到内核缓冲区;
- 第二次从内核缓冲区将数据拷贝到网络协议栈
注意:还会有一些信息,例如 offset 和 length 信息,会拷贝到 SocketBuffer,但是拷贝的信息很少,基本无消耗。
3.4 总结:mmap 和 sendFile 对比
我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。
零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- mmap 需要 4 次上下文切换,3 次数据拷贝;sendFile 需要 3 次上下文切换,最少 2 次数据拷贝。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
4 NIO 零拷贝案例
public class NewIOClient {
public static void main(String[] args) throws Exception {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 7001));
String filename = "protoc-3.6.1-win32.zip";
//得到一个文件channel
FileChannel fileChannel = new FileInputStream(filename).getChannel();
//准备发送
long startTime = System.currentTimeMillis();
//在linux下一个transferTo 方法就可以完成传输
//在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要主要
//transferTo 底层使用到零拷贝
long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel);
System.out.println("发送的总的字节数 =" + transferCount + " 耗时:" + (System.currentTimeMillis() - startTime));
//关闭
fileChannel.close();
}
}
public class NewIOServer {
public static void main(String[] args) throws Exception {
InetSocketAddress address = new InetSocketAddress(7001);
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
ServerSocket serverSocket = serverSocketChannel.socket();
serverSocket.bind(address);
//创建buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel socketChannel = serverSocketChannel.accept();
int readcount = 0;
while (-1 != readcount) {
try {
readcount = socketChannel.read(byteBuffer);
}catch (Exception ex) {
// ex.printStackTrace();
break;
}
//
byteBuffer.rewind(); //倒带 position = 0 mark 作废
}
}
}
}









