想写这个系列很久了,对自己也是个总结与提高。原来在学JAVA时,那些JAVA入门书籍会告诉你一些规律还有法则,但是用的时候我们一般很难想起来,因为我们用的少并且不知道为什么。知其所以然方能印象深刻并学以致用。
本篇文章针对JAVA中的MMAP的文件映射读写机制,来分析为何很多告诉框架用了这个机制,以及这个机制好在哪里,快在哪里。
本文基于JDK 1.8
JAVA File MMAP原理解析
1. 内存管理术语
- MMC:CPU的内存管理单元。
- 物理内存:即内存条的内存空间。
- 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
- 页面文件:操作系统反映构建并使用虚拟内存的硬盘空间大小而创建的文件,在windows下,即pagefile.sys文件,其存在意味着物理内存被占满后,将暂时不用的数据移动到硬盘上。
- 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由MMC发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。
2. 什么是MMAP
尽管从JDK 1.4版本开始,Java内存映射文件(Memory Mapped Files)就已经在java.nio包中,但它对很多程序开发者来说仍然是一个相当新的概念。引入NIO后,Java IO已经相当快,而且内存映射文件提供了Java有可能达到的最快IO操作,这也是为什么那些高性能Java应用应该使用内存映射文件来持久化数据。 作为NIO的一个重要的功能,Mmap方法为我们提供了将文件的部分或全部映射到内存地址空间的能力,同当这块内存区域被写入数据之后会变成脏页,操作系统会用一定的算法把这些数据写入到文件中,而我们的java程序不需要去关心这些。这就是内存映射文件的一个关键优势,即使你的程序在刚刚写入内存后就挂了,操作系统仍然会将内存中的数据写入文件系统。 另外一个更突出的优势是共享内存,内存映射文件可以被多个进程同时访问,起到一种低时延共享内存的作用。
3. Java MMAP实现
3.1. Java MMAP 与 FileChannel操作文件对比
package com.github.hashZhang.scanfold.jdk.file; import java.io.RandomAccessFile; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; import java.nio.channels.FileChannel; import java.util.Random; public class FileMmapTest { public static void main(String[] args) throws Exception { //记录开始时间 long start = System.currentTimeMillis(); //通过RandomAccessFile的方式获取文件的Channel,这种方式针对随机读写的文件较为常用,我们用文件一般是随机读写 RandomAccessFile randomAccessFile = new RandomAccessFile("./FileMmapTest.txt", "rw"); FileChannel channel = randomAccessFile.getChannel(); System.out.println("FileChannel初始化时间:" + (System.currentTimeMillis() - start) + "ms"); //内存映射文件,模式是READ_WRITE,如果文件不存在,就会被创建 MappedByteBuffer mappedByteBuffer1 = channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024); MappedByteBuffer mappedByteBuffer2 = channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024); System.out.println("MMAPFile初始化时间:" + (System.currentTimeMillis() - start) + "ms"); start = System.currentTimeMillis(); testFileChannelSequentialRW(channel); System.out.println("FileChannel顺序读写时间:" + (System.currentTimeMillis() - start) + "ms"); start = System.currentTimeMillis(); testFileMMapSequentialRW(mappedByteBuffer1, mappedByteBuffer2); System.out.println("MMAPFile顺序读写时间:" + (System.currentTimeMillis() - start) + "ms"); start = System.currentTimeMillis(); try { testFileChannelRandomRW(channel); System.out.println("FileChannel随机读写时间:" + (System.currentTimeMillis() - start) + "ms"); } finally { randomAccessFile.close(); } //文件关闭不影响MMAP写入和读取 start = System.currentTimeMillis(); testFileMMapRandomRW(mappedByteBuffer1, mappedByteBuffer2); System.out.println("MMAPFile随机读写时间:" + (System.currentTimeMillis() - start) + "ms"); } public static void testFileChannelSequentialRW(FileChannel fileChannel) throws Exception { byte[] bytes = "测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1".getBytes(); byte[] to = new byte[bytes.length]; //分配直接内存,减少复制 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length); //顺序写入 for (int i = 0; i < 100000; i++) { byteBuffer.put(bytes); byteBuffer.flip(); fileChannel.write(byteBuffer); byteBuffer.flip(); } fileChannel.position(0); //顺序读取 for (int i = 0; i < 100000; i++) { fileChannel.read(byteBuffer); byteBuffer.flip(); byteBuffer.get(to); byteBuffer.flip(); } } public static void testFileMMapSequentialRW(MappedByteBuffer mappedByteBuffer1, MappedByteBuffer mappedByteBuffer2) throws Exception { byte[] bytes = "测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2".getBytes(); byte[] to = new byte[bytes.length]; //顺序写入 for (int i = 0; i < 100000; i++) { mappedByteBuffer1.put(bytes); } //顺序读取 for (int i = 0; i < 100000; i++) { mappedByteBuffer2.get(to); } } public static void testFileChannelRandomRW(FileChannel fileChannel) throws Exception { try { byte[] bytes = "测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1测试字符串1".getBytes(); byte[] to = new byte[bytes.length]; //分配直接内存,减少复制 ByteBuffer byteBuffer = ByteBuffer.allocateDirect(bytes.length); //随机写入 for (int i = 0; i < 100000; i++) { byteBuffer.put(bytes); byteBuffer.flip(); fileChannel.position(new Random(i).nextInt(bytes.length*100000)); fileChannel.write(byteBuffer); byteBuffer.flip(); } //随机读取 for (int i = 0; i < 100000; i++) { fileChannel.position(new Random(i).nextInt(bytes.length*100000)); fileChannel.read(byteBuffer); byteBuffer.flip(); byteBuffer.get(to); byteBuffer.flip(); } } finally { fileChannel.close(); } } public static void testFileMMapRandomRW(MappedByteBuffer mappedByteBuffer1, MappedByteBuffer mappedByteBuffer2) throws Exception { byte[] bytes = "测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2测试字符串2".getBytes(); byte[] to = new byte[bytes.length]; //随机写入 for (int i = 0; i < 100000; i++) { mappedByteBuffer1.position(new Random(i).nextInt(bytes.length*100000)); mappedByteBuffer1.put(bytes); } //随机读取 for (int i = 0; i < 100000; i++) { mappedByteBuffer2.position(new Random(i).nextInt(bytes.length*100000)); mappedByteBuffer2.get(to); } } }
在这里,我们初始化了一个文件,并把它映射到了128M的内存中。分FileChannel还有MMAP的方式,通过顺序或随机读写,写了一些内容并读取一部分内容。
运行结果是:
FileChannel初始化时间:7ms MMAPFile初始化时间:8ms FileChannel顺序读写时间:420ms MMAPFile顺序读写时间:20ms FileChannel随机读写时间:860ms MMAPFile随机读写时间:45ms
可以看到,通过MMAP内存映射文件的方式操作文件,更加快速,并且性能提升的相当明显。
3.2. Java MMAP 源代码分析
我们可以利用strace命令先看看上面程序的系统调用:
strace -c java com/github/hashZhang/scanfold/jdk/file/FileMmapTest
结果如下:
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 99.79 0.410139 205070 2 futex 0.04 0.000151 13 12 mprotect 0.03 0.000127 6 23 mmap 0.03 0.000110 5 23 14 open 0.02 0.000065 22 3 munmap 0.01 0.000058 7 8 read 0.01 0.000053 6 9 close 0.01 0.000052 6 9 fstat 0.01 0.000040 4 10 7 stat 0.01 0.000039 10 4 brk 0.01 0.000036 36 1 clone 0.01 0.000033 11 3 2 access 0.01 0.000025 13 2 readlink 0.01 0.000024 12 2 rt_sigaction 0.00 0.000012 12 1 getrlimit 0.00 0.000012 12 1 set_tid_address 0.00 0.000011 11 1 rt_sigprocmask 0.00 0.000011 11 1 set_robust_list 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 arch_prctl ------ ----------- ----------- --------- --------- ---------------- 100.00 0.410998 117 23 total
我们可以注意到有open、mmap、munmap、close这几个比较重要的系统调用,我们的文件操作基本主要和这几个系统调用有关。
接下来我们来看MMAP相关的源代码:
3.2.1. 初始化内存映射文件Buffer -> MappedByteBuffer
MappedByteBuffer的类关系:
可以看到,DirectByteBuffer是一种MappedByteBuffer。
channel.map(FileChannel.MapMode.READ_WRITE, 0, 128 * 1024 * 1024);
对应的源代码:
FileChannel.java:
public MappedByteBuffer map(FileChannel.MapMode mode, long position, long size) throws IOException { //保证文件还没被关闭,基本上FileChannel的每个方法都会做这个判断 ensureOpen(); //模式不能为空 if (mode == null) throw new NullPointerException("Mode is null"); if (position < 0L) throw new IllegalArgumentException("Negative position"); if (size < 0L) throw new IllegalArgumentException("Negative size"); //不能超过长度限制 if (position + size < 0) throw new IllegalArgumentException("Position + size overflow"); //size不能超过Integer的最大值 //因为在写入数据时,java地址转换为linux内存地址的时候,强制转换成了int类型,所以映射大小不能超过Integer的最大值,也就是<2G(2^31-1) if (size > Integer.MAX_VALUE) throw new IllegalArgumentException("Size exceeds Integer.MAX_VALUE"); int imode = -1; if (mode == FileChannel.MapMode.READ_ONLY) imode = MAP_RO; else if (mode == FileChannel.MapMode.READ_WRITE) imode = MAP_RW; else if (mode == FileChannel.MapMode.PRIVATE) imode = MAP_PV; assert (imode >= 0); if ((mode != FileChannel.MapMode.READ_ONLY) && !writable) throw new NonWritableChannelException(); if (!readable) throw new NonReadableChannelException(); long addr = -1; int ti = -1; try { //线程锁控制,这里用的原生线程锁,限制最多同时有两个线程(进程)同时去映射同一个文件 begin(); ti = threads.add(); if (!isOpen()) return null; //首先要获取文件目前的大小,来判断是否需要扩展文件 long filesize; do { //JNI调用1:调用fstat命令获取文件大小 filesize = nd.size(fd); } while ( //遇到系统EINTR信号(被中断的系统调用)时,要一直重试,因为这不代表调用有错误 (filesize == IOStatus.INTERRUPTED) && isOpen() ); if (!isOpen()) return null; //当映射的文件位置与大小超过文件整体大小时,需要扩展文件 if (filesize < position + size) { // Extend file size if (!writable) { throw new IOException("Channel not open for writing " + "- cannot extend file to required size"); } int rv; do { //JNI调用2: 调用ftruncate扩展文件 rv = nd.truncate(fd, position + size); } while ( //遇到系统EINTR信号(被中断的系统调用)时,要一直重试,因为这不代表调用有错误 (rv == IOStatus.INTERRUPTED) && isOpen() ); if (!isOpen()) return null; } //如果映射size为0,直接返回空的DirectByteBuffer或者只读的DirectByteBufferR //个人感觉这个可以提前,放到判断文件大小之前,但是更严谨的话,应该放到现在的位置 if (size == 0) { addr = 0; // a valid file descriptor is not required FileDescriptor dummy = new FileDescriptor(); if ((!writable) || (imode == MAP_RO)) return Util.newMappedByteBufferR(0, 0, dummy, null); else return Util.newMappedByteBuffer(0, 0, dummy, null); } //计算出页位置, allocationGranularity是系统文件分页大小(pageCache的page大小) int pagePosition = (int)(position % allocationGranularity); //计算出映射起始页位置 long mapPosition = position - pagePosition; //计算需要的大小 long mapSize = size + pagePosition; try { //JNI调用3: mmap addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError x) { // 如果因为内存不足导致的失败,尝试请求Full-GC System.gc(); try { //因为System.gc()只是告诉jvm要做FullGC,但是不一定立刻做,所以这里等待100ms来让JVM FullGC(FullGC时间不算在这个100ms内,因为FullGC是全局中断) Thread.sleep(100); } catch (InterruptedException y) { Thread.currentThread().interrupt(); } try { //重新尝试 addr = map0(imode, mapPosition, mapSize); } catch (OutOfMemoryError y) { //FullGC之后还是没能分配,就抛异常 throw new IOException("Map failed", y); } } // On Windows, and potentially other platforms, we need an open // file descriptor for some mapping operations. FileDescriptor mfd; try { mfd = nd.duplicateForMapping(fd); } catch (IOException ioe) { unmap0(addr, mapSize); throw ioe; } assert (IOStatus.checkAll(addr)); assert (addr % allocationGranularity == 0); int isize = (int)size; //新建一个Unmapper来在GC的时候回收掉mmap出来的内存 //这个回收也是jni调用munmap FileChannelImpl.Unmapper um = new FileChannelImpl.Unmapper(addr, mapSize, isize, mfd); if ((!writable) || (imode == MAP_RO)) { //返回只读的DirectByteBuffer封装的mmap内存 return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um); } else { //返回DirectByteBuffer封装的mmap内存 return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um); } } finally { threads.remove(ti); end(IOStatus.checkAll(addr)); } }