5、java NIO 零拷贝技术

  • Post author:
  • Post category:java

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

关于零拷贝技术其实涉及到了一定的操作系统知识和一定的计算机组成原理知识

传统的文件传输过程

在这里插入图片描述
如果进程想要把某本地数据传到网卡上,通过网络传输出去,首先进程会从用户态切换到内核态,然后试图查询缓存,如果缓存命中,直接读取缓存的数据到这个进程的缓冲区。

进程是资源分配的最小单位,进程与进程之间是相互隔离的,如果往进程中的用户缓存中写数据,就必须要切换到权限更高的内核进程,这一过程叫做系统调用。

如果缓存未命中,就需要DMA从磁盘中读取数据到内核缓冲区中,然后再由内核进程读取数到用户进程的缓冲区中:

第一步拷贝:把磁盘上的数据拷贝到操作系统的内核缓冲区里。这个拷贝过程是通过DMA搬运的。
第二次拷贝:把内核缓冲区的数据拷贝到用户的缓冲区中,我们应用程序就能使用这部分数据了,这步拷贝是由CPU完成的
第三次拷贝:把刚才拷贝到用户缓冲区的数据再拷贝到内核的socket缓冲区中,这个过还是由CPU完成
第四次拷贝:把内核socket缓冲区的数据拷贝到网卡的缓冲区中,这个过程是由DMA完成

综上,传统的IO实现文件传输需要四次拷贝:2次CPU拷贝+2次DMA拷贝

  • DMA拷贝:把数据从磁盘拷贝到内核缓冲区
  • read():系统调用的过程中把内核缓冲区的数据拷贝到用户的缓冲区中
  • write():操作系统直接将内核缓冲区的数据拷贝到socket缓冲区中
  • CPU拷贝:CPU把内核缓冲区的数据拷贝到socket缓冲区中

传统的文件传输一共发生了两次系统调用:

  • read()
  • write()

所以发生了4次操作系统状态切换。

在大并发的场景下,上述过程就会被累计放大,进程影响到系统性能,要想提高文件传输的性能就需要减少操作系统用户态和内核态的上下文切换和数据拷贝次数

零拷贝技术

它是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域,这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽,通俗的说,零拷贝就是一种避免CPU将数据从一块存储拷贝到另一块存储的技术,零拷贝可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。

广义的零拷贝技术,可以理解,减少上下文切换和数据拷贝次数的技术,而狭义地讲,应该指没有CPU拷贝的技术,切记零拷贝不是没有拷贝,而是没有CPU拷贝。

技术1:mmap+write()

在这里插入图片描述

mmap ()系统调用函数,使用了虚拟内存,可以把内核空间和用户空间的虚拟地址映射到同一个物理地址,从而减少数据拷贝次数,这样操作系统与用户空间就不需要进行任何的数据拷贝操作
我们可以用mmap()替换read()系统调用函数,mmap()系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样操作系统与用户空间就不需要进行任何数据拷贝操作,这样就减少了一次拷贝。

我们减少拷贝次数的一种方法是调用mmap()来代替read调用:
buf = mmap(diskfd, len);
write(sockfd, buf, len);

最终mmap()+write()就变成了3次拷贝()(2次DMA拷贝+1次CPU拷贝)+4次状态切换。

技术2:sendfile

在这里插入图片描述

sendfile系统调用可以直接把内核缓冲区的数据直接拷贝到socket缓冲区中,不再拷贝到用户态,这样就只由2次上下文切换和3次数据拷贝(2次DMA拷贝+1次CPU拷贝)

技术2:LInux内核2.4对sendfile优化

从LInux内核2.4版本开始起,对于支持网卡支持SG-DMA的技术的情况下,上述过程又进行了优化:
第一步:通过DMA将数据从磁盘上拷贝到内核的缓冲区中
第二步:将缓冲区描述符和数据长度传到socket缓冲区
这样网卡的SG-DMA控制器就可以直接将内核缓冲区的数据拷贝到网卡的缓冲区里,此过程不需要将操作系统内核的数据拷贝到socket缓冲区中,进而减少了一次拷贝,所以上述就成了这样
在这里插入图片描述
至此才真正实现了零拷贝,即只有2次数据拷贝(2次DMA拷贝)+2次状态切换

2次状态切换是少不了的,因为要直接操作硬件,用户进程没有这么大权限,所以至少要发生一次系统调用
其实2.4还是有一次cpu拷贝的,只是拷贝的数据量很小,CPU需要把数据的长度length和偏移量offset拷贝到socket 缓冲区。这个过程相比2.1于拷贝完整的数据,可以忽略

java NIO 对零拷贝技术的实现

mmap

Java NIO有一个MappedByteBuffer的类,可以用来实现内存映射。它的底层是调用了Linux内核的mmap的API。

public class MmapTest {

    public static void main(String[] args) {
        try {
            FileChannel readChannel = FileChannel.open(Paths.get("./jay.txt"), StandardOpenOption.READ);
            MappedByteBuffer data = readChannel.map(FileChannel.MapMode.READ_ONLY, 0, 1024 * 1024 * 40);
            FileChannel writeChannel = FileChannel.open(Paths.get("./siting.txt"), StandardOpenOption.WRITE, StandardOpenOption.CREATE);
            //数据传输
            writeChannel.write(data);
            readChannel.close();
            writeChannel.close();
        }catch (Exception e){
            System.out.println(e.getMessage());
        }
    }
}
/*
说明
1. MappedByteBuffer 可让文件直接在内存(堆外内存)修改
 */
public class MappedByteBufferTest {
    public static void main(String[] args) throws Exception {

        RandomAccessFile randomAccessFile = new RandomAccessFile("1.txt", "rw");
        //获取对应的通道
        FileChannel channel = randomAccessFile.getChannel();

        /**
         * 参数1: FileChannel.MapMode.READ_WRITE 使用的读写模式
         * 参数2: 0 : 可以直接修改的起始位置
         * 参数3:  5: 是映射到内存的大小(不是索引位置) ,即将 1.txt 的多少个字节映射到内存
         * 可以直接修改的范围就是 0-5
         * 实际类型 DirectByteBuffer
         */
        MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);

        mappedByteBuffer.put(0, (byte) 'H');
        mappedByteBuffer.put(3, (byte) '9');
        mappedByteBuffer.put(5, (byte) 'Y');//IndexOutOfBoundsException

        randomAccessFile.close();
        System.out.println("修改成功~~");



    }
}

sendfile

FileChannel的transferTo()/transferFrom(),底层就是sendfile() 系统调用函数。

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();

    }
}

建议:如果涉及到文件传输,transferTo是首选,但是如果涉及到对内存数据的修改选用MappedByteBuffer。


版权声明:本文为m0_45364328原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。