写点什么

☕【Java 深层系列】「并发编程系列」深入分析和研究 MappedByteBuffer 的实现原理和开发指南

作者:浩宇天尚
  • 2022 年 1 月 27 日
  • 本文字数:6427 字

    阅读完需:约 21 分钟

☕【Java深层系列】「并发编程系列」深入分析和研究MappedByteBuffer的实现原理和开发指南

前言介绍

在 Java 编程语言中,操作文件 IO 的时候,通常采用 BufferedReader,BufferedInputStream 等带缓冲的 IO 类处理大文件,不过 java nio 中引入了一种基于 MappedByteBuffer 操作大文件的方式,其读写性能极高,比起 bio 的模型处理方式,它大大的加大了支持解析读取文件的数量和空间。

OS 的内存管理

内存层面的技术名词概念

  • MMU:CPU 的内存管理单元。

  • 物理内存:即内存条的内存空间。

  • 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。

  • 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件,在 windows 下,即 pagefile.sys 文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。

  • 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由 MMC 发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。

虚拟内存和物理内存

正在运行的一个进程,它所需的内存是有可能大于内存条容量之和的,如内存条是 256M,程序却要创建一个 2G 的数据区,那么所有数据不可能都加载到内存(物理内存),必然有数据要放到其他介质中(比如硬盘),待进程需要访问那部分数据时,再调度进入物理内存,而这种场景下,被调度到硬盘的资源空间所占用的存储,我们便将他理解为虚拟内存。

MappedByteBuffer

从大体上讲一下 MappedByteBuffer 究竟是什么。从继承结构上来讲,MappedByteBuffer 继承自 ByteBuffer,所以,ByteBuffer 有的能力它全有;像变动 position 和 limit 指针啦、包装一个其他种类 Buffer 的视图啦,内部维护了一个逻辑地址 address。

“MappedByteBuffer” 会提升速度,变快

  • 为什么快?因为它使用 direct buffer 的方式读写文件内容,这种方式的学名叫做内存映射。这种方式直接调用系统底层的缓存,没有 JVM 和系统之间的复制操作,所以效率大大的提高了。而且由于它这么快,还可以用它来在进程(或线程)间传递消息,基本上能达到和 “共享内存页” 相同的作用,只不过它是依托实体文件来运行的。

  • 还有就是它可以让读写那些太大而不能放进内存中的文件。实现假定整个文件都放在内存中(实际上,大文件放在内存和虚拟内存中),基本上都可以将它当作一个特别大的数组来访问,这样极大的简化了对于大文件的修改等操作。

MappedByteBuffer 的案例用法

FileChannel 提供了 map 方法来把文件映射为 MappedByteBuffer: MappedByteBuffer map(int mode,long position,long size); 可以把文件的从 position 开始的 size 大小的区域映射为 MappedByteBuffer,mode 指出了可访问该内存映像文件的方式,共有三种,分别为:


  • MapMode.READ_ONLY(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException。

  • MapMode.READ_WRITE(读 / 写): 对得到的缓冲区的更改最终将写入文件;但该更改对映射到同一文件的其他程序不一定是可见的(无处不在的 “一致性问题” 又出现了)。

  • MapMode.PRIVATE(专用): 可读可写, 但是修改的内容不会写入文件, 只是 buffer 自身的改变,这种能力称之为”copy on write”

MappedByteBuffer 较之 ByteBuffer 新增的三个方法

  • fore() 缓冲区是 READ_WRITE 模式下,此方法对缓冲区内容的修改强行写入文件

  • load() 将缓冲区的内容载入内存,并返回该缓冲区的引用

  • isLoaded() 如果缓冲区的内容在物理内存中,则返回真,否则返回假

采用 FileChannel 构建相关的 MappedByteBuffer

//一个byte占1B,所以共向文件中存128M的数据int length = 0x8FFFFFF;try (FileChannel channel = FileChannel.open(Paths.get("src/c.txt"),    StandardOpenOption.READ, StandardOpenOption.WRITE);) {  MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length);  for(int i=0;i<length;i++) {    mapBuffer.put((byte)0);  }  for(int i = length/2;i<length/2+4;i++) {     //像数组一样访问     System.out.println(mapBuffer.get(i));  }}
复制代码
实现相关的读写文件的对比处理
import java.io.DataInputStream;import java.io.DataOutputStream;import java.io.FileInputStream;import java.io.FileOutputStream;import java.io.IOException;import java.nio.MappedByteBuffer;import java.nio.channels.FileChannel;import java.nio.file.Paths;import java.nio.file.StandardOpenOption;
public class TestMappedByteBuffer { private static int length = 0x2FFFFFFF;//1G private abstract static class Tester { private String name; public Tester(String name) { this.name = name; } public void runTest() { System.out.print(name + ": "); long start = System.currentTimeMillis(); test(); System.out.println(System.currentTimeMillis()-start+" ms"); } public abstract void test(); } private static Tester[] testers = { new Tester("Stream RW") { public void test() { try (FileInputStream fis = new FileInputStream( "src/a.txt"); DataInputStream dis = new DataInputStream(fis); FileOutputStream fos = new FileOutputStream( "src/a.txt"); DataOutputStream dos = new DataOutputStream(fos);) { byte b = (byte)0; for(int i=0;i<length;i++) { dos.writeByte(b); dos.flush(); } while (dis.read()!= -1) { } } catch (IOException e) { e.printStackTrace(); } } }, new Tester("Mapped RW") { public void test() { try (FileChannel channel = FileChannel.open(Paths.get("src/b.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE);) { MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length); for(int i=0;i<length;i++) { mapBuffer.put((byte)0); } mapBuffer.flip(); while(mapBuffer.hasRemaining()) { mapBuffer.get(); } } catch (IOException e) { e.printStackTrace(); } } }, new Tester("Mapped PRIVATE") { public void test() { try (FileChannel channel = FileChannel.open(Paths.get("src/c.txt"), StandardOpenOption.READ, StandardOpenOption.WRITE);) { MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.PRIVATE, 0, length); for(int i=0;i<length;i++) { mapBuffer.put((byte)0); } mapBuffer.flip(); while(mapBuffer.hasRemaining()) { mapBuffer.get(); } } catch (IOException e) { e.printStackTrace(); } } } }; public static void main(String[] args) { for(Tester tester:testers) { tester.runTest(); } }}
复制代码
测试结果
  • Stream RW->用传统流的方式,最慢,应该是由于用的数据量是 1G,无法全部读入内存,所以它根本无法完成测试。

  • MapMode.READ_WRITE,它的速度每次差别较大,在 0.6s 和 8s 之间波动,而且很不稳定。

  • MapMode.PRIVATE 就稳得出奇,一直是 1.1s 到 1.2s 之间。


无论是哪个速度都是十分惊人的,但是 MappedByteBuffer 也有不足,就是在数据量很小的时候,表现比较糟糕,那是因为 direct buffer 的初始化时间较长,所以建议大家只有在数据量较大的时候,在用 MappedByteBuffer。

map 过程

FileChannel 提供了 map 方法把文件映射到虚拟内存,通常情况可以映射整个文件,如果文件比较大,可以进行分段映射。


FileChannel 中的几个变量:


  • MapMode mode:内存映像文件访问的方式,也就是上面说的三种方式。

  • position:文件映射时的起始位置。

  • allocationGranularity:Memory allocation size for mapping buffers,通过 native 函数 initIDs 初始化。


接下去通过分析源码,了解一下 map 过程的内部实现。通过 RandomAccessFile 获取 FileChannel。


public final FileChannel getChannel() {    synchronized (this) {        if (channel == null) {            channel = FileChannelImpl.open(fd, path, true, rw, this);        }        return channel;    }}
复制代码


上述实现可以看出,由于 synchronized ,只有一个线程能够初始化 FileChannel。通过 FileChannel.map 方法,把文件映射到虚拟内存,并返回逻辑地址 address,实现如下:


public MappedByteBuffer map(MapMode mode, long position, long size)  throws IOException {        int pagePosition = (int)(position % allocationGranularity);        long mapPosition = position - pagePosition;        long mapSize = size + pagePosition;        try {            addr = map0(imode, mapPosition, mapSize);        } catch (OutOfMemoryError x) {            System.gc();            try {                Thread.sleep(100);            } catch (InterruptedException y) {                Thread.currentThread().interrupt();            }            try {                addr = map0(imode, mapPosition, mapSize);            } catch (OutOfMemoryError y) {                // After a second OOME, fail                throw new IOException("Map failed", y);            }        }        int isize = (int)size;        Unmapper um = new Unmapper(addr, mapSize, isize, mfd);        if ((!writable) || (imode == MAP_RO)) {            return Util.newMappedByteBufferR(isize,                                             addr + pagePosition,                                             mfd,                                             um);        } else {            return Util.newMappedByteBuffer(isize,                                            addr + pagePosition,                                            mfd,                                            um);        }}
复制代码


上述代码可以看出,最终 map 通过 native 函数 map0 完成文件的映射工作。


  1. 如果第一次文件映射导致 OOM,则手动触发垃圾回收,休眠 100ms 后再次尝试映射,如果失败,则抛出异常。

  2. 通过 newMappedByteBuffer 方法初始化 MappedByteBuffer 实例,不过其最终返回的是 DirectByteBuffer 的实例,实现如下:


static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {    MappedByteBuffer dbb;    if (directByteBufferConstructor == null)        initDBBConstructor();    dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(          new Object[] { new Integer(size),                         new Long(addr),                         fd,                         unmapper }    return dbb;}// 访问权限private static void initDBBConstructor() {    AccessController.doPrivileged(new PrivilegedAction<Void>() {        public Void run() {            Class<?> cl = Class.forName("java.nio.DirectByteBuffer");                Constructor<?> ctor = cl.getDeclaredConstructor(                    new Class<?>[] { int.class,                                     long.class,                                     FileDescriptor.class,                                     Runnable.class });                ctor.setAccessible(true);                directByteBufferConstructor = ctor;        }});}
复制代码


由于 FileChannelImpl 和 DirectByteBuffer 不在同一个包中,所以有权限访问问题,通过 AccessController 类获取 DirectByteBuffer 的构造器进行实例化。


DirectByteBuffer 是 MappedByteBuffer 的一个子类,其实现了对内存的直接操作。

get 过程

MappedByteBuffer 的 get 方法最终通过 DirectByteBuffer.get 方法实现的。


public byte get() {    return ((unsafe.getByte(ix(nextGetIndex()))));}public byte get(int i) {    return ((unsafe.getByte(ix(checkIndex(i)))));}private long ix(int i) {    return address + (i << 0);}
复制代码


  • map0()函数返回一个地址 address,这样就无需调用 read 或 write 方法对文件进行读写,通过 address 就能够操作文件。底层采用 unsafe.getByte 方法,通过(address + 偏移量)获取指定内存的数据。

  • 第一次访问 address 所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非 jvm 堆内存)。

  • 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。

性能分析

从代码层面上看,从硬盘上将文件读入内存,都要经过文件系统进行数据拷贝,并且数据拷贝操作是由文件系统和硬件驱动实现的,理论上来说,拷贝数据的效率是一样的。

通过内存映射的方法访问硬盘上的文件,效率要比 read 和 write 系统调用高

  • read()是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝;

  • map()也是系统调用,但没有进行数据拷贝,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝。


采用内存映射的读写效率要比传统的 read/write 性能高。

采用 RandomAccessFile 构建相关的 MappedByteBuffer

通过 MappedByteBuffer 读取文件


public class MappedByteBufferTest {    public static void main(String[] args) {        File file = new File("D://data.txt");        long len = file.length();        byte[] ds = new byte[(int) len];        try {            MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")                    .getChannel().map(FileChannel.MapMode.READ_ONLY, 0, len);            for (int offset = 0; offset < len; offset++) {                byte b = mappedByteBuffer.get();                ds[offset] = b;            }            Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");            while (scan.hasNext()) {                System.out.print(scan.next() + " ");            }        } catch (IOException e) {}    }}
复制代码

总结

MappedByteBuffer 使用虚拟内存,因此分配(map)的内存大小不受 JVM 的-Xmx 参数限制,但是也是有大小限制的。如果当文件超出 1.5G 限制时,可以通过 position 参数重新 map 文件后面的内容。MappedByteBuffer 在处理大文件时的确性能很高,但也存在一些问题,如内存占用、文件关闭不确定,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。


javadoc 中也提到:A mapped byte buffer and the file mapping that it represents remain valid until the buffer itself is garbage-collected.*

参考资料

https://blog.csdn.net/qq_41969879/article/details/81629469

发布于: 刚刚阅读数: 3
用户头像

浩宇天尚

关注

🏆InfoQ写作平台-签约作者🏆 2020.03.25 加入

【个人简介】酷爱计算机科学、醉心编程技术、喜爱健身运动、热衷悬疑推理的“极客达人” 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、微服务/分布式体系和算法设计等

评论

发布
暂无评论
☕【Java深层系列】「并发编程系列」深入分析和研究MappedByteBuffer的实现原理和开发指南