前言
java通常采用BufferedReader,BufferedInputStream等带缓冲的IO类处理文件读写,不过java nio中引入了一种基于MappedByteBuffer操作大文件的方式,其读写性能极高。MappedByteBuffer引入了内存映射文件的方法,该方案是建立的操作系统的内存管理机制上的。
操作系统的内存管理机制
操作系统的内存分为:物理内存与进程虚拟地址空间(即逻辑地址空间),物理地址大家都知道,就是真实的物理,那什么是进程虚拟地址空间?
原来当每次创建一个进程的时候,操作系统都会为该进程分配一块虚拟地址空间,如果是32位的操作系统就是4GB大小,之所以是4GB,是因为在32位的操作系统中,一个指针长度是4字节,而4字节指针的寻址能力是从0x00000000~0xFFFFFFFF,最大值0xFFFFFFFF表示的即为4GB大小的容量。这4GB的虚拟地址空间分为2GB用户空间,2GB内核空间,进程应用程序只能访问自己对应的那2GB用户空间,而2GB的内核空间数据被所有应用程序共享,但是应用程序是不能直接访问。这两种地址空间的产生是为了隔离应用程序的数据,防止被除自己以为的其他应用程序恶意篡改。
此外,为了提高内存使用效率产生了分页机制,将物理内存和进程虚拟地址空间进行分页,页的大小由CPU决定,并且对与这两种地址空间产生的页的大小是相同的,例如,如果按照每页4KB的大小,4GB虚拟地址空间共可以分成1048576个页,512M的物理内存可以分为131072个页。显然虚拟空间的页数要比物理空间的页数多得多,在程序运行时,用到哪些页的数据就加载哪些页的数据到内存进行分配内存,并建立虚拟地址空间中的页和刚分配的物理内存页间的映射,没用到的页暂时保留在硬盘上。
一个完整的可执行应用程序的装载过程如下:一个可执行文件其实就是一些编译好的数据和指令的集合,它也会被分成很多页,为其分配虚拟地址空间的过程中会创建将来要进行内存映射的数据结构,这种数据结构就是页目和页表,当创建完这种数据结构之后,将把应用程序的数据一一映射到虚拟地址空间相应的页中,这时并没有真正将数据加载到内存,当CPU访问程序中用到的某一个虚拟地址,发现该地址并没有相关联的物理地址时,CPU会认为这是个页错误(Page Fault),从而知道操作系统还未给该虚拟页分配内存,CPU会将控制权交还给操作系统,操作系统在物理内存中为其分配页面,然后再将这个物理页面与虚拟空间中的虚拟页面映射起来,从而程序得以继续执行,这种页的加载有时候也被叫做缺页中断。值得注意的,当物理内存不够使用时,操作系统可以找到最少使用的页,将其失效,并将其回写到硬盘,修改映射关系,留出空余空间。
MappedByteBuffer原理
从继承结构上看,MappedByteBuffer继承自ByteBuffer,FileChannel提供了map方法把文件映射到进程虚拟地址空间,通常情况可以映射整个文件,如果文件比较大,可以进行分段映射。
FileChannel的Map方法的MapMode参数指定了内存映像文件访问的方式,共三种:
- MapMode.READ_ONLY:只读,试图修改得到的缓冲区将导致抛出异常。
- MapMode.READ_WRITE:读/写,对得到的缓冲区的更改最终将写入文件;但该更改对映射到同一文件的其他程序不一定是可见的。
- MapMode.PRIVATE:私用,可读可写,但是修改的内容不会写入文件,只是buffer自身的改变,这种能力称之为”copy on write”。
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完成文件的映射工作,并返回一个进程虚拟地址addr,第一次文件映射导致OOM,则手动触发垃圾回收,休眠100ms后再次尝试映射,如果失败,则抛出异常。
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; }}); }
最后返回的MappedByteBuffer实例是DirectByteBuffer类型,其实现了对内存的直接操作。
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); }
可以看出,MappedByteBuffer是通过map0方法返回的进程虚拟地址和偏移量进行操作文件,因为map0方法对数据和进程虚拟地址空间进行了映射,通过缺页中断机制可以进行文件的分段加载,代码中使用了unsafe.getByte方法,可见数据是直接分配的物理内存,而不是JVM的内存空间。
性能浅析:
- read()是系统调用,首先将文件从硬盘拷贝到内核空间的一个缓冲区,再将这些数据拷贝到用户空间,实际上进行了两次数据拷贝;
- map()也是系统调用,但没有进行数据拷贝,当缺页中断发生时,直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝。
MappedByteBuffer的缺陷
使用MappedByteBuffer内存占用、文件关闭不确定,被其打开的文件只有在垃圾回收的才会被关闭,而且这个时间点是不确定的。
网络流传的解决方案如下:
AccessController.doPrivileged(new PrivilegedAction() {
public Object run() {
try {
Method getCleanerMethod = buffer.getClass().getMethod("cleaner", new Class[0]);
getCleanerMethod.setAccessible(true);
sun.misc.Cleaner cleaner = (sun.misc.Cleaner)
getCleanerMethod.invoke(byteBuffer, new Object[0]);
cleaner.clean();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
});
话外语:
在利用FileChannel进行map映射内存文件的时候,一个文件可以被多个应用程序进行映射,事实上,这也是一种对于超大型文件在不同进程间数据共享的一种方式。
MappedByteBuffer的实例
public void testMappedByte() throws FileNotFoundException, IOException {
long start = System.currentTimeMillis();
File file = new File("E:\\工作目录\\新项目\\凤舞一期\\backup\\new_show_style_json.txt");
long fileLength = file.length();
final int BUFFER_SIZE = 0x500000;// 5M
MappedByteBuffer inputBuffer = new RandomAccessFile(file, "rw").getChannel().map(FileChannel.MapMode.READ_WRITE,
0, fileLength);
byte[] dst = new byte[BUFFER_SIZE];
int count = 0;
for (int offset = 0; offset < fileLength; offset += BUFFER_SIZE) {
if (fileLength - offset >= BUFFER_SIZE) {
for (int i = 0; i < BUFFER_SIZE; i++)
dst[i] = inputBuffer.get(offset + i);
} else {
for (int i = 0; i < fileLength - offset; i++)
dst[i] = inputBuffer.get(offset + i);
}
String bs = new String(dst, "UTF-8");// 将buffer中的字节转成字符串
String[] ns = bs.split("\n");
for (String s : ns) {
if (s.contains(" -1- ")) {
count++;
System.out.println(s.split("- 1 -")[0]);
}
}
System.out.println();
// String s = IOUtils.toString(new ByteArrayInputStream(dst));
// System.out.println(s);
}
System.out.println("总处理条数:" + count);
long end = System.currentTimeMillis();
System.out.println((end - start) / 1000);// 处理809M的文件,90000条数据,只用了6秒
}