NIO与零拷贝

时间:2022-07-22
本文章向大家介绍NIO与零拷贝,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

一、是什么

先来看如下一段代码:

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(8888).accept();
socket.getOutputStream().write(arr);

这段代码就是读取一个文件,然后再把它写出去,看起来就几行代码,其实涉及到多次拷贝,其流程如下:

  • 读取需要拷贝的数据,这个过程有两个步骤:首先将文件通过DMA(direct memory access,直接内存拷贝,不经过CPU) 拷贝到系统内核的buffer中,然后在内核buffer中通过 CPU拷贝到用户的buffer中,这才完成了读取文件的步骤。即前四行就发生了两次拷贝。
  • 写数据的时候,将数据从用户buffer通过CPU拷贝到socket buffer中,最后从socket buffer通过DMA拷贝到协议栈。即最后一行也发生了两次拷贝。

整个过程,发生了四次拷贝,三次状态的切换。从一开始的用户态,切换到内核态,再切换到用户态,最后再切换成内核态。一次简单的读写,就有这么多名堂,性能肯定是不好的,所有就出现了零拷贝,零拷贝,不是不拷贝,而是整个过程不需要进行CPU拷贝。


二、零拷贝

1、使用mmap优化上述流程: mmap,是指通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据,这样,在进行网络传输时,就可以减少内核空间到用户空间的拷贝次数。同样做上面的事情,使用mmap时整个过程如下:

  • 首先通过DMA拷贝将硬盘数据拷贝到内核buffer,但是因为用户buffer可以共享内核buffer的数据,所以步骤二的cpu拷贝就免了;
  • 然后是直接从内核buffer通过CPU拷贝到socket buffer,最后DMA拷贝到协议栈。

整个过程三次拷贝,三次状态的切换,相比传统拷贝,优化了一丢丢,但这并不是零拷贝。

2、使用sendFile优化: linux 2.1的sendFile: sendFile是linux2.1版本开始提供的一个函数,可以让文件直接从内核buffer进入到socket buffer,不需要经过用户态,过程如下:

  • 首先还是将数据从硬盘中通过DMA拷贝到内核buffer,然后通过CPU拷贝将数据从内核buffer拷贝到socket buffer,最后通过DMA拷贝到协议栈。

整个过程还是3次拷贝,但是减少了一次装态切换,从用户态到内核态再到用户态,只经过了两次切换。这里还是有一次CPU拷贝,还不是真正的零拷贝。

linux 2.4的sendFile: linux 2.4对sendFile又做了一些优化,首先还是DMA拷贝到内核buffer,然后再通过CPU拷贝到socket buffer,最后DMA拷贝到协议栈。优化的点就在于,这次的CPU拷贝,拷贝的内容很少,只拷贝内核buffer的长度、偏移量等信息,消耗很低,可以忽略。因此,这个就是零拷贝。NIO的transferTo方法就可以实现零拷贝。

三、案例代码

1、传统IO拷贝大文件:

  • 服务端:接收客户端发来的数据
public class OldIoServer {
    
    @SuppressWarnings("resource")
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(6666);
        while (true) {
            Socket socket = serverSocket.accept();
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());
            byte[] byteArray = new byte[4096];
            while (true) {
                int readCount = dataInputStream.read(byteArray, 0, byteArray.length);
                if (-1 == readCount) {
                    break;
                }
            }
        }
    }
}
  • 客户端:发送一个大文件到服务端:
public class OldIoClient {
    
    @SuppressWarnings("resource")
    public static void main(String[] args) throws Exception {
        Socket socket = new Socket("127.0.0.1", 6666);
        // 需要拷贝的文件
        String fileName = "E:\download\soft\windows\jdk-8u171-windows-x64.exe";
        InputStream inputStream = new FileInputStream(fileName);
        DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());
        byte[] buffer = new byte[4096];
        long readCount;
        long total = 0;
        long start = System.currentTimeMillis();
        while ((readCount = inputStream.read(buffer)) >= 0) {
            total += readCount;
            dataOutputStream.write(buffer);
        }
        long end = System.currentTimeMillis();
        System.out.println("传输总字节数:" + total + ",耗时:" + (end - start) + "毫秒");
        dataOutputStream.close();
        inputStream.close();
        socket.close();
    }
}

这里拷贝了一个JDK,最后运行结果如下:

传输总字节数:217342912,耗时:4803毫秒

可以看到,将近5秒钟。接下来看看使用NIO的transferTo方法耗时情况:

  • 服务端:
public class NioServer {

    public static void main(String[] args) throws IOException {
        InetSocketAddress address = new InetSocketAddress(6666);
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        serverSocket.bind(address);
        ByteBuffer buffer = ByteBuffer.allocate(4096);
        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            int readCount = 0;
            while (-1 != readCount) {
                readCount = socketChannel.read(buffer);
                buffer.rewind(); // 倒带,将position设置为0,mark设置为-1
            }
        }
    }
}
  • 客户端:
public class NioClient {
    
    @SuppressWarnings("resource")
    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 6666));
        String fileName = "E:\download\soft\windows\jdk-8u171-windows-x64.exe";
        FileChannel channel = new FileInputStream(fileName).getChannel();
        long start = System.currentTimeMillis();
        // 在linux下,transferTo方法可以一次性发送数据
        // 在windows中,transferTo方法传输的文件超过8M得分段
        long totalSize = channel.size();
        long transferTotal = 0;
        long position = 0;
        long count = 8 * 1024 * 1024;
        if (totalSize > count) {
            BigDecimal totalCount = new BigDecimal(totalSize).divide(new BigDecimal(count)).setScale(0, RoundingMode.UP);
            for (int i=1; i<=totalCount.intValue(); i++) {
                if (i == totalCount.intValue()) {
                    transferTotal += channel.transferTo(position, totalSize, socketChannel);
                } else {
                    transferTotal += channel.transferTo(position, count + position, socketChannel);
                    position = position + count;
                }
            }
        } else {
            transferTotal += channel.transferTo(position, totalSize, socketChannel);
        }
        
        long end = System.currentTimeMillis();
        System.out.println("发送的总字节:" + transferTotal + ",耗时:" + (end - start) + "毫秒");
        channel.close();
        socketChannel.close();
    }
}

客户端发送文件调用transferTo方法要注意,在window中,这个方法一次只能传输8M,超过8M的文件要分段,像代码中那样分段传输,在linux中是没这个限制的。运行后结果如下:

发送的总字节:217342912,耗时:415毫秒

从结果可以看到,BIO与NIO耗时相差一个数量级,NIO只要0.4s,而BIO要4s。所以在网络传输中,使用NIO的零拷贝,可以大大提高性能。