Java NIO:详细解析NIO中的“零拷贝”以及于IO的效率对比

概述

在现在各种RPC框架、网络编程框架中,底层大量的使用了Java NIO作为效率的保证,NIO对比IO有着无与伦比的性能优势,才保证了各种高并发场景下的系统承载能力,其中不得不提的就是“零拷贝”“零拷贝”是决定NIO性能的关键性因素,但是其又受限于底层操作系统的支持。

“零拷贝”特性是通过程序层来调用 native方法,进而借助于操作系统底层的特殊IO实现的,就像字面意思一样,操作系统通过了一些设计上的优化,达到了减少操作数据过程中的拷贝次数、切换“上下文”的次数,从而极大的提升了数据传输效率。

验证“零拷贝”效率的提升

通过模拟客户端向服务端发送数据来比较NIO与传统IO的速度

唯一物料:准备一个百兆大文件,文件有越大越能体现的明显

传统IO效率验证示例:

服务端

package com.leolee.zeroCopy;

import java.io.DataInputStream;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * @ClassName IOServer
 * @Description: IO服务端接收数据,不对接受数据做任何处理,只用于模拟客户端的数据接收
 * @Author LeoLee
 * @Date 2020/10/13
 * @Version V1.0
 **/
public class IOServer {


    public static void main(String[] args) throws IOException {

        ServerSocket serverSocket = new ServerSocket(8899);

        while (true) {
            Socket socket = serverSocket.accept();
            DataInputStream dataInputStream = new DataInputStream(socket.getInputStream());

            byte[] bytes = new byte[4096];
            while (true) {
                int readCount = dataInputStream.read(bytes, 0, bytes.length);
                if (readCount == -1) {
                    break;
                }
            }
        }
    }
}

客户端

package com.leolee.zeroCopy;

import java.io.*;
import java.net.Socket;

/**
 * @ClassName IOClient
 * @Description: IO客户端读取文件并发送给服务器
 * @Author LeoLee
 * @Date 2020/10/13
 * @Version V1.0
 **/
public class IOClient {

    public static void main(String[] args) throws IOException {

        //建立到服务端的连接
        Socket socket = new Socket("127.0.0.1", 8899);

        //源文件
        String file = "C:" + File.separator + "Users" + File.separator + "LeoLee" + File.separator + "Desktop" + File.separator + "sqldeveloper-4.1.5.21.78-x64.zip";
        //读取目标数据
        InputStream inputStream = new FileInputStream(file);

        //发送数据
        DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());

        byte[] bytes = new byte[4096];
        long readcout;
        long total = 0;
        long startTime = System.currentTimeMillis();

        while ((readcout = inputStream.read(bytes)) >= 0) {
            total += readcout;
            dataOutputStream.write(bytes);
        }

        System.out.println("发送总字节数:" + total + ",耗时:" + (System.currentTimeMillis() - startTime));

        dataOutputStream.close();
        inputStream.close();
        socket.close();

    }
}

依次运行服务端和客户端后,客户端输出如下:

传统NIO效率验证示例:

服务端

package com.leolee.zeroCopy;

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

/**
 * @ClassName NIOServer
 * @Description: NIO服务端接收数据,不对接受数据做任何处理,只用于模拟客户端的数据接收
 * @Author LeoLee
 * @Date 2020/10/13
 * @Version V1.0
 **/
public class NIOServer {

    public static void main(String[] args) throws IOException {

        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        ServerSocket serverSocket = serverSocketChannel.socket();
        //该选项用来决定如果网络上仍然有数据向旧的ServerSocket传输数据,是否允许新的ServerSocket绑定到与旧的ServerSocket同样的端口上,该选项的默认值与操作系统有关,在某些操作系统中,允许重用端口,而在某些系统中不允许重用端口。
        //
        //当ServerSocket关闭时,如果网络上还有发送到这个serversocket上的数据,这个ServerSocket不会立即释放本地端口,而是等待一段时间,确保接收到了网络上发送过来的延迟数据,然后再释放端口。
        //
        //值得注意的是,public void setReuseAddress(boolean on) throws SocketException必须在ServerSocket还没有绑定到一个本地端口之前使用,否则执行该方法无效。此外,两个公用同一个端口的进程必须都调用serverSocket.setReuseAddress(true)方法,才能使得一个进程关闭ServerSocket之后,另一个进程的ServerSocket还能够立刻重用相同的端口
        serverSocket.setReuseAddress(true);
        serverSocket.bind(new InetSocketAddress("127.0.0.1", 8899));

        ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

        while (true) {
            SocketChannel socketChannel = serverSocketChannel.accept();
            //阻塞模式
            socketChannel.configureBlocking(true);

            int readCount = 0;

            while (-1 != readCount) {
                readCount = socketChannel.read(byteBuffer);
                byteBuffer.rewind();
            }
        }
    }
}

客户端

package com.leolee.zeroCopy;

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

/**
 * @ClassName NIOClient
 * @Description: NIO客户端读取文件并发送给服务器
 * @Author LeoLee
 * @Date 2020/10/13
 * @Version V1.0
 **/
public class NIOClient {

    public static void main(String[] args) throws IOException, InterruptedException {

        //建立到服务器连接
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 8899));
        //设置为阻塞模式:为了保证更准确的验证“零拷贝”的效率,所以设置为阻塞,一直读完所有的文件数据再传递到服务端
        socketChannel.configureBlocking(true);

        //源文件
        String file = "C:" + File.separator + "Users" + File.separator + "LeoLee" + File.separator + "Desktop" + File.separator + "sqldeveloper-4.1.5.21.78-x64.zip";

        FileChannel fileChannel = new FileInputStream(file).getChannel();

        long startTime = System.currentTimeMillis();

        //发送数据
        //mac电脑(linux系统)中,可以直接使用long transferCount = fileChannel.transferTo(position, fileChannel.size(), socketChannel);
        //并不需要如下循环,原因windows对一次传输的数据大小有限制(8388608bytes),所以不能依次传输所有数据,需要循环来传递
        //参考与https://blog.csdn.net/forget_me_not1991/article/details/80722386
        long position = 0;
        long size = fileChannel.size();
        long total = 0;
        while (position < size) {
            long transferCount = fileChannel.transferTo(position, fileChannel.size(), socketChannel);//这一步体现零拷贝
            System.out.println("发送:" + transferCount);
            if (transferCount <= 0) {
                break;
            }
            total += transferCount;
            position += transferCount;
        }


        System.out.println("发送总字节数:" + total + ",耗时:" + (System.currentTimeMillis() - startTime));

        fileChannel.close();
    }
}

依次运行服务端和客户端后,客户端输出如下:

结果说明

很明显的能够看出同样的文件通过不同的方法来传递,NIO明显要快于传统IO传递数据

13224 与 771

这简直就是一个天上一个地下!!!在linux系统上可能更为明显!!!

零拷贝原理解析

为什么传统IO会慢???

传统IO在进行数据传递的时候,以传递文件数据场景为例:

  1. 程序在用户空间向操作系统发出了读操作,这时上下文由用户空间切换到内核空间
  2. 内核空间向磁盘进行了数据请求,这时就通过DMA(Direct Memory Access)从磁盘进行了第一次数据拷贝
  3. 内核空间拿到数据之后,会将数据再拷贝到程序所在的用户空间,发生了由内核空间到用户空间的切换,这是第二次数据拷贝
  4. 此时程序将要开始其数据传递的操作,所以又拷贝了一份数据,这是第三次数据拷贝,并向系统发出了写操作
  5. 用户空间又一次切换到了内核空间,随之是第四次数据拷贝,内核空间拿到数据后,开始通过socket模块发送数据
  6. 内核空间返回写结果给用户空间的程序,再次发生了上下文切换

在传统IO传递数据的过程中,一共发生了四次数据拷贝,并伴随了4次上下文切换,且socket模块发送数据是有“队列”任务的,这就是传统IO效率低下的原因。而在这个过程中,用户空间只是起到了一个数据中转的作用,这样是相当的多余的操作!是极大的性能损耗。

不完全“零拷贝”(通过sendfile()发送数据)

相对于传统IO的read() syscall和write() syscall,sendfile() syscall可以省略数据从内核空间拷贝到用户空间的过程。简单的来讲就是处于用户空间的程序通过调用native方法(syscall 系统调用)告诉内核空间,你帮我发送一个文件数据,不用告诉我文件数据是什么,你只用帮我发就行了。当内核空间发送了文件数据后遍返回结果给到用户空间。

此方式重要的操作就是读写操作之间,增加了内核空间数据的拷贝,将从磁盘读取的数据,拷贝到了socket缓冲区中。

升级版“零拷贝”

完全“零拷贝”

  1. 程序在用户空间向系统发出sendfile()系统调用,上下文切换到了内核空间
  2. 内核空间从磁盘通过DMA(Direct Memory Access)方式拷贝了数据到内核缓冲区,同时对应在socket buffer中产生了一系列描述符,这些描述符的内容包括其指向的数据位置,以及数据有多长,这样就可以定位其对应的buffer数据
  3. 之后协议引擎发送数据,通过描述符直接在内核缓冲中读取目标数据进行发送,并没有产生任何拷贝行为

这个描述符就是“零拷贝”的关键所在!

起始从磁盘读数据到kemel buffer 和 socket buffer就是NIO的scatter,协议引擎通过kemel buffer 和 socket buffer获取数据,就是gather操作。

对于上面的NIO示例开说,fileChannel.transferTo 就是实质上的“零拷贝”操作,由于其实现代码在sun包下,这里就不做源码分析了,只要知道其调用了native方法,通过操作系统实现的“零拷贝”就可以了。

归根到底,我们要认识到,“零拷贝”的操作是要依靠与操作系统的能力的,现在的绝大多数操作系统都已经支持了“零拷贝”操作,这点我们不用担心。

需要代码的来这里拿嗷:demo项目地址

猜你喜欢

转载自blog.csdn.net/qq_25805331/article/details/109062107