文件IO

45 阅读13分钟

一,前言

文件读写是我们在软件开发过程中经常遇到,由于平时大部分场景对于文件的写入效率和性能要求并不苛刻,所以只要能够完成文件在磁盘上的正常的写入,就算完成了任务。但是文件写入都经历了那些过程,在特定场景我们该如何去选这一个合适的文件写入方式。本次和大家一起探讨一下文件IO部分。

二,约束

(1)本次探讨是基于X86架构的多用户操作系统。

(2)本次验证使用的软硬件配置信息为:CPU: Intel(R)_Core(TM)i7-8565U_CPU@_1.80GHz 。Memory: ChannelA-DIMM0 8G, DISK: WDC PC SN730 SDBQNTY-512G-1001 512G,JDK 1.8。

(3)本次主要验证各个IO接口的数据写入性能。

三,文件写入流程

要知道如何快速将数据快速刷入磁盘或者在什么场景下应该选用什么样的IO操作方式,那么首先我们要了解数据在写入磁盘经历了那些过程。

我们的程序都是运行在用户空间上,当我们使用JDK提供的IO操作,将数据写入到磁盘上时,首先会产生一个系统调用(trap中断),CPU进行上下文切换,完成由用户态到内核态切换(在多用户操作系统中通常面向硬件层面CPU一般存在三种状态,用户态,内核态和管态)。

然后通过内核的虚拟文件系统(VFS)找到inode,再通过inode找到address_space,然后通过address_space找到页缓存树。 最后判断page cache是否存在。如果不存在,则需要创建page cache,此时会产生一个缺页中断。如果存在则使用现有的page cache,这个过程就是实现了cache hit。page cache占用的是内存空间,一般单个page cache大小为4KB,同时page ache的大小也可以通过系统参数来调整,那么磁盘又和page cache存在什么关系呢。磁盘每个block都会映射到一个page cache上,一个block大小正常为4KB,通常由8个连续的扇区组成。在找到page cache 后,CPU会将用户空间的数据拷贝到内核的page cache中,此时产生了一次内存拷贝。

但是进入到page cache数据什么时候回被刷入到磁盘上呢,由于page cache处在内核空间中,这个则由系统通过一定策略唤醒pdflush线程进行writeback,触发pdflush线程回写有多种情况,例如空间不足,定时, 当然程序也可以强制刷入。在执行writeback时会产生一个CPU中断,CPU将指令发送给MDA控制单元,同时让出总线控制权,待MDA控制单元完成数据写入后,又会产生一个CPU中断,将总线控制权归还给CPU。以上就是大致的数据流转过程,具体的数据IO交互流程如下:

图表1

通过对以上流程的梳理,我们可以得出至少有两个提高性能的方案:(1)减少中断次数。(2)减少内存拷贝次数。

四,如何减少中断次数

首先考虑应该如减少中断次数,在应用层面我能想到的就是能不能用尽量少的IO次数将数据写入到磁盘文件中。自然我们就会想到的是能不能使用Buffer。数据在先写入Buffer空间,待Buffer饱和后调用读写接口将数据刷入磁盘。这样一次IO就可以将尽可能多的数据刷入到磁盘。同时JDK也提供了大量的Buffer IO操作接口,在性能方面我们就会得出Buffer byte stream性能高于byte stream性能。实际写入性能测试如下:

(备注:Buffer大小设置为4096,测试使用的是BufferedOutputStream和FileOutputStream)

图表2(X轴调用次数,Y轴耗时单位MS)

图表2是单次写入数据长度(128 byte)不变,验证使用buffer(buffer大小为固定为4096)和不使用buffer的IO操作,在不同调用次数上的性能差异表现。一共进行了3组测试,调用测试从10W到200W。从中可以得出使用了buffer的IO操作,性能要远好于不是用buffer的IO操作。

图表3(X轴单次写入字节数,Y轴耗时单位MS)

图表3是写入次数不变(100000次),验证写入字节数的差异对使用buffer和不适用buffer IO操作的影响。本次一共测试4组,分别是单次写入字节长度分别为:1024,2048,3072,4096。从图中可以看出随着字节长度的增长,使用了Buffer的IO操作性能反而下降。而这个性能的分界线就在单次写入的字节长度有没有超过 1/2 Buffer。出现这种情况的原因就是因为,当单次写入字节长度不超过1/2 Buffer是,通过buffer缓冲减少IO中断次数是生效的,但是一但超过1/2就和没有使用Buffer没有差异,同时buffer的内存拷贝反而拖慢了接口的性能,这个从测试图表中也能反映出来。

五,如何减少内存拷贝

在IO操作中,数据流转主要在用户空间,内核空间,磁盘之间发生。而减少数据拷贝我们首先想到的是使用MMAP(内存映射), 这个在高性能IO上都会涉及到。在了解MMAP之前我们先来解两个类:HeapByteBuffer 和 DirectByteBuffer。这两个类的实例化都可以通过ByteBuffer来完成。下边是相关类关系类图:

图表4

(1)HeapByteBuffer 开辟的是堆内空间,大小及回收都受到JVM影响和管控。

(2)DirectByteBuffer 开辟的是堆外空间,不受JVM堆内存大小约束,但是可以通过 -XX:MaxDirectMemory配置大小约束。另外回收一般是通过FULL GC,用户也可以通过手动释放,通过DirectBuffer接口获取cleaner对象,然后执行clean方法。下边我们对堆内buffer和堆外buffer性能进行比对,首先比对开辟内存空间的速度,如下图5。

图表5(X轴代表创建次数,Y轴代表耗时单位MS)

图表5需要验证堆内空间和堆外空间内存开辟性能比较。本次一共跑了三组数据(调用次数分别为10W次到200W次),单次开辟内存空间大小128 byte。可以看出单纯从开辟内存空间速度上,堆内操作性能要远好于堆外。这就说明如果是写入大批量的小文件,使用堆外开辟空间的方式就不是很合适,因为单纯的开辟内存空间的性能消耗就很巨大。

那么在大文件写入性能使用堆内空间和堆外空间在性能表现如何,这里使用FileChannel做文件写入测试,测试结果如下图6:(备注:先固定BUFFER大小为4096,通过梯度调整写入次数,观察性能情况)

编辑

图表6(X轴写入次数,Y轴耗时单位MS)

图表6本次做了三组测试,在BUFFER大小为4096字节下。调用次数从10W到1000W次,单次写入字节数1024byte。最大写入文件大小为9.53G。可以看到在Buffer空间不大的情况下两者表现差距不大,但使用堆外空间性能会略好。

那么如果调用次数不变,调整BUFFER大小会有两者的性能表现有将如何呢。测试情况如下图表7:

图表7(X轴buffer大小,Y轴写入耗时单位MS)

图表7验证在调用次数不变的情况下(1000000次),调整Buffer大小,对文件写入性能的影响。可以看出当Buffer空间不大时,两者的性能差距不大,但是随着Buffer的增长,堆外空间的性能优势就凸显出来。但是为什么随着Buffer的调大,性能会由好又变差。排查了一下原因,中间随着Buffer的调大,Writer的次数就会减少,性能会有所提升,但是随着Buffer的不断加大,开辟内存空间的耗时和Writer的时长都显著增加。

通过上边两个维度的比对,在Buffer空间不大的时候两者的性能差距不大,只有当Buffer空间调大的时候两者的性能差异才凸显出来。通过代码排查发现了两者的差别在于堆内的buffer多了一次堆内向堆外拷贝的过程,源码如下:

static int write(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
    if (var1 instanceof DirectBuffer) {
        return writeFromNativeBuffer(var0, var1, var2, var4);
    } else {
        int var5 = var1.position();
        int var6 = var1.limit();


        assert var5 <= var6;


        int var7 = var5 <= var6 ? var6 - var5 : 0;
        ByteBuffer var8 = Util.getTemporaryDirectBuffer(var7);


        int var10;
        try {
            var8.put(var1);
            var8.flip();
            var1.position(var5);
            int var9 = writeFromNativeBuffer(var0, var8, var2, var4);
            if (var9 > 0) {
                var1.position(var5 + var9);
            }


            var10 = var9;
        } finally {
            Util.offerFirstTemporaryDirectBuffer(var8);
        }


        return var10;
    }
}
public static ByteBuffer getTemporaryDirectBuffer(int var0) {
    Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
    ByteBuffer var2 = var1.get(var0);
    if (var2 != null) {
        return var2;
    } else {
        if (!var1.isEmpty()) {
            var2 = var1.removeFirst();
            free(var2);
        }


        return ByteBuffer.allocateDirect(var0);
    

在调用writer时,会首先判断buffer的实现,如果非DirectBuffer实现,则会去本地线程变量获取缓存Buffer,如果相同位数的缓存Buffer没有,则会回开辟一个堆外空间,然后将数据填充到堆外空间Buffer中,执行数据写入。最后将新开辟的Buffer,进行缓存。针对上边的拷贝耗时情况,这里做了一次拷贝耗时测试。测试约束:Buffer长度4096,从堆内到堆外拷贝次数1亿次,平均耗时在730毫秒左右,这也就是为什么在Buffer空间不大的时候,两者在单一文件写入性能差距不是特别明显的原因。但是当Buffer空间增大是两者的性能差距就会凸显。

上边聊的DirectByteBuffer/HeapByteBuffer和MMAP有有什么关系呢,为什么先要了解这两个类。我们先来了解一下MMAP的映过程,MMAP底层为系统函数,他能完成用户空间的虚拟内存地址到文件的映射,具体步骤是,先在虚拟内存开辟一段空间,然后建立页表完成映射关系,最后通过缺页中断装入数据。这样我们就能够通过操作内存数据达到直接操作文件的目的,这个过程减少了数据从用户空间到内核空间的数据拷贝。而对堆外内存空间的操作,就是通过DirectByteBuffer来完成。下边找了一个原理图共大家参考:

图表8

接下来我们来验证一下使用MMAP在文件写入性能:

图表9(X轴单次写入字节数,Y轴写入耗时单位MS)

图表9在固定1000000次写入,单次写入长度分别为1024和2048字节,生成文件大小分别为976MB和1.9G的性能表现。可以看出MMAP的性能和预期一样表现最佳,在300多毫秒就能完成将近1个G的文件写入,在837MS完成了将近2个G的文件写入。表现次佳是FileChannel,最差是BufferOutputStream。由于MMAP受虚拟内存大小约束,测试机器只能测试2G左右的文件写入,所以只做了2组测试。

但是查阅资料说FileChannel的性能在写入4KB的整数被数据时性能表现最佳,甚至超越MMAP的性能。因为底层是基于block的精准刷盘,对此又做了四组测试,验证FileChannel在4KB整数倍写入上的性能表现,测试结果如下图10

图表10(X轴单次写入字节数,Y轴写入耗时)

图表10,固定写次数1000000,单次写入数据量从1KB到20KB, 但是没有发现FileChannel有特别优异的性能表现,总体和上边性能表现大致相同,不知道是不是参数配置的原因,还是应为存储介质的差别。另外对于大文件拷贝,FileChannel也提供了零拷贝方法transferTo。通过测试,完成2G文件拷贝耗时3700MS,同样的文件在使用文件流每次读入4096字节共耗时6221MS。

本次验证的所有数据明细如下:

六,总结

总结:通过上边测试验证,可以得出一下几点结论:

(1)一般情况下使用buffer的性能要好于不buffer的性能,但是在使用的时候需要关注在应用场景中一次写入的字节长度和buffer长度的匹配关系,当超过Buffer 1/2长度时Buffer等同于失效。

(2)使用堆外空间Buffer的性能和使用堆内空间Buffer性能在Buffer空间不大的情况下差别不大,但是随着Buffer空间增长,堆外空间的性能优势凸显。

(3)使用mmap数据写入总体性能表现不错,然而mmap在大文件操作上仍然有一定的优化空间,例如内存锁定,防止被换出,以及大文件预热等操作。同时mmap的使用也会有一定的约束,例如使用mmap的文件大小受到虚拟内存空间的限制(在JDK1.8 mmap 限制长度2147483647L字节,大概2G大小)。还有使用MMAP需要提前知道文件大小。同时大批量小文件读写也不太适合。

(4)在选择文件IO API时,没有好坏之分,只有适不适合当前的使用场景。因为当外部因素发生变化及存储介质的差异,都会带来性能的差异。例如单次写入字节大小会影响Buffer的性能,小文件在使用MMAP也不一定合适。

七,扩展

以上探讨基本都是基于内核page cache的数据写入,有没有其它的方法绕过page cache直接完成数据写入。答案是有的,这就是direct I/O,实现direct I/O需要借助外部的JNI,这里在GitHub找了一个例子,大家可以去尝试一下 github.com/smacke/jayd…

int bufferSize = 1<<23; // Use 8 MiB buffers
byte[] buf = new byte[bufferSize];
DirectRandomAccessFile fin = new DirectRandomAccessFile(new File("hello.txt"), "r", bufferSize);
DirectRandomAccessFile fout = new DirectRandomAccessFile(new File("world.txt"), "rw", bufferSize);
while (fin.getFilePointer() < fin.length()) {
    int remaining = (int)Math.min(bufferSize, fin.length()-fin.getFilePointer());
    fin.read(buf,0,remaining);
    fout.write(buf,0,remaining);
}
fin.close();
fout.close();

下边罗列了一些在JAVA编程开发中常用的IO操作类。