7.java-nio-零拷贝案例和原理

146 阅读7分钟

零拷贝(zero copy)

所谓的零拷贝不是不拷贝,而是不经过CPU拷贝,它还是需要拷贝的(比如将数据从硬盘拷贝到内核态),这个零拷贝是从操作系统(CPU)的角度看的

内核空间和用户空间

电脑上运行的应用程序,在执行一些敏感操作时是需要经过操作系统才能完成的,如磁盘文件读写、内存的读写等等。因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。

因此,操作系统为每个进程分配的内存中一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。

  • 内核空间:主要提供进程调度、内存分配、连接硬件资源等功能
  • 用户空间:提供给各个程序进程的空间,不能直接访问内核空间资源,如果应用程序需要使用到内核空间的资源,则需要通过系统调用来完成。进程从用户空间切换到内核空间,完成相关操作后,再从内核空间切换回用户空间。

如果进程运行于内核空间,被称为进程的内核态

如果进程运行于用户空间,被称为进程的用户态

CPU上下文切换

CPU执行任务的指令需要通过CPU寄存器(CPU 寄存器是CPU内置的内存,虽然容量小但读写速度极快)和程序计数器(存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置)因此叫做CPU上下文

CPU上下文切换需要把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

进程从用户态到内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生CPU上下文的切换

将原来用户态任务的指令位置先保存起来。接着为了执行内核态代码,CPU 寄存器需要更新内核态指令的新位置。最后才是跳转到内核态运行内核任务。

DMA(direct memory access 直接内存通道)

DMA控制器本质上是一块主板上独立的芯片,允许外设设备和内存存储器直接进行IO数据传输,其过程不需要CPU的参与。相当于把内核空间和用户空间临时视作一体,这样将数据从内核态拷贝到用户态,或者从用户态拷贝到内核缓冲区就不会发生cpu上下文切换

传统的IO拷贝

底层执行过程:

1.用户应用进程调用read函数,向操作系统发起IO调用(用户态切换到内核态),CPU将硬盘上的数据使用DMA拷贝到内核缓冲区

2.CPU将数据从内核缓存拷贝到应用程序内存(内核态切换到用户态),read函数返回

3.在应用程序内存,用户进程可以对数据进行操作修改等,然后调用write函数向操作系统发起IO调用(用户态切换到内核态),CPU将数据从用户内存拷贝到socket缓冲区

4.CPU将数据从socket缓冲区使用DMA拷贝到网卡,write函数返回(内核态切换到用户态)

经过以上的步骤完成数据从本地硬盘传输到网络上的过程

可知,传统IO使用了4次拷贝中,前3次都发生了CPU上下文切换,并且使用了2次DMA拷贝,2次CPU拷贝:1.硬盘—>内核(用户态切换到内核态),2.内核—>jvm程序内存(内核态切换到用户态),3.jvm程序内存—>socket缓冲区(用户态切换到内核态),4.socket缓冲区—>网卡,随后write函数返回,发送完成(内核态切换到用户态)

在Java程序中,零拷贝技术分为两种:mmap(内存映射)和sendFile

两种技术的核心思想:减少CPU切换的次数和拷贝的次数,就能加快数据传输的速度

mmap(memory map) :这个系统调用函数可以让部分内核态数据和用户态数据共享,此时不需要将数据从内核拷贝到用户态,用户态也可以对数据进行修改,相当于将内核态数据映射到了用户态内存,所以叫memory map

使用mmap技术后,将文件发送到网络的步骤如下

1.用户应用进程调用mmap函数,向操作系统发起IO调用(用户态切换到内核态),CPU将硬盘上的数据使用DMA拷贝到内核缓冲区

2.mmap函数返回(内核态切换到用户态),用户进程可以对数据进行操作修改等

3.用户进程调用write函数向操作系统发起IO调用(用户态切换到内核态),CPU将数据从内核缓冲区使用拷贝到socket缓冲区

4.CPU将数据从socket缓冲区使用DMA拷贝到网卡,write函数返回(内核态切换到用户态)

经过了mmap的优化后,数据的读写比传统IO少了一次拷贝的过程,并且少的那次拷贝还是CPU拷贝

mmap使用了3次拷贝和4次上下文切换:硬盘—>内核(用户态切换到内核态),mmap函数返回(内核态切换到用户态),内核—>socket缓冲区(内核态切换到用户态),socket缓冲区—>网卡,随后write函数返回(内核态切换到用户态)

sendFile: sendfile是Linux2.1内核版本后引入的一个系统调用函数,它专门用来传输数据,并且在内核态中完成传输操作。避免多次的CPU上下文切换。linux 2.4版本之后,对sendfile做了优化升级,引入SG-DMA技术,让DMA可以直接从内核空间缓冲区中将数据读取到网卡。

使用sendfile系统调用后,将文件发送到网络的步骤如下

1.用户进程发起sendfile系统调用(用户态切换到内核态), CPU将硬盘上的数据使用DMA拷贝到内核缓冲区

2.CPU把内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到socket缓冲区

3.CPU把数据从socket缓冲区使用DMA拷贝到网卡,sendfile调用返回(内核态切换到用户态)

可以发现,sendfile系统调用+DMA控制器实现的零拷贝,只发生了2次CPU上下文切换,以及2次DMA拷贝。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。

Java NIO对mmap技术的实现

Java NIO中有一个MappedByteBuffer类,其底层是调用了Linux内核的mmap的API

public class ZeroCopyTest {
​
    @Test
    public void mmapTest() throws IOException {
​
        //创建FileChannel通道,并赋予可读权限
        FileChannel fileChannel=FileChannel.open(Paths.get("C:\Users\test\Desktop\aaa.jpeg"), StandardOpenOption.READ);
        
        //创建mmap容器,并读取指定的数据到容器中
        MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
​
        //创建目标文件的通道,并赋予创建、写入权限
        FileChannel writeChannel = FileChannel.open(Paths.get("C:\Users\test\Desktop\bbb.jpeg"), StandardOpenOption.CREATE,StandardOpenOption.WRITE);
​
        //数据传输
        writeChannel.write(map);
        System.out.println("写入完成");
​
        fileChannel.close();
        writeChannel.close();
​
​
    }
}
Java NIO对sendFile技术的实现

FileChannel的transferTo()/transferFrom()方法,底层就是通过sendfile() 系统调用函数来实现,Kafka 这个开源项目也是用到它

public class ZeroCopyTest {
​
    @Test
    public void sendFileTest() throws IOException {
    
        FileChannel fileChannel=FileChannel.open(Paths.get("C:\Users\test\Desktop\aaa.jpeg"), StandardOpenOption.READ);
    
        FileChannel writeChannel = FileChannel.open(Paths.get("C:\Users\test\Desktop\bbb.jpeg"), StandardOpenOption.CREATE,StandardOpenOption.WRITE);
    
        //使用transferTo数据传输,指定数据传输的大小
        fileChannel.transferTo(0,fileChannel.size(),writeChannel);
        
        //使用transferFrom来拷贝也可以,只是调用者需要反过来
        //writeChannel.transferFrom(fileChannel,0,fileChannel.size());
        
        System.out.println("写入完成");
        fileChannel.close();
        writeChannel.close();
    
    }
}