【源码剖析】Java NIO 包中的 MappedByteBuffer

[复制链接]
发表于 2025-11-10 09:26:02 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?立即注册

×

1. 媒介

上一篇文章我们先容了 HeapByteBuffer 的源码,这篇文章我们来先容下 MappedByteBuffer,这个 MappedByteBuffer 是 DirectByteBuffer 的父类。


  • 【源码剖析】Java NIO 包中的 Buffer
  • 【源码剖析】Java NIO 包中的 ByteBuffer
  • 【源码剖析】Java NIO 包中的 HeapByteBuffer
2. MappedByteBuffer

MappedByteBuffer 是 ByteBuffer 的子类,体现一个直接字节缓冲区,其内容是文件的内存映射地区。通过 MappedByteBuffer,步伐可以直接对文件内容举行读写操纵,而无需通过传统的 I/O 流或通道。
相比传统的文件 IO 操纵,比如 FileInputStream 和 FileOutputStream,这种方式可以直接对内存中的数据举行操纵,操纵体系会负责将内存中的更改同步到磁盘文件中。
MappedByteBuffer 通过 FileChannel 的 map 方法创建,创建的时间可以设置三种模式:

  • MapMode.READ_ONLY:只读模式,映射的缓冲区是只读的
  • MapMode.READ_WRITE:读写模式,映射的缓冲区是可读写的,对缓冲区的修改会同步到文件中
  • MapMode.PRIVATE:私有模式,映射的缓冲区是可写的,但修改不会同步到文件中,而是创建一个私有副本
但是由于 MappedByteBuffer 利用的是堆外内存,以是如果实行映射过大的文件,大概会导致内存不敷(OutOfMemoryError),毕竟内存映射文件的巨细受操纵体系和可用物理内存的限定。
以是末了总结一下,当须要频仍读写大文件,大概须要随机文件访问的时间就可以利用这个 MappedByteBuffer。

3. 例子

起首我们须要天生一个 1G 的文件。
  1. public class FileTest {
  2.     public static void main(String[] args) {
  3.         String filePath = "D:\\学习资料\\计算机编程语言java学习\\后台\\JDK源码\\jdk1.8Source\\src\\test\\file\\hello.txt"; // 生成的文件路径
  4.         long fileSizeInBytes = 1024L * 1024 * 1024; // 1GB
  5.         try {
  6.             generateFile(filePath, fileSizeInBytes);
  7.             System.out.println("文件生成成功!路径: " + filePath);
  8.         } catch (IOException e) {
  9.             System.err.println("文件生成失败: " + e.getMessage());
  10.         }
  11.     }
  12.     /**
  13.      * 生成指定大小的文件,内容为 "helloWorld" 的重复填充
  14.      *
  15.      * @param filePath      文件路径
  16.      * @param fileSizeInBytes 文件大小(字节)
  17.      * @throws IOException 如果写入失败
  18.      */
  19.     public static void generateFile(String filePath, long fileSizeInBytes) throws IOException {
  20.         // "helloWorld" 的字节数
  21.         byte[] content = "helloWorld".getBytes();
  22.         int contentLength = content.length;
  23.         try (FileOutputStream fos = new FileOutputStream(filePath);
  24.              BufferedOutputStream bos = new BufferedOutputStream(fos)) {
  25.             // 写入次数
  26.             long len = fileSizeInBytes / contentLength;
  27.             // 一次写入 helloWorld 字节数
  28.             for (long i = 0; i < len; i++) {
  29.                 bos.write(content);
  30.             }
  31.             // 剩余字节
  32.             long remainingBytes = fileSizeInBytes % contentLength;
  33.             if (remainingBytes > 0) {
  34.                 bos.write(content, 0, (int) remainingBytes);
  35.             }
  36.         }
  37.     }
  38. }
复制代码
天生了 hello.txt 之后,可以看下面图。

天生 1G 的文件之后我们再来看下传统的 IO 读取数据和 MappedByteBuffer 读取数据的服从。
  1. public class MappedByteBufferPerformance {
  2.     public static void main(String[] args) throws Exception {
  3.         String filePath = "D:\\学习资料\\计算机编程语言java学习\\后台\\JDK源码\\jdk1.8Source\\src\\test\\file\\hello.txt"; // 生成的文件路径
  4.         long fileSize = 1024 * 1024 * 1024; // 1GB
  5.         long startTime = System.currentTimeMillis();
  6.         try (RandomAccessFile file = new RandomAccessFile(filePath, "r");
  7.              FileChannel channel = file.getChannel()) {
  8.             MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_ONLY, 0, fileSize);
  9.             // 读取文件内容
  10.             while (buffer.hasRemaining()) {
  11.                 buffer.get(); // 读取一个字节
  12.             }
  13.         }
  14.         long endTime = System.currentTimeMillis();
  15.         System.out.println("MappedByteBuffer 读取时间: " + (endTime - startTime) + " ms");
  16.     }
  17. }
复制代码
起首上面是 MappedByteBuffer 的读取,统共用了 317 ms,如下图所示。

下面我们再来看下利用传统 IO 来读取文件的耗时。
  1. public class BufferedIOPerformance {
  2.     public static void main(String[] args) throws Exception {
  3.         String filePath = "D:\\学习资料\\计算机编程语言java学习\\后台\\JDK源码\\jdk1.8Source\\src\\test\\file\\hello.txt"; // 生成的文件路径
  4.         long startTime = System.currentTimeMillis();
  5.         try (FileInputStream fis = new FileInputStream(filePath);
  6.              BufferedInputStream bis = new BufferedInputStream(fis)) {
  7.             // 读取文件内容
  8.             while (bis.read() != -1) {
  9.                 // 读取一个字节
  10.             }
  11.         }
  12.         long endTime = System.currentTimeMillis();
  13.         System.out.println("普通 I/O 读取时间: " + (endTime - startTime) + " ms");
  14.     }
  15. }
复制代码
平凡 IO 的读取耗时如下:

以是这里总结下读取的结果:
操纵方式文件巨细读取时间备注MappedByteBuffer1 GB317 ms直接内存映射,服从极高BufferedInputStream1 GB19552 ms带缓冲区的平凡 I/O,速率较慢
4. 属性

MappedByteBuffer 中只有一个属性 fd,其他属性都在父类 ByteBuffer 中。
private final FileDescriptor fd; 是 Java 中用于体现操纵体系文件形貌符的对象。它答应 Java 步伐与底层的文件体系举行交互。说白了这玩意就是用来映射文件到内存的。

5. 构造器

  1. MappedByteBuffer(int mark, int pos, int lim, int cap, // package-private
  2.                  FileDescriptor fd)
  3. {
  4.     super(mark, pos, lim, cap);
  5.     this.fd = fd;
  6. }
  7. MappedByteBuffer(int mark, int pos, int lim, int cap) {
  8.     super(mark, pos, lim, cap);
  9.     this.fd = null;
  10. }
复制代码
这两个构造器实在就是一个指定了文件形貌符,一个没有指定。

6. mappingOffset、mappingAddress、mappingLength

  1. private long mappingOffset() {
  2.         // 页大小
  3.     int ps = Bits.pageSize();
  4.     // 求直接内存的偏移量
  5.     long offset = address % ps;
  6.     // 确保一定是正数
  7.     return (offset >= 0) ? offset : (ps + offset);
  8. }
  9. private long mappingAddress(long mappingOffset) {
  10.         // address 表示缓冲区的起始地址
  11.         // mappingOffset 是上面的偏移量
  12.     return address - mappingOffset;
  13. }
  14. private long mappingLength(long mappingOffset) {
  15.     return (long)capacity() + mappingOffset;
  16. }
复制代码
第一个方法 mappingOffset 获取的是 MappedByteBuffer 的内存所在相对于内存页面起始位置的偏移量, Bits.pageSize(): 这内里返回的是操纵体系的内存分页巨细,一样平常是 4KB 大概 8KB,这里取决于用什么操纵体系。在举行内存映射的时间可以用这个方法求出偏移量来举行内存对齐。
第二个方法 mappingAddress 用来盘算内存页面的起始所在,这里的 mappingOffset 一样平常就是上面的 mappingOffset 方法。address - mappingOffset 这个方法就是利用缓冲区 ByteBuffer 的起始所在减去偏移量。
第三个方法 mappingLength 求出的是内存映射文件的总长度,也就是 mmap 文件映射的内容地区。
上面这几个方法就是获取 MappedByteBuffer 的各种所在信息,那为什么又要有一个偏移量呢?我们知道操纵体系分配最小单位是一个页,以是当利用 mmap 映射操纵体系内存的时间分配的内存总是一个页的起始位置。

固然我们获取了 MappedByteBuffer,但是这个 MappedByteBuffer 的起始位置有大概不是一个页的起始位置,也就是说上面图中 mappingAddress 是页的起始位置,但是 MappedByteBuffer 内里的起始所在是 address。操纵体系分配内存肯定是一个页来分配的,以是 MappedByteBuffer 的起始所在和现实分配的有大概不一样,相差就是 mappingOffset。上面的 mappingOffset 求出来的就是 mappingAddress -> address 之间的间隔,而 mappingAddress 求出来的就是操纵体系内核现实调用 mmap 分配的内存页出发点,就是上图的 mappingAddress,末了一个方法 mappingLength 求出来的就是 mmap 现实分配的内存容量。



7. isLoaded 判定内存是否还在内存中

  1. public final boolean isLoaded() {
  2.     // 判断 MappedByteBuffer 有没有映射到一个文件
  3.     checkMapped();
  4.     // 如果起始地址为 0 或者容量为 0
  5.     if ((address == 0) || (capacity() == 0))
  6.         // 表示已经不在物理内存里面了
  7.         return true;
  8.     // 获取 mmap 分配的内存的起始位置,也就是图中的 mappingOffset
  9.     long offset = mappingOffset();
  10.     // MappedBuffer 实际映射的内存区域大小 也是 mmap 实际分配的内存大小
  11.     long length = mappingLength(offset);
  12.     // mappingAddress(offset) 获取实际的映射起始位置 mapPosition
  13.     // Bits.pageCount(length) 表示分配了多少个页
  14.     // 调用 native 方法
  15.     return isLoaded0(mappingAddress(offset), length, Bits.pageCount(length));
  16. }
  17. private native boolean isLoaded0(long address, long length, int pageCount);
复制代码


  • 如果结果是 true,体现缓冲区的内容很大概已经驻留在物理内存中,访问这些数据时不会触发假造内存页错误或 I/O 操纵。
  • 如果返回 false,并不肯定体现缓冲区的内容没有驻留在物理内存中,大概只是部分数据不在物理内存中。

8. load 方法将 ByteBuffer 加载到 Page Cache 中

  1. public final MappedByteBuffer load() {
  2.     // 判断文件描述符是不是空
  3.     checkMapped();
  4.     if ((address == 0) || (capacity() == 0))
  5.         return this;
  6.     // 获取 mmap 内存地址到 MappedByteBuffer 的距离
  7.     long offset = mappingOffset();
  8.     // 获取 mmap 分配的内存长度
  9.     long length = mappingLength(offset);
  10.     // 调用 native 将 MappedByteBuffer 中的内容预读到 page cache 中
  11.     load0(mappingAddress(offset), length);
  12.     // Read a byte from each page to bring it into memory. A checksum
  13.     // is computed as we go along to prevent the compiler from otherwise
  14.     // considering the loop as dead code.
  15.     Unsafe unsafe = Unsafe.getUnsafe();
  16.     // 一个页的大小
  17.     int ps = Bits.pageSize();
  18.     // 这个 ByteBuffer 分配了多少个页
  19.     int count = Bits.pageCount(length);
  20.     // 获取 mmap 映射地址的起始地址
  21.     long a = mappingAddress(offset);
  22.     byte x = 0;
  23.     // 从 mmap 起始地址开始遍历所有页,每遍历一次访问一下都会发生缺页中断,
  24.     // 同时将 MappedByteBuffer 和 Page Cache 进行页表映射
  25.     for (int i=0; i<count; i++) {
  26.         x ^= unsafe.getByte(a);
  27.         a += ps;
  28.     }
  29.     if (unused != 0)
  30.         unused = x;
  31.     return this;
  32. }
复制代码
这个方法会将 ByteBuffer 内容内里的数据加载到 Page Cache 中,而且这个方法还会遍历全部页预读一次。由于数据加载到 Page Cache 之后,并不会立即就天生假造内存到物理内存的映射。以是加载到 Page Cache 的物理页之后须要访问一次发生缺页停止,这时间才会天生页表项。

9. force 刷盘

  1. public final MappedByteBuffer force() {
  2.    checkMapped();
  3.    if ((address != 0) && (capacity() != 0)) {
  4.        // 核心逻辑,从 mmap 映射的起始位置开始,将映射的内容进行刷盘
  5.        long offset = mappingOffset();
  6.        force0(fd, mappingAddress(offset), mappingLength(offset));
  7.    }
  8.    return this;
  9. }
  10. private native void force0(FileDescriptor fd, long address, long length);
复制代码
这个方法就是将 Buffer 内里的数据举行刷盘。




如有错误,欢迎指出!!!
回复

使用道具 举报

登录后关闭弹窗

登录参与点评抽奖  加入IT实名职场社区
去登录
快速回复 返回顶部 返回列表