传统IO拷贝
传统IO的数据拷贝流程如下图:

- 数据需要从磁盘拷贝到内核空间,再从内核空间拷到用户空间(JVM)。
- 程序可能进行数据修改等操作。
- 再将数据拷贝到内核空间,内核空间再拷贝到网卡内存,通过网络发送出去(或拷贝到磁盘)。
即数据的读写(这里用户空间发到网络也算作写),都至少需要两次拷贝。
当然磁盘到内核空间属于DMA拷贝(DMA即直接内存存取,原理是外部设备不通过CPU而直接与系统内存交换数据)。而内核空间到用户空间则需要CPU的参与进行拷贝,既然需要CPU参与,也就涉及到了内核态和用户态的相互切换,如下图:

NIO零拷贝
sendfile和mmap本质上都是基于零拷贝技术实现的,在Java NIO中,其分别通过FileChannel的transferTo()和map()提供支持。
sendfile
sendfile的数据拷贝如下图:

内核态与用户态切换如下图:

改进的地方:
- 我们已经将上下文切换次数从4次减少到了2次。
- 将数据拷贝次数从4次减少到了3次(其中只有1次涉及了CPU,另外2次是DMA直接存取)。
但这还没有达到我们零拷贝的目标。如果底层NIC(网络接口卡)支持gather操作,我们能进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,Socket缓冲区描述符已被修改用来适应这个需求。简单来说,传输的数据不需要连续的内存空间,整个过程中并没有实际内容数据从内核缓冲区拷贝到Socket缓冲区,取而代之的是将包含数据的位置以及长度的描述符附加到Socket缓冲区,之后直接从内核缓冲区进行收集然后传输即可。这种方式不但减少多次的上下文切换,同时消除了需要CPU参与的重复的数据拷贝。用户这边的使用方式不变,而内部已经有了质的改变:

mmap
以上都建立在整个过程不需要进行额外的数据文件操作的情况下,如果既需要这样的速度,也需要进行数据操作就可以使用mmap(内存映射文件)。mmap将文件的内容从内核缓冲区映射到用户的进程中,这样就不需要拷贝了,但是mmap有个缺点是如果其他进程在向这个文件write,那么会被认为是一个错误的存储访问。除此之外,mmap最后向网卡传输时必须将数据从内核缓冲区拷贝到Socket缓冲区。
实验
传统IO拷贝:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46// Server
public class OldIOServer {
    public static void main(String[] args) throws Exception {
        ServerSocket serverSocket = new ServerSocket();
        serverSocket.bind(new InetSocketAddress(8899));
        while (true) {
            Socket socket = serverSocket.accept();
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
            try {
                byte[] byteArray = new byte[4096];
                while (true) {
                    int readCount = dataInputStream.read(byteArray, 0, byteArray.length);
                    if (-1 == readCount) {
                        break;
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
}
// Client
public class OldIOClient {
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("localhost", 8899);
        String fileName = "/Users/hecenjie/Downloads/joker.mp4";
        InputStream inputStream = new FileInputStream(fileName);
        DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
        byte[] buffer = new byte[4096];
        long readCount;
        long total = 0L;
        long startTime = System.currentTimeMillis();
        while((readCount = inputStream.read(buffer) )>= 0) {
            total += readCount;
            dataOutputStream.write(buffer);
        }
        System.out.println("[old] 发送总字节数: " + total + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
        dataOutputStream.close();
        socket.close();
        inputStream.close();
    }
}
NIO零拷贝:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61// Server
public class NewIOServer {
    public static void main(String[] args) throws Exception {
        InetSocketAddress address = new InetSocketAddress(8899);
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.setReuseAddress(true);
        serverSocket.bind(address);
        ByteBuffer byteBuffer = ByteBuffer.allocate(4096);
        while (true) {
            SocketChannel channel = serverSocketChannel.accept();
            channel.configureBlocking(true);
            int readCount = 0;
            while(-1 != readCount) {
                try{
                    readCount = channel.read(byteBuffer);
                    byteBuffer.rewind();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
// Client
public class NewIOClient {
    public static void main(String[] args) throws Exception {
        SocketChannel socketChannel = SocketChannel.open();
        String fileName = "/Users/hecenjie/Downloads/joker.mp4";
        FileChannel fileChannel = new FileInputStream(fileName).getChannel();
        socketChannel.connect(new InetSocketAddress("localhost", 8899));
        socketChannel.configureBlocking(true);
        // begin
        sendfile(fileChannel, socketChannel);
        mmap(fileChannel, socketChannel);
    }
    public static void sendfile(FileChannel fileChannel, SocketChannel socketChannel) throws IOException {
        long position = 0L;
        long startTime = System.currentTimeMillis();
        while(position < fileChannel.size()) {
            // 如果socketChannel是非阻塞的,可能就读取不到指定数量的字节
            position += fileChannel.transferTo(position, Math.min(2147483647L, fileChannel.size()-position), socketChannel);
        }
        System.out.println("[sendfile] 发送总字节数: " + position + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
    }
    public static void mmap(FileChannel fileChannel, SocketChannel socketChannel) throws IOException {
        MappedByteBuffer mbb;
        long position = 0L;    // 同时也充当position的作用
        long startTime = System.currentTimeMillis();
        while(position < fileChannel.size()){
            mbb = fileChannel.map(FileChannel.MapMode.READ_ONLY, position, Math.min(fileChannel.size()-position, Integer.MAX_VALUE));
            position += socketChannel.write(mbb);
        }
        System.out.println("[mmap] 发送总字节数: " + position + " , 耗时: " + (System.currentTimeMillis() - startTime) + "毫秒");
    }
}
实验结果:1
2
3[old] 发送总字节数: 2300047434 , 耗时: 2572毫秒
[sendfile] 发送总字节数: 2300047434 , 耗时: 474毫秒
[mmap] 发送总字节数: 2300047434 , 耗时: 1271毫秒
这里有几点需要特别注意的地方。首先,transferTo()是有处理长度限制的,也就是2147483647L个字节(2GB - 1)。所以如果处理超过2GB的文件就要循环执行transferTo()方法,直至处理长度和文件长度一样为止。其次,如果将SocketChannel设置成非阻塞的,可能就读取不到指定数量的字节,因此如果使用之前没有循环的方式,会发现就算是小于2GB的文件也传输不完,而使用循环的方式则天然的解决了此问题。