Java 中的那些IO
Linux IO结构
在开始之前,先介绍一下Linux的IO结构。
VFS(Virtual FileSystem) 虚拟文件系统
文件系统是内核的功能,是一种工作在内核空间的软件,访问一个文件必须要需要文件系统的存在才可以。Linux 可以支持多达数十种不同的文件系统,它们的实现各不相同,因此 Linux 内核向用户空间提供了虚拟文件系统这个统一的接口用来对文件系统进行操作。
虚拟文件系统是位于用户空间进程和内核空间中多种不同的底层文件系统的实现之间的一个抽象的接口层,它提供了常见的文件系统对象模型(如 i-node, file object, page cache, directory entry, etc.)和访问这些对象的方法(如 open, close, delete, write, read, create, fstat, etc.),并将它们统一输出,类似于库的作用。从而向用户进程隐藏了各种不同的文件系统的具体实现,这样上层软件只需要和 VFS 进行交互而不必关系底层的文件系统,简化了软件的开发,也使得 linux 可以支持多种不同的文件系统。
I/O子系统架构
上图概括了一次磁盘 write 操作的过程,假设文件已经被从磁盘中读入了 page cache 中。
- 一个用户进程通过 write() 系统调用发起写请求
- 内核更新对应的 page cache
- pdflush 内核线程将 page cache 写入至磁盘中
- 文件系统层将每一个 block buffer 存放为一个 bio 结构体,并向块设备层提交一个写请求
- 块设备层(block device)从上层接受到请求,执行 IO 调度操作,并将请求放入IO 请求队列中
- 设备驱动(如 SCSI 或其他设备驱动)完成写操作
- 磁盘设备固件执行对应的硬件操作,如磁盘的旋转,寻道等,数据被写入到磁盘扇区中
Block Layer
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 IO
Buffer I/O 又被称作Standard I/O,大多数文件系统的默认 I/O 操作都是Buffer I/O。在 Linux 的Buffer I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存(page cache)中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。Buffer I/O 有以下这些优点:
- 缓存 I/O 使用了操作系统内核缓冲区,在一定程度上分离了应用程序空间和实际的物理设备。
- 缓存 I/O 可以减少读盘的次数,从而提高性能。
Java中的IO也是Buffer IO。
常见的FileInputStream/FileOutPutStream/RandomAccessFile/FileChannel
,都是Buffer IO。
Page Cache
在 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、文件以及磁盘之间的关系如下图所示
写入数据时,则首先将其写入 Page Cache,并将其作为脏页进行管理。 脏页表示数据存储在 Page Cache 中,但需要最总写入底层存储设备。 这些脏页面的内容会异步写入(或者同步系统调用 sync 或 fsync)到底层存储设备。
文件块不仅在写入会经过Page Cache,在读取文件时也会被写入到Page Cache中。 例如,当读取一个100兆字节的文件两次时,第二次访问会更快,因为文件块直接来自内存中的 Page Cache,不必再次从磁盘读取。
MMAP
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的优缺点
优点
- 小数据量的读写性能极高
缺点
- 映射的大小最好4k对齐
- 释放麻烦
- 只能定长
- 随机写频繁的场景下,性能不一定比Buffer IO快
虽然缺点很多,但是如果需要超高性能时还是需要考虑使用mmap的。
JAVA中的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][5]) 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 IO
通过Direct I/O 方式进行数据传输,数据均直接在用户地址空间的缓冲区和磁盘之间直接进行传输,完全不需要Page Cache的支持。操作系统层提供的缓存往往会使应用程序在读写数据的时候获得更好的性能,但是对于某些特殊的应用程序,比如说数据库管理系统这类应用,他们更倾向于选择他们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。下图是Direct IO的路径:
Java中的Direct IO
JDK并没有提供对Direct IO的支持(但C++使用很简单),需要通过JNA的方式来调用,这里推荐两个DIO库
关于Cache层的位置,查了很多资料,但说法不一致。有说Cache层在VFS和FS之间的,有说在FS之下的,但这不是很重要,我们只需要认为Cache层在VFS/FS之下就可以了。
IO方式的选择
Buffer IO
适用于普通类型的文件读写,性能尚可,操作简单,无注意事项。
MMAP
小数据量读写性能高,但不灵活。
Direct IO
需要自己控制Cache时,可以适用Direct IO,例如数据库/中间件应用,可以避免文件的读写还经过一层Page Cache,造成额外开销。