1. 理论

1. 零拷贝介绍

零拷贝是网络编程的关键, 很多性能优化都需要零拷贝。

在 Java程序中, 常用的零拷贝方式有m(memory)map[内存映射] 和 sendFile。

2. NIO 与 传统IO对比

(1) 传统示意图

NIO与零拷贝_NIO

user context: 用户态

kernel context: 内核态

User space: 用户空间

Kernel space: 内核空间

Syscall read: 系统调用读取

Syscall write: 系统调用写入

Hard drive: 硬件驱动

kernel buffer: 内核态缓冲区

user buffer: 用户态缓冲区

socket buffer: 套接字缓存

protocol engine: 协议引擎

DMA: Direct Memory Access: 直接内存拷贝(不使用CPU)

总结: 4次拷贝, 3次状态切换, 效率不高

(2)mmap优化流程示意图:

NIO与零拷贝_NIO_02

mmap 通过内存映射, 将文件映射到内核缓冲区, 同时, 用户空间可以共享内核空间的数据。

这样, 在进行网络传输时, 就可以减少内核空间到用户空间的拷贝次数。

总结: 3次拷贝, 3次状态切换, 不是真正意义上的零拷贝。

 (3) sendFile Linux2.1版本优化流程示意图

NIO与零拷贝_NIO_03

 

 

数据根本不经过用户态, 直接从内核缓冲区进入到Socket Buffer, 同时, 由于和用户台完全无关, 就减少了一次上下文切换。

但是仍然有一次CPU拷贝, 不是真正的零拷贝(没有CPU拷贝)。

总结: 3次拷贝, 2次切换

(4) sendFile Linux

NIO与零拷贝_NIO_04

避免了从内核缓冲区拷贝到Socket buffer的操作, 直接拷贝到协议栈, 从而再一次减少了数据拷贝。

其实是有一次cpu拷贝的, kernel buffer -> socket buffer, 但是拷贝的信息很少, length, offset, 消耗低, 基本可以忽略。

总结: 2次拷贝(如果忽略消耗低的cpu拷贝的话), 2次切换, 基本可以认为是零拷贝了。

3.零拷贝理解

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

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

4. mmap 与 sendFile 总结

mmap适合小数据两读写, sendFile适合大文件传输

mmap 需要3次上下文切换, 3次数据拷贝; sendFile 需要3次上下文切换, 最少2次数据拷贝。

sendFile 可以利用 DMA 方式, 减少 CPU 拷贝, 而 mmap则不能(必须从内核拷贝到Socket缓冲区)。

2.测试

使用传统方法和NIO方法传递一个大文件。文件的大小是106,520,576 字节 (101MB)。

NIO与零拷贝_NIO_05

1. 使用传统的IO 方法

服务器端代码

package filecopy;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.time.StopWatch;

import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class OldServerSocket {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(6666);
        Socket accept = serverSocket.accept();
        System.out.println("客户端连接成功" + accept.getRemoteSocketAddress());
        InputStream inputStream = accept.getInputStream();
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        int copy = IOUtils.copy(inputStream, new FileOutputStream("F:/old2.tar"));
        stopWatch.stop();
        System.out.println("传输完成,用时: " + stopWatch.getTime());
        inputStream.close();
        accept.close();
    }
}

客户端代码:

package filecopy;

import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.time.StopWatch;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.Socket;

public class OldSocketClient {

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("127.0.0.1", 6666);
        OutputStream outputStream = socket.getOutputStream();
        File file = new File("F:/1.tar");
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        IOUtils.copy(new FileInputStream(file), outputStream);
        stopWatch.stop();
        System.out.println("传输完成,用时: " + stopWatch.getTime());
        outputStream.close();
    }
}

结果:

传输完成,用时: 2542

2. 使用NIO方法

服务端代码:

package filecopy;

import java.io.FileOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;

public class NewIOServer {

    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(new InetSocketAddress(6666));
        serverSocket.setReuseAddress(true);

        // 输出的文件
        FileChannel channel = new FileOutputStream("F:/new2.tar").getChannel();

        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
        while (true) {
            try {
                SocketChannel socketChannel = serverSocketChannel.accept();
                System.out.println("客户端连接成功: " + socketChannel.getRemoteAddress());
                int readcount = 0;
                while (-1 != (readcount = socketChannel.read(byteBuffer))) {
                    byteBuffer.clear(); // 将索引重新指会0
                    channel.write(byteBuffer);// 将存进的ByteBuffer对象写进文件输出流
                    byteBuffer.flip();// 翻转缓冲区
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

客户端代码:

package filecopy;

import org.apache.commons.lang3.time.StopWatch;

import java.io.FileInputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;

public class NewIoClient {

    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("localhost", 6666));

        //得到一个文件channel
        FileChannel fileChannel = new FileInputStream("F:/1.tar").getChannel();

        StopWatch stopWatch = new StopWatch();
        stopWatch.start();
        //在linux下一个transferTo 方法就可以完成传输, 底层使用到零拷贝
        //在windows 下 一次调用 transferTo 只能发送8m , 就需要分段传输文件, 而且要考虑分段传输的起始位置
        // 每次传7M
        int sendSize = 8 * 1024;
        // 已经传送的大小(也作为下次传输的起始位置)
        int sendedSize = 0;
        long totalSize = fileChannel.size();
        while (sendedSize < totalSize) {
            sendedSize += fileChannel.transferTo(sendedSize, sendSize, socketChannel);
        }

        stopWatch.stop();
        System.out.println("传输完成,用时: " + stopWatch.getTime());

        //关闭
        fileChannel.close();
    }
}

 

【当你用心写完每一篇博客之后,你会发现它比你用代码实现功能更有成就感!】