DirectByteBuffer 与 MappedByteBuffer

在此之前,我一直以为 DirectByteBufferMappedByteBuffer 是完全不同的两个类,因为它们的理念似乎并不相同:一个是在堆外分配内存,另一个是使用内存映射(虽然其也占用了堆外内存)。参考大佬的文章:占小狼:深入浅出 MappedByteBuffer,本文将对这两者的关系及内部实现原理进行深入梳理。

前言

在 Java IO 操作中,通常采用 BufferedReaderBufferedInputStream 等带缓冲的 IO 类处理大文件。不过,Java NIO 中引入了一种基于 MappedByteBuffer 操作大文件的方式,其读写性能极高。本文旨在介绍其高性能背后的内部实现原理。

内存管理基础

在深入 MappedByteBuffer 之前,先了解计算机内存管理的几个关键术语:

  1. MMC (Memory Management Controller):CPU 的内存管理单元。
  2. 物理内存:即内存条的内存空间。
  3. 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间),而实际上,它通常被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
  4. 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件。在 Windows 下即 pagefile.sys 文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。
  5. 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由 MMC 发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。

为什么会有虚拟内存和物理内存的区别?

如果正在运行的一个进程,它所需的内存有可能大于内存条容量之和。例如内存条是 256M,程序却要创建一个 2G 的数据区,那么所有数据不可能都加载到物理内存,必然有数据要放到其他介质中(比如硬盘)。待进程需要访问那部分数据时,再调度进入物理内存。

什么是虚拟内存地址和物理内存地址?

假设你的计算机是 32 位,那么它的地址总线是 32 位的,也就是它可以寻址 0x0 ~ 0xFFFFFFFF(4G)的地址空间。但如果你的计算机只有 256M 的物理内存 0x0 ~ 0x0FFFFFFF,同时你的进程产生了一个不在这 256M 地址空间中的地址,那么计算机该如何处理呢?回答这个问题前,先说明计算机的内存分页机制。

计算机会对虚拟内存地址空间(32 位为 4G)进行分页产生页(Page),对物理内存地址空间(假设 256M)进行分页产生页帧(Page Frame)。页和页帧的大小一样,所以虚拟内存页的个数势必要大于物理内存页帧的个数。

在计算机上有一个页表(Page Table),就是映射虚拟内存页到物理内存页的,更确切地说是页号到页帧号的映射,而且是一对一的映射。

问题: 虚拟内存页的个数 > 物理内存页帧的个数,岂不是有些虚拟内存页的地址永远没有对应的物理内存地址空间?

解答: 不是的,操作系统是这样处理的。操作系统有个页面失效(Page Fault)功能。操作系统找到一个最少使用的页帧,使之失效,并把它写入磁盘,随后把需要访问的页放到页帧中,并修改页表中的映射,保证了所有的页都会被调度。

虚拟内存地址和物理内存地址的定义:

  • 虚拟内存地址:由页号(与页表中的页号关联)和偏移量(页的大小,即这个页能存多少数据)组成。

举个例子,有一个虚拟地址它的页号是 4,偏移量是 20,那么它的寻址过程是这样的:首先到页表中找到页号 4 对应的页帧号(比如为 8),如果页不在内存中,则用失效机制调入页,接着把页帧号和偏移量传给 MMC 组成一个物理上真正存在的地址,最后就是访问物理内存的数据了。

MappedByteBuffer 是什么

从继承结构上看,MappedByteBuffer 继承自 ByteBuffer,内部维护了一个逻辑地址 address

示例:通过 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) {
            e.printStackTrace();
        }
    }
}

Map 过程分析

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

FileChannel 中的几个关键变量

  • MapMode mode:内存映像文件访问的方式,共三种:

    1. MapMode.READ_ONLY:只读,试图修改得到的缓冲区将导致抛出异常。
    2. MapMode.READ_WRITE:读/写,对得到的缓冲区的更改最终将写入文件;但该更改对映射到同一文件的其他程序不一定是可见的。
    3. MapMode.PRIVATE:私用,可读可写,但是修改的内容不会写入文件,只是 buffer 自身的改变,这种能力称之为"Copy On Write"。
  • position:文件映射时的起始位置。
  • allocationGranularity:Memory allocation size for mapping buffers,通过 native 函数 initIDs 初始化。

接下来通过分析源码,了解一下 map 过程的内部实现。

1. 通过 RandomAccessFile 获取 FileChannel

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

上述实现可以看出,由于 synchronized,只有一个线程能够初始化 FileChannel

2. 通过 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() {
            try {
                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;
            } catch (Exception e) {
                throw new InternalError(e);
            }
            return null;
        }
    });
}

由于 FileChannelImplDirectByteBuffer 不在同一个包中,所以有权限访问问题,通过 AccessController 类获取 DirectByteBuffer 的构造器进行实例化。DirectByteBuffer 提供了对内存的直接操作能力,MappedByteBuffer 底层复用了这一机制。

Get 过程

MappedByteBufferget 方法最终通过 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,这样就无需调用 readwrite 方法对文件进行读写,通过 address 就能够操作文件。底层采用 unsafe.getByte 方法,通过 address + 偏移量 获取指定内存的数据。

  1. 第一次访问 address 所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面。如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非 JVM 堆内存)。
  2. 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。

性能分析

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

但是通过内存映射的方法访问硬盘上的文件,效率要比 readwrite 系统调用高,这是为什么?

首先我们要知道,MappedByteBuffer 底层使用的是 mmap() 这个函数,其会在内存中开启一个空间,这个空间可以算是属于用户空间。这块空间是使用虚拟地址空间管理的,内核空间的程序和用户空间的程序都可以读写该空间的内容。

  • read():是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝
  • mmap():也是系统调用,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不会经过内核空间。
  • write 数据:用户空间的程序将数据写入到这映射的这块区域,内核会自动将其同步到磁盘上,也不会经过内核空间。

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

总结与深入思考

基本总结

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

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

关于继承关系的思考

看起来一切正常,直到我发现一个问题:MappedByteBuffer 是一个具体类(注:不同 JDK 版本实现可能不同,此处指其行为),而 DirectByteBuffer 与其理念明明完全不一样。一个是利用缺页中断这种机制来实现,可以直接将文件拷贝到内存中而不用经过内核的缓冲区;而另外一个就是在堆外申请了内存。为什么会形成这样的关联?

我的想法是,MappedByteBuffer 其核心原理还是缺页中断的机制,但是其内部没有实现操作内存的方法。map0() 方法返回的是映射的内存的首地址,然后它利用这个首地址去创建了 DirectByteBuffer 这种类型的对象,利用下面的第一个构造函数:

// 操作的内存的地址是外部传入的
DirectByteBuffer(long addr, int cap, Object ob) {
    super(-1, 0, cap, cap);
    address = addr;
    cleaner = null;
    att = ob;
}

对比 DirectByteBuffer 自行分配内存的构造函数:

// 这个构造函数中,addr 是通过 unsafe.allocateMemory(size) 得来的
DirectByteBuffer(int cap) {                   // package-private
    super(-1, 0, cap, cap);
    boolean pa = VM.isDirectMemoryPageAligned();
    int ps = Bits.pageSize();
    long size = Math.max(1L, (long)cap + (pa ? ps : 0));
    Bits.reserveMemory(size, cap);

    long base = 0;
    try {
        base = unsafe.allocateMemory(size);
    } catch (OutOfMemoryError x) {
        Bits.unreserveMemory(size, cap);
        throw x;
    }
    unsafe.setMemory(base, size, (byte) 0);
    if (pa && (base % ps != 0)) {
        // Round up to page boundary
        address = base + ps - (base & (ps - 1));
    } else {
        address = base;
    }
    cleaner = Cleaner.create(this, new Deallocator(base, size, cap));
    att = null;
}

通过 ByteBuffer.allocateDirect(1024) 产生的 ByteBuffer

public static ByteBuffer allocateDirect(int capacity) {
    // 直接创建了 DirectByteBuffer 这个类的实例,利用 DirectByteBuffer(int cap) 这个构造函数
    // 其内部会利用 Unsafe.allocateMemory() 来分配堆外内存,返回内存的首地址
    // DirectByteBuffer 操作的该地址指向的内存
    return new DirectByteBuffer(capacity);
}

而通过 channel.map(FileChannel.MapMode.READ_ONLY, 0, 10) 这种方式产生的对象实例也是 DirectByteBuffer 类型的。不过它使用的不是 DirectByteBuffer(int cap) 这个构造函数,而是使用的 DirectByteBuffer(long addr, int cap, Object ob)。这个明显内存地址是传递进去的,这就是它们最大的不同,即内存的分配方式不同

MappedByteBufferDirectByteBuffer 对内存操作的 API 其实是可以共用的,只是内存的分配方式有所不同而已。上述两种方式创建的其实都是 DirectByteBuffer 类型的实例。


说明: 本文基于 JDK 8 及早期版本的分析。在 JDK 9+ 版本中,由于模块化系统(Project Jigsaw)的引入,对 Unsafe 及反射访问内部 API 进行了更严格的限制,部分实现细节可能有所变化。