JavaNIO-MappedByteBuffer

作者: 德彪 | 来源:发表于2017-11-24 17:53 被阅读79次

    内核空间与用户空间

    Kernel space 是 Linux 内核的运行空间,User space 是用户程序的运行空间。为了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。

    内核空间中存放的是内核代码和数据。内核空间是操作系统所在区域。内核代码有特别的权力:它能与设备控制器通讯,控制着用户区域进程的运行状态,等等。最重要的是,所有 I/O 都直接或间接通过内核空间。

    用户空间是常规进程所在区域,进程的用户空间中存放的是用户程序的代码和数据。

    Linux使用两级保护机制:0级供内核使用,3级供用户程序使用。

    当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核运行态(内核态)。此时处理器处于特权级最高的(0级)内核代码中执行,CPU可执行任何指令。当进程在执行用户自己的代码时,则称其处于用户运行态(用户态)。

    32位Linux的虚拟地址空间为0~4G。Linux内核将这4G字节的空间分为两部分。将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为“内核空间”。而将较低的3G字节(从虚拟地址 0x00000000到0xBFFFFFFF),供各个进程使用,称为“用户空间)。每个进程有各自的私有用户空间(0~3G),这个空间对系统中的其他进程是不可见的。最高的1GB字节虚拟内核空间则为所有进程以及内核所共享。

    str = "my string" // 用户空间
    x = x + 2
    file.write(str) // 切换到内核空间
     
    y = x + 4 // 切换回用户空间
    

    上面代码中,第一行和第二行都是简单的赋值运算,在 User space 执行。第三行需要写入文件,就要切换到 Kernel space,因为用户不能直接写文件,必须通过内核安排。第四行又是赋值运算,就切换回 User space。

    分页存储

    操作系统在运行程序时,需要为每一个进程分配内存。比如A进程需要200m,B进程需要300m,c进程需要100m。那么操作系统应该如何为他们分配这些内存呢?

    一种想法是直接分配连续的内存。操作系统维护一个内存列表,每次申请内存时就去这个列表中寻找合适的连续内存块,分配给用户进程。这样会带来一个问题,那就是内存碎片化。由于程序申请内存的大小是不规律的,在经过多次分配之后,内存空间就会变得零碎,产生很多不连续的小的内存碎片,这些碎片无法被程序使用(因为碎片化的内存不是连续的,也不够大)。

    可以通过‘紧凑’的方法将这些碎片拼接成可用的大块内存空间,但是必须要付出很大的开销。因此产生了离散化的分配方式:允许直接将一个紧凑直接分散的装入到许多不相邻的内存块当中。就可以充分的利用内存空间。

    离散分配其中之一的分配方式就是分页:将用户程序的地址空间分为若干个固定大小的区域,称为页。比如,每个页为1kb。相应的将内存空间也分为若干个物理块,和页的大小相同。这样就可以将用户程序的任一页放入任一物理块当中,实现了离散分配。

    在分页系统中,允许将进程的各个页离散的存储在内存的任一物理块当中,为了保证进程能够正确运行,即能够在内存中找到每个页面所对应的物理块,系统为每一个进程建立了一张页面映像表,简称页表。在进程地址空间内的所有页,依次在页表中有一页表项,其中记录了相应页在内存中的物理块号。

    image.png

    在配置了页表之后,进程执行时,通过查找该表,即可找到每页在内存中的物理块号。可见,页表的作用是实现从页号到物理块号的地址映射。

    虚拟内存

    所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件RAM)内存地址。这样做好处颇多,总结起来可分为两大类:

    1. 一个以上的虚拟地址可指向同一个物理内存地址。

    2. 虚拟内存空间可大于实际可用的硬件内存。

    那么,这是如何做到的呢?

    我们会同时运行多个进程,而每个进程占用的内存大小不固定,但是这些进程所需要的内存大小加起来却会超过我们实际的物理内存(比如4g内存),用户感觉到的内存容量会比实际内存容量大的多。这是因为:

    应用程序在运行之前没有必要将之全部装入内存,而仅需将那些当前要运行的少数页面装入内存便可运行,其余部分暂留在磁盘上。程序在运行时,如果他要访问的页已经调入内存,便可继续执行下去;但如果程序所要访问的页面尚未调入内存(缺页),便发出缺页请求(页错误),此时操作系统将利用请求调页功能将他们调入内存,以便程序能够继续执行下去。如果此时内存已满,无法再装入新的页,操作系统还需再利用页的置换功能,将内存中暂时不用的页调到磁盘上,腾出足够的内存空间后,再将要访问的页调入内存,使程序继续执行下去。这样,可以使一个或多个大的用户程序在较小的内存空间中运行。

    联想一下Linux系统在硬盘分区时需要让我们选择一个swap分区,结合上面的知识,可知这个swap分区就是上面置换时提到的磁盘。摘抄一段百度百科对swap的定义:

    Swap分区在系统的物理内存不够用的时候,把硬盘空间中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap分区中,等到那些程序要运行时,再从Swap分区中恢复保存的数据到内存中。

    因此,虚拟内存的实现利用了上面提到的分页存储的方法,同时,需要存储系统需要增加页面置换和页面调度功能。

    我们知道页表的基本作用就是将用户地址空间中的逻辑地址映射为内存空间中的物理地址,为了满足页面的换进换出功能,在页表中增加几个字段:

    image.png

    对上面字段的解释:

    1. 状态位P: 由于在请求分页系统中,只将应用程序的一部分调入内存,还有一部分在磁盘上,所以需要在页表中增加一个存在位字段,指示该夜是否已调入内存,供应用程序参考。

    2. 访问字段A:用于记录本页在一段时间内的访问次数,或已有多长时间未被访问,提供给置换算法在选择换出页面时参考。

    3. 修改位M:标识该页在调入内存后是否被修改过。由于内存中的每一页都在外存上保留一个副本,因此,在置换该页时,若未被修改,就不需要将该页再写回到外存,减少磁盘交互的次数;若已被修改,则必须将该页重写到外存上,保证外存中所保留的副本是最新的。

    4. 外存地址:指出该页在外存上的地址,通常是物理块号,供调入该页时参考。

    回想一下,在前面 **内核空间与用户空间 这一节当中,提到了 Linux的虚拟地址空间为0~4G,从0x00000000到0xFFFFFFFF。这里的虚拟地址,经过MMU的转换,可以映射为物理页号。每一个进程都维护自己的虚拟地址,从虚拟地址中分配内存,实际上底层将这些虚拟地址,通过查询页表映射到物理块号,然后进行相应的置换或者读入。实际上,是所有的进程共享这些物理内存,此时的物理内存相当于一个池(联想 线程池?)。

    image.png

    IO原理

    有了上面的基础,我们再来看一下操作系统中的IO:

    image.png

    进程使用read()系统调用,要求其缓冲区被填满。内核随即向磁盘控制硬件发出命令,要求其从磁盘读取数据。磁盘控制器把数据直接写入内核内存缓冲区,这一步通过 DMA 完成,无需主CPU协助。一旦磁盘控制器把缓冲区装满,内核即把数据从内核空间的临时缓冲区拷贝到进程执行read()调用时指定的缓冲区。

    我们可能会觉得,把数据从内核空间拷贝到用户空间似乎有些多余。为什么不直接让磁盘控制器把数据送到用户空间的缓冲区呢?这样做有几个问题。首先,硬件通常不能直接访问用户空间。其次,像磁盘这样基于块存储的硬件设备操作的是固定大小的数据块,而用户进程请求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间与存储设备的过程中,内核负责数据的分解、再组合工作,因此充当着中间人的角色。

    采用分页技术的操作系统执行 I/O 的全过程可总结为以下几步:

    1. 确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数据可能跨越多个文件系统页,而且这些页可能也不连续。

    2. 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。

    3. 在内存页与磁盘上的文件系统页之间建立映射。

    4. 为每一个内存页产生页错误。

    5. 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。

    6. 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性信息。

    内存映射文件

    传统的文件 I/O 是通过用户进程发布read()和write()系统调用来传输数据的。比如FileInputStream.read(byte b[]),实际上是调用了read()系统调用完成数据的读取。回想上一篇文章FileInputStream.read(byte b[])会造成几次数据拷贝呢?

    1. 从磁盘到内核缓冲区的拷贝

    2. 内核缓冲区到JVM进程直接缓冲区的拷贝

    3. JVM直接缓冲区到FileInputStream.read(byte b[])中byte数组b指向的堆内存的拷贝

    可见,传统的IO要经历至少三次数据拷贝才可以把数据读出来,即使是使用直接缓冲区DirectBuffer,也需要至少两次拷贝过程。

    我们知道,设备控制器不能通过 DMA 直接存储到用户空间,但是利用虚拟内存一个以上的虚拟地址可指向同一个物理内存地址这个特点,则可以把内核空间地址与用户空间的虚拟地址映射到同一个物理地址,这样,DMA 硬件(只能访问物理内存地址)就可以填充对内核与用户空间进程同时可见的缓冲区。

    image.png

    这样的话,就省去了内核与用户空间的往来拷贝,但前提条件是,内核与用户缓冲区必须使用相同的页对齐,缓冲区的大小还必须是磁盘控制器块大小的倍数。

    内存映射 I/O 使用文件系统建立从用户空间直到可用文件系统页的虚拟内存映射。这样做有几个好处:

    • 用户进程把文件数据当作内存,所以无需发布read()或write()系统调用。

    • 当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件得到更新。

    • 操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管理。

    • 数据总是按页对齐的,无需执行缓冲区拷贝。

    • 大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。

    MappedByteBuffer

    了解了上面的内容,我们知道在操作系统和硬件层面实际上是为我们提供了内存映射文件这样的机制的。在java1.4之后,java也提供了对应的接口,可以让我们利用操作系统这一特性,提高文件读写性能,那就是MappedByteBuffer。

    MappedByteBuffer继承自ByteBuffer,MappedByteBuffer被abstract修饰,所以他不能被实例化。我们可以调用FileChannel.map()方法获取一个MappedByteBuffer:

    FileInputStream inputStream = new FileInputStream(file);
    FileChannel channel = inputStream.getChannel();
    MappedByteBuffer map = channel.map(MapMode.READ_WRITE, 0, file.length());
    

    这个MappedByteBuffer实际上是其子类DirectByteBuffer实例的引用。也就是说,我们获得的MappedByteBuffer实际上是DirectBuffer类型的缓冲区。也就是说,使用MappedByteBuffer并不会消耗Java虚拟机内存堆。

    public abstract class FileChannel extends AbstractChannel implements ByteChannel, GatheringByteChannel, ScatteringByteChannel {
        // 这里仅列出部分API
        public abstract MappedByteBuffer map(MapMode mode, long position, long size)
        public static class MapMode
        {
            public static final MapMode READ_ONLY
            public static final MapMode READ_WRITE
            public static final MapMode PRIVATE
        }
    }
    

    我们可以创建一个MappedByteBuffer来代表一个文件中字节的某个子范围。例如,要映射100到299(包含299)位置的字节,可以使用下面的代码:buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 100, 200);

    如果要映射整个文件则使用:buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());

    文件映射可以是可写的或只读的。前两种映射模式MapMode.READ_ONLY和MapMode.READ_WRITE意义是很明显的,它们表示希望获取的映射只读还是允许修改映射的文件。请求的映射模式将受被调用map()方法的FileChannel对象的访问权限所限制。如果通道是以只读的权限打开的却请求MapMode.READ_WRITE模式,那么map()方法会抛出一个NonWritableChannelException异常;如果在一个没有读权限的通道上请求MapMode.READ_ONLY映射模式,那么将产生NonReadableChannelException异常。

    第三种模式MapMode.PRIVATE表示想要一个写时拷贝(copy-on-write)的映射。这意味着通过put()方法所做的任何修改都会导致产生一个私有的数据副本并且该副本中的数据只有MappedByteBuffer实例可以看到。该过程不会对底层文件做任何修改。尽管写时拷贝的映射可以防止底层文件被修改,但也必须以read/write权限来打开文件以建立MapMode.PRIVATE映射。只有这样,返回的MappedByteBuffer对象才能允许使用put()方法。

    一个映射一旦建立之后将保持有效,直到MappedByteBuffer对象被施以垃圾收集动作为止。关闭相关联的FileChannel不会破坏映射,只有丢弃缓冲区对象本身才会破坏该映射。

    MappedByteBuffer主要用在对大文件的读写或对实时性要求比较高的程序当中。

    For most operating systems, mapping a file into memory is more expensive than reading or writing a few tens of kilobytes of data via the usual read and write methods. From the standpoint of performance it is generally only worth mapping relatively large files into memory.

    参考java doc FileChannel.map

    参考

    Java nio入门教程详解(三)

    Java nio入门教程详解(二十一)

    《计算机操作系统(第四版)》 西安电子科技大学出版社

    相关文章

      网友评论

        本文标题:JavaNIO-MappedByteBuffer

        本文链接:https://www.haomeiwen.com/subject/htmfbxtx.html