为了解决磁盘IO效率低下的问题,操作系统在进程空间内增加了一片空间,用于与磁盘文件进行地址映射,这部分内存也是虚拟内存地址,通过指针操作这部分内存,系统会自动将处理过的页写回对应的磁盘文件位置,就不需要去调用系统read、write等函数,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
这部分内存映射需要维护一份页表,用于管理内存——文件地址的映射关系,如果当前虚拟内存地址找不到对应的物理地址,就会发生所谓的缺页,缺页时系统会根据地址偏移量在PageCache中查看目标地址是否已经缓存过了,如果有就直接指向该PageCache地址,如果没有就需要将目标文件加载入PageCache中。
通过mmap的映射功能,就能避免IO操作,直接去操作内存,这就是所谓的零拷贝技术。
下面将要从几幅图说起IO到零拷贝。
这是最普通的文件服务器传输文件过程,首先在内核态将文件从物理设备读取到内核空间,这是一次直接直接内存拷贝,然后用户进程需要从内核中将数据读取到用户进程空间,完成读的流程,这是一次CPU拷贝,至此,读的过程完成了,进程需要将数据发送给客户端,这时有需要将数据放到内核空间的socket处,之后通过协议层发送出去。
这整个流程需要两次CPU拷贝、两次直接内存拷贝,还需要不断在内核态用户态切换。(第一种:四次)
第二种模型是引入了mmap,在内核空间与用户空间建立映射关系,就可以让socket空间直接操作内核空间就能完成拷贝功能,还不需要在内核态用户态之间切换,write系统调用使内核将数据从原始内核缓冲区复制到与套接字关联的内核缓冲区中。
这个方式使用mmap代替了read,虽然看上去减少了拷贝,但是缺存在风险。当映射一个文件到内存,然后调用write,在另一个进程write同一个文件时,就会发生系统错误。(第二种:三次)
第三种模型,基于Linux新增引入的sendfile系统调用,不仅能减少文件拷贝,还能减少系统切换,sendfile可以直接完成内核空间的拷贝流程,从内核空间拷贝到套接字空间,由此跳过了用户空间。(第三种:三次)
第四种模型,在内核版本2.4中,对sendfile进行了优化,可以直接从内核空间将数据发送到协议器,还消除了到套接字区域的数据拷贝,对于用户级应用程序没有任何变化。(第四种:两次)
综上,数据发送的流程中数据不会结果多余的拷贝,内核与用户态空间内都不会有多余的备份,这就是所谓的零拷贝技术,基于sendfile与mmap。
说回RocketMQMQ是IO使用的大户,MMap、FileChannel、RandomAccessFile是MQ文件操作最常使用的方法。
RocketMQ支持MMap与FileChannel,默认使用MMap,在PageCache繁忙时,会使用FileChannel,同样也可以避免PageCache竞争锁。
在MappedFile类中,可以看到FileChannel与MappedByteBuffer两个变量,在Java代码中可以通过FileChannel的map方法将文件映射到虚拟内存。
在MappedFile的init方法中也可以看到mmap初始化的过程。
在实际的写入流程中,操作的buffer可能是mmap也可能是TransientStorePool申请来的直接内存,避免页面被换出到交换区。
TransientStorePool是否启用根据TransientStorePoolEnable确定,当开启时,表示优先使用堆外内存存储数据,通过Commit线程刷到内存映射Buffer中。
TransientStorePool是一个简易的池化类,其中包含了池的大小,每个单元存储的大小,存储单元的队列以及存储配置类。具体的初始化操作可以在init方法中看到有循环使用allocateDirect申请JVM外的内存空间,相比于allocate申请到的JVM内的内存,堆外内存操作更加迅速,免去了数据从堆外再次拷贝到堆内的流程。
申请到内存后,取到了申请的内存地址。
Pointer pointer = new Pointer(address); LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize));拿到地址后,创建一个指向该处的指针,调用本地链接库的方法,将该地址的内存锁住,防止释放。
综上,相信你已经对页表、文件系统IO操作有了一定的认识了。