Java盲点攻克「直接内存IO流处理系列」深入分析和研究MappedByteBuffer的实现原理和开发指南

Posted 洛神灬殇

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java盲点攻克「直接内存IO流处理系列」深入分析和研究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

以上是关于Java盲点攻克「直接内存IO流处理系列」深入分析和研究MappedByteBuffer的实现原理和开发指南的主要内容,如果未能解决你的问题,请参考以下文章

Java盲点攻克「数值浮点数精度系列」Double与Float的坑与解决办法以及BigDecimal的取而代之!

深度剖析 | JVM深层系列[HotSpotVM研究系列] JVM调优的"标准参数"的各种陷阱和坑点分析(攻克盲点及混淆点)「 1 」

深度剖析 | JVM深层系列[HotSpotVM研究系列] JVM调优的“标准参数“的各种陷阱和坑点分析(攻克盲点及混淆点)「 1 」

作者推荐 | Java难点攻克「NIO和内存映射性能提升系列」彻底透析NIO底层的内存映射机制原理与Direct Memory的关系

Java难点攻克「大文件处理系列」让我们一起去挑战一下如何读取一个较大或者超大的数据文件

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