[NIO]-零拷贝和内存映射

零拷贝

传统的IO操作,拷贝一个文件:

  1. 数据需要从磁盘拷贝到内核空间,再从内核空间拷到用户空间(JVM)。
  2. 程序可能进行数据修改等操作
  3. 再将数据拷贝到内核空间,内核空间再拷贝到网卡内存,通过网络发送出去(或拷贝到磁盘)。

即数据的读写(这里用户空间发到网络也算作写),都至少需要两次拷贝。
为什么要拷贝出来?这里涉及到jvm的GC机制,当垃圾被回收之后,jvm中的堆内存,可能会被压缩,这时候如果去进行IO操作,数据可能就出现紊乱。所以,需要将数据拷贝一份出来,再进行操作(拷贝的时候jvm确保GC关闭)
当然磁盘到内核空间属于DMA拷贝(DMA即直接内存存取,原理是外部设备不通过CPU而直接与系统内存交换数据)。而内核空间到用户空间则需要CPU的参与进行拷贝,既然需要CPU参与,也就涉及到了内核态和用户态的相互切换,如下图:

  1. 对于操作系统而言,JVM只是一个用户进程,处于用户态空间中。而处于用户态空间的进程是不能直接操作底层的硬件的。而IO操作就需要操作底层的硬件,比如磁盘。因此,IO操作必须得借助内核的帮助才能完成(中断),即:会有用户态到内核态的切换。

  2. 我们写代码 new byte[] 数组时,一般是都是“随意” 创建一个“任意大小”的数组。比如,new byte[128]、new byte[1024]、new byte[4096]….但是,对于磁盘块的读取而言,每次访问磁盘读数据时,并不是读任意大小的数据的,而是:每次读一个磁盘块或者若干个磁盘块(这是因为访问磁盘操作代价是很大的,而且我们也相信局部性原理) 因此,就需要有一个“中间缓冲区”—即内核缓冲区。

  3. 先把数据从磁盘读到内核缓冲区中,然后再把数据从内核缓冲区搬到用户缓冲区。 这也是为什么我们总感觉到第一次read操作很慢,而后续的read操作却很快的原因吧。因为,对于后续的read操作而言,它所需要读的数据很可能已经在内核缓冲区了,此时只需将内核缓冲区中的数据拷贝到用户缓冲区即可,并未涉及到底层的读取磁盘操作,当然就快了。

那我们可能会说:DMA为什么不直接将磁盘上的数据读入到用户缓冲区呢?

  1. 一方面是:前面提到的内核缓冲区作为一个中间缓冲区。用来“适配”用户缓冲区的“任意大小”和每次读磁盘块的固定大小。
  2. 另一方面则是,用户缓冲区位于用户态空间,而DMA读取数据这种操作涉及到底层的硬件,硬件一般是不能直接访问用户态空间的。
    综上,由于DMA不能直接访问用户空间(用户缓冲区),普通IO操作需要将数据来回地在 用户缓冲区 和 内核缓冲区移动,这在一定程序上影响了IO的速度。那有没有相应的解决方案呢?

零拷贝的数据拷贝如下图:

内核态与用户态切换如下图:

改进的地方:

  1. 我们已经将上下文切换次数从4次减少到了2次;
  2. 将数据拷贝次数从4次减少到了3次(其中只有1次涉及了CPU,另外2次是DMA直接存取)。

但这还没有达到我们零拷贝的目标。如果底层NIC(网络接口卡)支持gather操作,我们能进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,socket缓冲区描述符已被修改用来适应这个需求。这种方式不但减少多次的上下文切换,同时消除了需要CPU参与的重复的数据拷贝。用户这边的使用方式不变,而内部已经有了质的改变:

NIO的零拷贝由transferTo()方法实现。transferTo()方法将数据从FileChannel对象传送到可写的字节通道(如Socket Channel等)。在内部实现中,由native方法transferTo0()来实现,它依赖底层操作系统的支持。在UNIX和Linux系统中,调用这个方法将会引起sendfile()系统调用。

NIO的直接内存是由MappedByteBuffer实现的。核心即是map()方法,该方法把文件映射到内存中,获得内存地址addr,然后通过这个addr构造MappedByteBuffer类,以暴露各种文件操作API。

NIO的直接内存映射

首先,它的作用位置处于传统IO(BIO)与零拷贝之间,为何这么说?、

  1. 传统IO,可以把磁盘的文件经过内核空间,读到JVM空间,然后进行各种操作,最后再写到磁盘或是发送到网络,效率较慢但支持数据文件操作。
  2. 零拷贝则是直接在内核空间完成文件读取并转到磁盘(或发送到网络)。由于它没有读取文件数据到JVM这一环,因此程序无法操作该文件数据,尽管效率很高!

而直接内存则介于两者之间,效率一般且可操作文件数据。直接内存(mmap技术)将文件直接映射到内核空间的内存,返回一个操作地址(address),它解决了文件数据需要拷贝到JVM才能进行操作的窘境。而是直接在内核空间直接进行操作,省去了内核空间拷贝到用户空间这一步操作。

NIO中一个重要的类:MappedByteBuffer。
MappedByteBuffer是java nio引入的文件内存映射方案,读写性能极高。它是一个内存映射文件。MappedByteBuffer 将文件直接映射到内存(这里的内存指的是虚拟内存,并不是物理内存)。通常,可以映射整个文件,如果文件比较大的话可以分段进行映射,只要指定文件的那个部分就可以。
FileChannel.map()方法返回。不需要跟磁盘打交道,只需要跟内存交到。因为这个内存映射文件,是可以直接从Java访问的文件,读写的操作会被同时映射到硬盘中。
FileChannel提供了map方法来把文件影射为内存映像文件: MappedByteBuffer map(int mode,long position,long size); 可以把文件的从position开始的size大小的区域映射为内存映像文件,mode指出了 可访问该内存映像文件的方式:

1
2
3
4
READ_ONLY,READ_WRITE,PRIVATE.                    
a. READ_ONLY,(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException.(MapMode.READ_ONLY)
b. READ_WRITE(读/写): 对得到的缓冲区的更改最终将传播到文件;该更改对映射到同一文件的其他程序不一定是可见的。 (MapMode.READ_WRITE)
c. PRIVATE(专用): 对得到的缓冲区的更改不会传播到文件,并且该更改对映射到同一文件的其他程序也不是可见的;相反,会创建缓冲区已修改部分的专用副本。 (MapMode.PRIVATE)

Scattering和Gathering

Scattering:散射->将读取的数据放到ByteBuffer数组里面
Gathering:聚集->将ByteBuffer数组里面的数据读出

有了Scattering和Gathering技术的实现,才能真正的实现零拷贝,因为文件的存储(大文件)往往是分段存储的,而如果没有这两个技术,那么就必须先要将分片存储的数据拷贝到一个缓冲区,再从缓冲区读取到socketBuffer中,这样又损耗了性能了。

FileLock

文件加锁,通过FileChannel的lock方法,就可以给文件加锁了。通过设置shared属性的值,可以分为共享锁(读锁)和排他锁(写锁)

-------------本文结束感谢您的阅读-------------