零拷贝是网络编程的关键,很多性能优化都离不开。在 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底层该过程的逻辑,上半部分表示用户态和内核态的上下文切换,下半部分表示数据复制操作。

java文件夹映射 java内存映射文件和零拷贝_网络编程

  1. 调用 read() 导致 用户态内核态 的一次变化,同时,第一次复制开始DMA引擎 从磁盘读取 test.txt 文件,并将数据 放入到内核缓冲区
  2. 发生 第二次数据拷贝,即:将 内核缓冲区 的数据拷贝到 用户缓冲区,同时,发生了一次用 内核态用户态上下文切换
  3. 调用 write() 方法,发生第三次数据拷贝,即系统将 用户缓冲区 的数据拷贝到 Socket 缓冲区。同时,又发生了一次 用户态内核态上下文切换
  4. 第四次拷贝,数据 异步 的从 Socket 缓冲区使用 DMA 引擎 拷贝到 网络协议引擎(protocol engine)。这一段,不需要进行上下文切换write() 方法返回,再次从 内核态 切换到 用户态

通过分析我们可以看到,仅仅是一次简单的读写操作,就发生了三次 用户态 到 内核态 的切换,四次 拷贝,显然代价是非常高的。

3 零拷贝优化

这里我们首先明确一下:零拷贝并不是完全没有拷贝,从操作系统角度来看,零拷贝是没有 cpu 拷贝。

3.1 优化一:mmap(不是真正的零拷贝)

mmap 通过内存映射,将文件映射到 内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。

java文件夹映射 java内存映射文件和零拷贝_NIO_02

如上图,user bufferkernel buffer 共享 test.txt。如果你想把硬盘的 test.txt 传输到网络中,就不用先拷贝到用户空间,再从用户空间拷贝到 Socket 缓冲区了。只需要从内核缓冲区拷贝到 Socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但上下文切换次数依旧没有减少。

3.2 优化二:sendFile(也不是真正的零拷贝)

Linux 2.1 版本 提供了 sendFile 函数,其基本原理如下:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换

java文件夹映射 java内存映射文件和零拷贝_java文件夹映射_03

系统调用 sendFile 函数时,数据被 DMA 引擎从文件复制到 内核缓冲区,然后调用 write 方法时,从内核缓冲区进入到 Socket 缓冲区,这时是没有上下文切换的,因为在一个用户空间。最后,数据从 Socket 缓冲区进入到 协议栈。此时,数据经过了 3 次拷贝3 次上下文切换

3.3 优化三:sendFile 2.0(真正的零拷贝)

Linux 在 2.4 版本中,做了一些修改,避免了内核缓冲区 拷贝到 Socket buffer 的操作,直接拷贝到网络协议栈,从而再一次减少了数据拷贝。

java文件夹映射 java内存映射文件和零拷贝_零拷贝_04


现在,text.txt 要从文件进入到 网络协议栈,只需 2 次拷贝:

  • 第一次使用 DMA 引擎从文件拷贝到内核缓冲区;
  • 第二次从内核缓冲区将数据拷贝到网络协议栈

注意:还会有一些信息,例如 offset 和 length 信息,会拷贝到 SocketBuffer,但是拷贝的信息很少,基本无消耗。

3.4 总结:mmap 和 sendFile 对比

我们说零拷贝,是从操作系统的角度来说的。因为内核缓冲区之间,没有数据是重复的(只有 kernel buffer 有一份数据)。

零拷贝不仅仅带来更少的数据复制,还能带来其他的性能优势,例如更少的上下文切换,更少的 CPU 缓存伪共享以及无 CPU 校验和计算。

mmap 和 sendFile 的区别

  • 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 作废
            }
        }
    }
}