Java基础:JavaNIO 之 内存映射文件原理

1. 前言

最近研究Java中内存映射I/O。Java类库中的NIO中的内存映射文件MappedByteBuffer,相对于Java I/O是一个新的功能。特把适合用于处理大文件,在对大文件处理的时候效率极高。本文章将从操作系统I/O调用原理讲解为什么内存映射文件MappedByteBuffer相比较Java I/O性能极高。话不多说,我们开始学习吧。

2. 浅谈Java I/O InputStream Read调用

在Java I/O InputStream中我们可以调用Java API中Read、Write函数进行读取数据流(文件流、网络Socket流)。其实我们从源码中可以分析出来,InputStream(比如FileInputStream)对于Java API Read和Write函数的调用,最终是调用JNI(Java Native Interface)的read、write系统调用。JNI,它提供了若干的API实现了Java和其他语言的通信(主要是C&C++)。最终调用操作系统底层系统调用。

在传统的文件IO操作中,我们都是调用操作系统提供的底层标准IO系统调用函数 read()、write() ,此时调用此函数的进程(在JAVA中即java进程)由当前的用户态切换到内核态,然后OS的内核代码负责将相应的文件数据读取到内核的IO缓冲区,然后再把数据从内核IO缓冲区拷贝到进程的私有地址空间中去,也就是拷贝到了用户缓冲区,这样便完成了一次IO操作。

至于为什么要多此一举搞一个内核IO缓冲区把原本只需一次拷贝数据的事情搞成需要2次数据拷贝呢? 我想学过操作系统或者计算机系统结构的人都知道,这么做是为了减少磁盘的IO操作,为了提高性能而考虑的,因为我们的程序访问一般都带有局部性,也就是所谓的局部性原理,在这里主要是指的空间局部性,即我们访问了文件的某一段数据,那么接下去很可能还会访问接下去的一段数据,由于磁盘IO操作的速度比直接访问内存慢了好几个数量级,所以OS根据局部性原理会在一次 read()系统调用过程中预读更多的文件数据缓存在内核IO缓冲区中,当继续访问的文件数据在缓冲区中时便直接拷贝数据到进程私有空间,避免了再次的低效率磁盘IO操作。在JAVA中当我们采用IO包下的文件操作流,如:

FileInputStream in = new FileInputStream("D:\\java.txt");  
in.read();  

但是这种I/O调用存在频繁操作系统调用,并且每次操作系统调用需要从用户态切换到内核态的问题,因为我们需要不停的read数据,就要不停的调用操作系统底层函数read()、write()函数。为了解决这个问题,Java I/O提供了一种BufferedInputStream这种装饰器类,这个类中会有一段事先缓存的byte[]数据,这样一来,我们每一次 buf_in.read() 时候,BufferedInputStream 会根据情况自动为我们预读更多的字节数据到它自己维护的一个内部字节数组缓冲区中,这样我们便可以减少系统调用次数,相应减少了用户态程序切换到内核态,从而达到其缓冲区的目的。

FileInputStream in = new FileInputStream("D:\\java.txt"); 
BufferedInputStream buf_in = new BufferedInputStream(in); 
buf_in.read();
public  
class BufferedInputStream extends FilterInputStream {  
  
    private static int defaultBufferSize = 8192;  
  
    /** 
     * The internal buffer array where the data is stored. When necessary, 
     * it may be replaced by another array of 
     * a different size. 
     */  
    protected volatile byte buf[];  
  /** 
     * See 
     * the general contract of the <code>read</code> 
     * method of <code>InputStream</code>. 
     * 
     * @return     the next byte of data, or <code>-1</code> if the end of the 
     *             stream is reached. 
     * @exception  IOException  if this input stream has been closed by 
     *              invoking its {@link #close()} method, 
     *              or an I/O error occurs.  
     * @see        java.io.FilterInputStream#in 
     */  
    public synchronized int read() throws IOException {  
    if (pos >= count) {  
        fill();  
        if (pos >= count)  
        return -1;  
    }  
    return getBufIfOpen()[pos++] & 0xff;  
    }

我们可以看到,BufferedInputStream 内部维护着一个 字节数组 byte[] buf 来实现缓冲区的功能,我们调用的 buf_in.read() 方法在返回数据之前有做一个 if 判断,如果 buf 数组的当前索引不在有效的索引范围之内,即 if 条件成立, buf 字段维护的缓冲区已经不够了,这时候会调用 内部的 fill() 方法进行填充,而fill()会预读更多的数据到 buf 数组缓冲区中去,然后再返回当前字节数据,如果 if 条件不成立便直接从 buf缓冲区数组返回数据了。其中getBufIfOpen()返回的就是 buf字段的引用。顺便说下,源码中的 buf 字段声明为 protected volatile byte buf[]; 主要是为了通过 volatile 关键字保证 buf数组在多线程并发环境中的内存可见性.

3. 内存映射文件

BufferedInputStream虽然解决了进程进行频繁的操作系统底层调用的问题(因为我们可以从缓冲区中读取数据,不用进行操作系统read调用),但是他还是不能解决数据从磁盘读取到内核缓冲区,然后内核缓冲区的数据复制移动到用户空间缓冲区。程序还是需要从用户态切换到内核态,然后再进行操作系统调用,并且数据移动和复制了两次。

Java NIO在此后采用了MappedByteBuffer用来解决这个,内存映射文件和之前说的 标准IO操作最大的不同之处就在于它虽然最终也是要从磁盘读取数据,但是它并不需要将数据读取到OS内核缓冲区,而是直接将进程的用户私有地址空间中的一部分区域与文件对象建立起映射关系,就好像直接从内存中读、写文件一样,速度当然快了。为了说清楚这个,我们以 Linux操作系统为例子,看下图:

在这里插入图片描述

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

4. MpappedByteBuffer

java中提供了3种内存映射模式,即:只读(readonly)、读写(read_write)、专用(private) ,对于 只读模式来说,如果程序试图进行写操作,则会抛出ReadOnlyBufferException异常;第二种的读写模式表明了通过内存映射文件的方式写或修改文件内容的话是会立刻反映到磁盘文件中去的,别的进程如果共享了同一个映射文件,那么也会立即看到变化!而不是像标准IO那样每个进程有各自的内核缓冲区,比如JAVA代码中,没有执行 IO输出流的 flush() 或者 close() 操作,那么对文件的修改不会更新到磁盘去,除非进程运行结束;最后一种专用模式采用的是OS的“写时拷贝”原则,即在没有发生写操作的情况下,多个进程之间都是共享文件的同一块物理内存(进程各自的虚拟地址指向同一片物理地址),一旦某个进程进行写操作,那么将会把受影响的文件数据单独拷贝一份到进程的私有缓冲区中,不会反映到物理文件中去。

java中提供了3种内存映射模式,即:只读(readonly)、读写(read_write)、专用(private) ,对于 只读模式来说,如果程序试图进行写操作,则会抛出ReadOnlyBufferException异常;第二种的读写模式表明了通过内存映射文件的方式写或修改文件内容的话是会立刻反映到磁盘文件中去的,别的进程如果共享了同一个映射文件,那么也会立即看到变化!而不是像标准IO那样每个进程有各自的内核缓冲区,比如JAVA代码中,没有执行 IO输出流的 flush() 或者 close() 操作,那么对文件的修改不会更新到磁盘去,除非进程运行结束;最后一种专用模式采用的是OS的“写时拷贝”原则,即在没有发生写操作的情况下,多个进程之间都是共享文件的同一块物理内存(进程各自的虚拟地址指向同一片物理地址),一旦某个进程进行写操作,那么将会把受影响的文件数据单独拷贝一份到进程的私有缓冲区中,不会反映到物理文件中去。

在JAVA NIO中可以很容易的创建一块内存映射区域,代码如下:

File file = new File("E:\\download\\office2007pro.chs.ISO");  
FileInputStream in = new FileInputStream(file);  
FileChannel channel = in.getChannel();  
MappedByteBuffer buff = channel.map(FileChannel.MapMode.READ_ONLY, 0,channel.size());

按照jdk文档的官方说法,内存映射文件属于JVM中的直接缓冲区,还可以通过 ByteBuffer.allocateDirect() ,即DirectMemory的方式来创建直接缓冲区。他们相比基础的 IO操作来说就是少了中间缓冲区的数据拷贝开销。同时他们属于JVM堆外内存,不受JVM堆内存大小的限制。

  • 3
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值