在开始之前,先介绍一下Linux的IO结构。
文件系统是内核的功能,是一种工作在内核空间的软件,访问一个文件必须要需要文件系统的存在才可以。Linux 可以支持多达数十种不同的文件系统,它们的实现各不相同,因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口用来对文件系统进行操作。
虚拟文件系统是位于用户空间进程和内核空间中多种不同的底层文件系统的实现之间的一个抽象的接口层,它提供了常见的文件系统对象模型(如 i-node, file object, page cache, directory entry, etc.)和访问这些对象的方法(如 open, close, delete, write, read, create, fstat, etc.),并将它们统一输出,类似于库的作用。从而向用户进程隐藏了各种不同的文件系统的具体实现,这样上层软件只需要和 VFS 进行交互而不必关系底层的文件系统,简化了软件的开发,也使得 linux 可以支持多种不同的文件系统。
上图概括了一次磁盘 write 操作的过程,假设文件已经被从磁盘中读入了 page cache 中。
Block layer 处理所有和块设备相关的操作。block layer 最关键是数据结构是 bio 结构体。bio 结构体是 file system layer 到 block layer 的接口。 当执行一个写操作时,文件系统层将数据写入 page cache(由 block buffer 组成),将连续的块放到一起,组成 bio 结构体,然后将 bio 送至 block layer。
block layer 处理 bio 请求,并将这些请求链接成一个队列,称作 IO 请求队列,这个连接的操作就称作 IO 调度(也叫 IO elevator 即电梯算法).
Buffer I/O 又被称作Standard I/O,大多数文件系统的默认 I/O 操作都是Buffer I/O。在 Linux 的Buffer I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。Buffer I/O 有以下这些优点:
Java中的IO也是Buffer IO。
常见的 FileInputStream/FileOutPutStream/RandomAccessFile/FileChannel
,都是Buffer IO。
在 Linux 的实现中,文件 Cache 分为两个层面,一是 Page Cache,另一个 Buffer Cache,每一个 Page Cache 包含若干 Buffer Cache。内存管理系统和 VFS 只与 Page Cache 交互,内存管理系统负责维护每项 Page Cache 的分配和回收,同时在使用 memory map 方式访问时负责建立映射;VFS 负责 Page Cache 与用户空间的数据交换。而具体文件系统则一般只与 Buffer Cache 交互,它们负责在外围存储设备和 Buffer Cache 之间交换数据。Page Cache、Buffer Cache、文件以及磁盘之间的关系如下图所示
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:
MMAP的读写实际是上也是会经过Cache层的,那么MMAP方式与普通方式(Buffer IO)操作文件的区别是什么呢?
上面已经介绍了Buffer IO的操作方式,MMAP和Buffer IO的区别在于Buffer IO需要现在用户空间下维护一个Buffer区(例如FileChannel读写时使用的Buffer);以写入文件为例,先向用户空间下的Buffer写入数据,然后再拷贝到内核缓冲(page cache),而MMAP直接将文件(确切的说应该是文件对应的Page Cache)映射到进程的地址空间,进程就可以直接以内存的操作方式来操作文件了,不需要用户缓冲到内核缓冲的拷贝。进程对mmap的操作相当于直接操作了cache,读取mmap时等于直接读取cache,写入mmap时等于直接写cache,然后操作系统异步刷盘,当然也可以手动调用sync强制刷盘。少了一次拷贝,速度上自然有提升,所以MMAP又成为零拷贝(ZERO COPY)。
虽然缺点很多,但是如果需要超高性能时还是需要考虑使用mmap的。
通过FileChannel创建mmap
FileChannel channel = FileChannel.open(new File("your file path").toPath(), StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE); //FileChannel.MapMode.READ_WRITE为映射的模式,READ_WRITE代表可读写;0,10为映射的文件偏移,单位字节 MappedByteBuffer mappedByteBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, 10); //MappedByteBuffer继承于NIO的ByteBuffer,读写数据的接口和ByteBuffer一致,注意:MappedByteBuffer实际上属于堆外内存(Direct Buffer) mappedByteBuffer.putInt(1); mappedByteBuffer.put((byte) 0x01); mappedByteBuffer.putLong(1l); //对于mmap的写入,都是写入在cache中的,操作系统会异步刷盘,当然如果对数据一致性有严格要求,可以手动调用force强制刷盘,但是这样性能就非常差了。 mappedByteBuffer.force();
Java中对于MMAP的释放没有一个优雅的方式,释放起来比较麻烦,下面贴一个释放的工具类:
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.nio.ByteBuffer; import java.nio.MappedByteBuffer; public final class ByteBufferSupport { private static final MethodHandle INVOKE_CLEANER; static { MethodHandle invoker; try { // Java 9 added an invokeCleaner method to Unsafe to work around // module visibility issues for code that used to rely on DirectByteBuffer's cleaner() Class<?> unsafeClass = Class.forName("sun.misc.Unsafe"); Field theUnsafe = unsafeClass.getDeclaredField("theUnsafe"); theUnsafe.setAccessible(true); invoker = MethodHandles.lookup() .findVirtual(unsafeClass, "invokeCleaner", MethodType.methodType(void.class, ByteBuffer.class)) .bindTo(theUnsafe.get(null)); } catch (Exception e) { // fall back to pre-java 9 compatible behavior try { Class<?> directByteBufferClass = Class.forName("java.nio.DirectByteBuffer"); Class<?> cleanerClass = Class.forName("sun.misc.Cleaner"); Method cleanerMethod = directByteBufferClass.getDeclaredMethod("cleaner"); cleanerMethod.setAccessible(true); MethodHandle getCleaner = MethodHandles.lookup().unreflect(cleanerMethod); Method cleanMethod = cleanerClass.getDeclaredMethod("clean"); cleanerMethod.setAccessible(true); MethodHandle clean = MethodHandles.lookup().unreflect(cleanMethod); clean = MethodHandles.dropArguments(clean, 1, directByteBufferClass); invoker = MethodHandles.foldArguments(clean, getCleaner); } catch (Exception e1) { throw new AssertionError(e1); } } INVOKE_CLEANER = invoker; } private ByteBufferSupport() { } public static void unmap(MappedByteBuffer buffer) { try { INVOKE_CLEANER.invoke(buffer); } catch (Throwable ignored) { throw Throwables.propagate(ignored); } } }
通过Direct I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要页缓存的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库管理系统这类应用,他们更倾向于选择他们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。下图是Direct IO的路径:
JDK并没有提供对Direct IO的支持(但C++使用很简单),需要通过JNA的方式来调用,这里推荐两个DIO库
适用于普通类型的文件读写,性能尚可,操作简单,无注意事项。
小数据量读写性能高,但不灵活。
需要自己控制Cache时,可以适用Direct IO,例如数据库/中间件应用,可以避免文件的读写还经过一层Page Cache,造成额外开销。