下载模块使用文件内存映射来提供IO效率

简单介绍下传统IO的方式

IO,其实意味着:数据不停地搬入搬出缓冲区而已(使用了缓冲区)。比如,用户程序发起读操作,导致“ syscall read ”系统调用,就会把数据搬入到 一个buffer中;用户发起写操作,导致 “syscall write ”系统调用,将会把一个 buffer 中的数据 搬出去(发送到网络中 or 写入到磁盘文件)

普通的IO处理的流程图:

  1. 将数据从磁盘读数据到内核缓冲区,由DMA来完成
  2. 然后内核将内核缓冲区的数据拷贝到用户空间的用户进程
  3. 这样完成了数据的读

一般Java应用程序读取数据代码如下:

1
2
3
4
5
byte[] b = new byte[4096];
long len;
while((len = inputStream.read(b))>=0) {

}

当执行到read()方法时,底层执行了很多操作的:

  1. 内核给磁盘控制器发指令:我要读磁盘上的某块磁盘块上的数据。
  2. 在DMA的控制下,把磁盘上的数据读入到内核缓冲区。
  3. 内核把数据从内核缓冲区拷贝到用户缓冲区。

从上面几步我们可以分析出:

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

  2. 对于磁盘块的读取而言,每次访问磁盘读数据时,并不是读任意大小的数据的,而是每次读一个磁盘块或者若干个磁盘块(这是因为访问磁盘操作代价是很大的,而且我们也相信局部性原理) 因此,就需要有一个“中间缓冲区”–即内核缓冲区。先把数据从磁盘读到内核缓冲区中,然后再把数据从内核缓冲区搬到用户缓冲区。


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

  3. DMA为什么不直接将磁盘上的数据读入到用户缓冲区呢?一方面是 ⓑ中提到的内核缓冲区作为一个中间缓冲区。用来“适配”用户缓冲区的“任意大小”和每次读磁盘块的固定大小。另一方面则是,用户缓冲区位于用户态空间,而DMA读取数据这种操作涉及到底层的硬件,硬件一般是不能直接访问用户态空间的(OS的原因吧)

    综上,由于DMA不能直接访问用户空间(用户缓冲区),普通IO操作需要将数据来回地在 用户缓冲区 和 内核缓冲区移动,这在一定程序上影响了IO的速度。那有没有相应的解决方案呢?

内存映射

内存映射IO,也即JAVA NIO中提到的内存映射文件或者说 直接内存,示例图如下:

从上图可以看出:内核空间的 buffer 与 用户空间的 buffer 都映射到同一块 物理内存区域。

它的主要特点如下:

  1. 对文件的操作不需要再发read 或者 write 系统调用了—The user process sees the file data asmemory, so there is no need to issue read() or write() system calls.

  2. 当用户进程访问“内存映射文件”地址时,自动产生缺页错误,然后由底层的OS负责将磁盘上的数据送到内存。

这就是是JAVA NIO中提到的内存映射缓冲区(Memory-Mapped-Buffer)它类似于JAVA NIO中的直接缓冲区(Directed Buffer)。MemoryMappedBuffer可以通过java.nio.channels.FileChannel.java(通道)的 map方法创建。

使用内存映射缓冲区来操作文件,它比普通的IO操作读文件要快得多。甚至比使用文件通道(FileChannel)操作文件 还要快。因为,使用内存映射缓冲区操作文件时,没有显示的系统调用(read,write),而且OS还会自动缓存一些文件页(memory page)

操作系统的内存区域分布

内存映射文件和之前说的 标准IO操作最大的不同之处就在于它虽然最终也是要从磁盘读取数据,但是它并不需要将数据读取到OS内核缓冲区,而是直接将进程的用户私有地址空间中的一部分区域与文件对象建立起映射关系,就好像直接从内存中读、写文件一样,速度当然快了。

为了说清楚这个,我们以Linux操作系统为例子,看下图:

Linux中的进程虚拟存储器,即进程的虚拟地址空间,如果你的机子是 32 位,那么就有 2^32 = 4G的虚拟地址空间,我们可以看到图中有一块区域: “Memory mapped region for shared libraries” ,这段区域就是在内存映射文件的时候将某一段的虚拟地址和文件对象的某一部分建立起映射关系,此时并没有拷贝数据到内存中去,而是当进程代码第一次引用这段代码内的虚拟地址时,触发了缺页异常,这时候OS根据映射关系直接将文件的相关部分数据拷贝到进程的用户私有空间中去,当有操作第N页数据的时候重复这样的OS页面调度程序操作。注意啦,原来内存映射文件的效率比标准IO高的重要原因就是因为少了把数据拷贝到OS内核缓冲区这一步(可能还少了native堆中转这一步)。

1
2
3
4
File file = new File("vanda.apk");  
FileInputStream in = new FileInputStream(file);
FileChannel channel = in.getChannel();
MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,channel.size());

这样就获取到了内存映射MappedByteBuffer。

总结来说:

内存映射文件是将硬盘上文件的位置与进程逻辑地址空间中一块大小相同的区域之间一一对应, 建立内存映射由mmap()系统调用将文件直接映射到用户空间,mmap()中没有进行数据拷贝,真正的数据拷贝是在缺页中断处理时进行的,mmap()会返回一个指针ptr,它指向进程逻辑地址空间中的一个地址,要操作其中的数据时即第一次访问ptr指向的内存区域,必须通过MMU将逻辑地址转换成物理地址,MMU在地址映射表中是无法找到与ptr相对应的物理地址的,也就是MMU失败,将产生一个缺页中断,缺页中断的中断响应函数会通过mmap()建立的映射关系,从硬盘上将文件读取到物理内存中,这个过程只进行了一次数据拷贝。因此,内存映射的效率要比read/write调用效率高。