文件 IO 操做的一些最佳实践

背景

已通过去的中间件性能挑战赛,和正在进行中的 第一届 PolarDB 数据性能大赛 都涉及到了文件操做,合理地设计架构以及正确地压榨机器的读写性能成了比赛中获取较好成绩的关键。正在参赛的我收到了几位公众号读者朋友的反馈,他们大多表达出了这样的烦恼:“对比赛很感兴趣,但不知道怎么入门”,“能跑出成绩,但相比前排的选手,成绩相差10倍有余”…为了能让更多的读者参与到以后相相似的比赛中来,我简单整理一些文件IO操做的最佳实践,而不涉及总体系统的架构设计,但愿经过这篇文章的介绍,让你可以欢快地参与到以后相似的性能挑战赛之中来。java

知识点梳理

本文主要关注的 Java 相关的文件操做,理解它们须要一些前置条件,好比 PageCache,Mmap(内存映射),DirectByteBuffer(堆外缓存),顺序读写,随机读写...不必定须要彻底理解,但至少知道它们是个啥,由于本文将会主要围绕这些知识点来展开描述。linux

初识 FileChannel 和 MMAP

首先,文件IO类型的比赛最重要的一点,就是选择好读写文件的方式,那 JAVA 中文件IO有多少种呢?原生的读写方式大概能够被分为三种:普通IO,FileChannel(文件通道),MMAP(内存映射)。区分他们也很简单,例如 FileWriter,FileReader 存在于 java.io 包中,他们属于普通IO;FileChannel 存在于 java.nio 包中,属于 NIO 的一种,可是注意 NIO 并不必定意味着非阻塞,这里的 FileChannel 就是阻塞的;较为特殊的是后者 MMAP,它是由 FileChannel 调用 map 方法衍生出来的一种特殊读写文件的方式,被称之为内存映射。git

使用 FIleChannel 的方式:github

FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();
复制代码

获取 MMAP 的方式:数组

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();
复制代码

MappedByteBuffer 即是 JAVA 中 MMAP 的操做类。缓存

面向于字节传输的传统 IO 方式遭到了咱们的唾弃,咱们重点探讨 FileChannel 和 MMAP 这两种读写方式的区别。安全

FileChannel 读写

// 写
byte[] data = new byte[4096];
long position = 1024L;
//指定 position 写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data), position);
//从当前文件指针的位置写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data));

// 读
ByteBuffer buffer = ByteBuffer.allocate(4096);
long position = 1024L;
//指定 position 读取 4kb 的数据
fileChannel.read(buffer,position);
//从当前文件指针的位置读取 4kb 的数据
fileChannel.read(buffer);
复制代码

FileChannel 大多数时候是和 ByteBuffer 这个类打交道,你能够将它理解为一个 byte[] 的封装类,提供了丰富的 API 去操做字节,不了解的同窗能够去熟悉下它的 API。值得一提的是,write 和 read 方法均是线程安全的,FileChannel 内部经过一把 private final Object positionLock = new Object(); 锁来控制并发。微信

FileChannel 为何比普通 IO 要快呢?这么说可能不严谨,由于你要用对它,FileChannel 只有在一次写入 4kb 的整数倍时,才能发挥出实际的性能,这得益于 FileChannel 采用了 ByteBuffer 这样的内存缓冲区,让咱们能够很是精准的控制写盘的大小,这是普通 IO 没法实现的。4kb 必定快吗?也不严谨,这主要取决你机器的磁盘结构,而且受到操做系统,文件系统,CPU 的影响,例如中间件性能挑战赛时的那块盘,一次至少写入 64kb 才能发挥出最高的 IOPS。多线程

中间件性能挑战复赛的盘

然而 PolarDB 这块盘就彻底不同了,可谓是异常彪悍,具体是如何的表现因为比赛仍在进行中,不予深究,但凭借着 benchmark everyting 的技巧,咱们彻底能够测出来。架构

另一点,成就了 FileChannel 的高效,介绍这点以前,我想作一个提问:FileChannel 是直接把 ByteBuffer 中的数据写入到磁盘吗?思考几秒…答案是:NO。ByteBuffer 中的数据和磁盘中的数据还隔了一层,这一层即是 PageCache,是用户内存和磁盘之间的一层缓存。咱们都知道磁盘 IO 和内存 IO 的速度但是相差了好几个数量级。咱们能够认为 filechannel.write 写入 PageCache 即是完成了落盘操做,但实际上,操做系统最终帮咱们完成了 PageCache 到磁盘的最终写入,理解了这个概念,你就应该可以理解 FileChannel 为何提供了一个 force() 方法,用于通知操做系统进行及时的刷盘。

同理,当咱们使用 FileChannel 进行读操做时,一样经历了:磁盘->PageCache->用户内存这三个阶段,对于平常使用者而言,你能够忽略掉 PageCache,但做为挑战者参赛,PageCache 在调优过程当中是万万不能忽视的,关于读操做这里不作过多的介绍,咱们再下面的小结中还会再次说起,这里当作是引出 PageCache 的概念。

MMAP 读写

// 写
byte[] data = new byte[4];
int position = 8;
//从当前 mmap 指针的位置写入 4b 的数据
mappedByteBuffer.put(data);
//指定 position 写入 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.put(data);

// 读
byte[] data = new byte[4];
int position = 8;
//从当前 mmap 指针的位置读取 4b 的数据
mappedByteBuffer.get(data);
//指定 position 读取 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.get(data);
复制代码

FileChannel 已经足够强大了,MappedByteBuffer 还能玩出什么花来呢?请允许我卖个关子先,先介绍一下 MappedByteBuffer 的使用注意点。

当咱们执行 fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024); 以后,观察一下磁盘上的变化,会马上得到一个 1.5G 的文件,但此时文件的内容所有是 0(字节 0)。这符合 MMAP 的中文描述:内存映射文件,咱们以后对内存中 MappedByteBuffer 作的任何操做,都会被最终映射到文件之中,

mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,能够像操做内存同样操做这个文件,至关于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操做,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到物理内存进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高

看了稍微官方一点的描述,你可能对 MMAP 有了些许的好奇,有这么厉害的黑科技存在的话,还有 FileChannel 存在的意义吗!而且网上不少文章都在说,MMAP 操做大文件性能比 FileChannel 搞出一个数量级!然而,经过我比赛的认识,MMAP 并不是是文件 IO 的银弹,它只有在一次写入很小量数据的场景下才能表现出比 FileChannel 稍微优异的性能。紧接着我还要告诉你一些令你沮丧的事,至少在 JAVA 中使用 MappedByteBuffer 是一件很是麻烦而且痛苦的事,主要表现为三点:

  1. MMAP 使用时必须实现指定好内存映射的大小,而且一次 map 的大小限制在 1.5G 左右,重复 map 又会带来虚拟内存的回收、从新分配的问题,对于文件不肯定大小的情形实在是太不友好了。
  2. MMAP 使用的是虚拟内存,和 PageCache 同样是由操做系统来控制刷盘的,虽然能够经过 force() 来手动控制,但这个时间把握很差,在小内存场景下会很使人头疼。
  3. MMAP 的回收问题,当 MappedByteBuffer 再也不须要时,能够手动释放占用的虚拟内存,但…方式很是的诡异。
public static void clean(MappedByteBuffer mappedByteBuffer) {
    ByteBuffer buffer = mappedByteBuffer;
    if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
        return;
    invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
    return AccessController.doPrivileged(new PrivilegedAction<Object>() {
        public Object run() {
            try {
                Method method = method(target, methodName, args);
                method.setAccessible(true);
                return method.invoke(target);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    });
}

private static Method method(Object target, String methodName, Class<?>[] args) throws NoSuchMethodException {
    try {
        return target.getClass().getMethod(methodName, args);
    } catch (NoSuchMethodException e) {
        return target.getClass().getDeclaredMethod(methodName, args);
    }
}

private static ByteBuffer viewed(ByteBuffer buffer) {
    String methodName = "viewedBuffer";
    Method[] methods = buffer.getClass().getMethods();
    for (int i = 0; i < methods.length; i++) {
        if (methods[i].getName().equals("attachment")) {
            methodName = "attachment";
            break;
        }
    }
    ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
    if (viewedBuffer == null)
        return buffer;
    else
        return viewed(viewedBuffer);
}
复制代码

对的,你没看错,这么长的代码仅仅是为了干回收 MappedByteBuffer 这一件事。

因此我建议,优先使用 FileChannel 去完成初始代码的提交,在必须使用小数据量(例如几个字节)刷盘的场景下,再换成 MMAP 的实现,其余场景 FileChannel 彻底能够 cover(前提是你理解怎么合理使用 FileChannel)。至于 MMAP 为何在一次写入少许数据的场景下表现的比 FileChannel 优异,我尚未查到理论根据,若是你有相关的线索,欢迎留言。理论分析下,FileChannel 一样是写入内存,但比 MMAP 多了一次内核缓冲区与用户空间互相复制的过程,因此在极端场景下,MMAP 表现的更加优秀。至于 MMAP 分配的虚拟内存是否就是真正的 PageCache 这一点,我以为能够近似理解成 PageCache。

顺序读比随机读快,顺序写比随机写快

不管你是机械硬盘仍是 SSD,这个结论都是必定成立的,虽然背后的缘由不太同样,咱们今天不讨论机械硬盘这种古老的存储介质,重点 foucs 在 SSD 上,来看看在它之上进行的随机读写为何比顺序读写要慢。即便各个 SSD 和文件系统的构成具备差别性,但咱们今天的分析一样具有参考价值。

首先,什么是顺序读,什么是随机读,什么是顺序写,什么是随机写?可能咱们刚接触文件 IO 操做时并不会有这样的疑惑,但写着写着,本身都开始怀疑本身的理解了,不知道你有没有经历过这样相似的阶段,反正我有一段时间的确怀疑过。那么,先来看看两段代码:

写入方式一:64个线程,用户本身使用一个 atomic 变量记录写入指针的位置,并发写入

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
    })
}
复制代码

写入方式二:给 write 加了锁,保证了同步。

ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
    final int index = i;
    executor.execute(()->{
        write(new byte[4*1024]);
    })
}

public synchronized void write(byte[] data){
    fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}
复制代码

答案是方式二才算顺序写,顺序读也是同理。对于文件操做,加锁并非一件很是可怕的事,不敢同步 write/read 才可怕!有人会问:FileChannel 内部不是已经有 positionLock 保证写入的线程安全了吗,为何还要本身加同步?为何这样会快?我用大白话来回答的话就是多线程并发 write 而且不加同步,会致使文件空洞,它的执行次序多是

时序1:thread1 write position[0~4096)

时序2:thread3 write position[8194~12288)

时序2:thread2 write position[4096~8194)

因此并非彻底的“顺序写”。不过你也别担忧加锁会致使性能降低,咱们会在下面的小结介绍一个优化:经过文件分片来减小多线程读写时锁的冲突。

在来分析原理,顺序读为何会比随机读要快?顺序写为何比随机写要快?这两个对比其实都是一个东西在起做用:PageCache,前面咱们已经提到了,它是位于 application buffer(用户内存)和 disk file(磁盘)之间的一层缓存。

PageCache

以顺序读为例,当用户发起一个 fileChannel.read(4kb) 以后,实际发生了两件事

  1. 操做系统从磁盘加载了 16kb 进入 PageCache,这被称为预读
  2. 操做通从 PageCache 拷贝 4kb 进入用户内存

最终咱们在用户内存访问到了 4kb,为何顺序读快?很容量想到,当用户继续访问接下来的[4kb,16kb]的磁盘内容时,即是直接从 PageCache 去访问了。试想一下,当须要访问 16kb 的磁盘内容时,是发生4次磁盘 IO 快,仍是发生1次磁盘 IO+4 次内存 IO 快呢?答案是显而易见的,这一切都是 PageCache 带来的优化。

深度思考:当内存吃紧时,PageCache 的分配会受影响吗?PageCache 的大小如何肯定,是固定的 16kb 吗?我能够监控 PageCache 的命中状况吗? PageCache 会在哪些场景失效,若是失效了,咱们又要哪些补救方式呢?

我进行简单的自问自答,背后的逻辑还须要读者去推敲:

  • 当内存吃紧时,PageCache 的预读会受到影响,实测,并无搜到到文献支持
  • PageCache 是动态调整的,能够经过 linux 的系统参数进行调整,默认是占据总内存的 20%
  • github.com/brendangreg… github 上一款工具能够监控 PageCache
  • 这是颇有意思的一个优化点,若是用 PageCache 作缓存不可控,不妨本身作预读如何呢?

顺序写的原理和顺序读一致,都是收到了 PageCache 的影响,留给读者本身推敲一下。

直接内存(堆外) VS 堆内内存

前面 FileChannel 的示例代码中已经使用到了堆内内存: ByteBuffer.allocate(4 * 1024),ByteBuffer 提供了另外的方式让咱们能够分配堆外内存 : ByteBuffer.allocateDirect(4 * 1024)。这就引来的一系列的问题,我何时应该使用堆内内存,何时应该使用直接内存?

我不花太多笔墨去阐述了,直接上对比:

堆内内存 堆外内存
底层实现 数组,JVM 内存 unsafe.allocateMemory(size)返回直接内存
分配大小限制 -Xms-Xmx 配置的 JVM 内存相关,而且数组的大小有限制,在作测试时发现,当 JVM free memory 大于 1.5G 时,ByteBuffer.allocate(900M) 时会报错 能够经过 -XX:MaxDirectMemorySize 参数从 JVM 层面去限制,同时受到机器虚拟内存(说物理内存不太准确)的限制
垃圾回收 没必要多说 当 DirectByteBuffer 再也不被使用时,会出发内部 cleaner 的钩子,保险起见,能够考虑手动回收:((DirectBuffer) buffer).cleaner().clean();
拷贝方式 用户态<->内核态 内核态

关于堆内内存和堆外内存的一些最佳实践:

  1. 当须要申请大块的内存时,堆内内存会受到限制,只能分配堆外内存。
  2. 堆外内存适用于生命周期中等或较长的对象。( 若是是生命周期较短的对象,在 YGC 的时候就被回收了,就不存在大内存且生命周期较长的对象在 FGC 对应用形成的性能影响 )。
  3. 直接的文件拷贝操做,或者 I/O 操做。直接使用堆外内存就能少去内存从用户内存拷贝到系统内存的消耗
  4. 同时,还可使用池+堆外内存 的组合方式,来对生命周期较短,但涉及到 I/O 操做的对象进行堆外内存的再使用( Netty中就使用了该方式 )。在比赛中,尽可能不要出如今频繁 new byte[] ,建立内存区域再回收也是一笔不小的开销,使用 ThreadLocal<ByteBuffer>ThreadLocal<byte[]> 每每会给你带来意外的惊喜~
  5. 建立堆外内存的消耗要大于建立堆内内存的消耗,因此当分配了堆外内存以后,尽量复用它。

黑魔法:UNSAFE

public class UnsafeUtil {
    public static final Unsafe UNSAFE;
    static {
        try {
            Field field = Unsafe.class.getDeclaredField("theUnsafe");
            field.setAccessible(true);
            UNSAFE = (Unsafe) field.get(null);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}
复制代码

咱们可使用 UNSAFE 这个黑魔法实现不少没法想象的事,我这里就稍微介绍一两点吧。

实现直接内存与内存的拷贝:

ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);
复制代码

copyMemory 方法能够实现内存之间的拷贝,不管是堆内和堆外,1~2 个参数是 source 方,3~4 是 target 方,第 5 个参数是 copy 的大小。若是是堆内的字节数组,则传递数组的首地址和 16 这个固定的 ARRAY_BYTE_BASE_OFFSET 偏移常量;若是是堆外内存,则传递 null 和直接内存的偏移量,能够经过 ((DirectBuffer) buffer).address() 拿到。为何不直接拷贝,而要借助 UNSAFE?固然是由于它快啊!少年!另外补充:MappedByteBuffer 也可使用 UNSAFE 来 copy 从而达到写盘/读盘的效果哦。

至于 UNSAFE 还有那些黑科技,能够专门去了解下,我这里就不过多赘述了。

文件分区

前面已经提到了顺序读写时咱们须要对 write,read 加锁,而且我一再强调的一点是:加锁并不可怕,文件 IO 操做并无那么依赖多线程。可是加锁以后的顺序读写必然没法打满磁盘 IO,现在系统强劲的 CPU 总不能不压榨吧?咱们能够采用文件分区的方式来达到一箭双雕的效果:既知足了顺序读写,又减小了锁的冲突。

那么问题又来了,分多少合适呢?文件多了,锁冲突变下降了;文件太多了,碎片化太过严重,单个文件的值太少,缓存也就不容易命中,这样的 trade off 如何平衡?没有理论答案,benchmark everything~

Direct IO

linux io

最后咱们来探讨一下以前从没提到的一种 IO 方式,Direct IO,什么,Java 还有这东西?博主你骗我?以前怎么告诉我只有三种 IO 方式!别急着骂我,严谨来讲,这并非 JAVA 原生支持的方式,但能够经过 JNA/JNI 调用 native 方法作到。从上图咱们能够看到 :Direct IO 绕过了 PageCache,但咱们前面说到过,PageCache 但是个好东西啊,干吗不用他呢?再仔细推敲一下,还真有一些场景下,Direct IO 能够发挥做用,没错,那就是咱们前面没怎么提到的:随机读。当使用 fileChannel.read() 这类会触发 PageCache 预读的 IO 方式时,咱们其实并不但愿操做系统帮咱们干太多事,除非真的踩了狗屎运,随机读都能命中 PageCache,但概率可想而知。Direct IO 虽然被 Linus 无脑喷过,但在随机读的场景下,依旧存在其价值,减小了 Block IO Layed(近似理解为磁盘) 到 Page Cache 的 overhead。

话说回来,Java 怎么用 Direct IO 呢?有没有什么限制呢?前面说过,Java 目前原生并不支持,但也有好心人封装好了 Java 的 JNA 库,实现了 Java 的 Direct IO,github 地址:github.com/smacke/jayd…

int bufferSize = 20 * 1024 * 1024;
DirectRandomAccessFile directFile = new DirectRandomAccessFile(new File("dio.data"), "rw", bufferSize);
for(int i= 0;i< bufferSize / 4096;i++){
    byte[] buffer = new byte[4 * 1024];
    directFile.read(buffer);
    directFile.readFully(buffer);
}
directFile.close();
复制代码

但须要注意的是,只有 Linux 系统才支持 DIO! 因此,少年,是时候上手装一台 linux 了。值得一提的是,听说在 Jdk10 发布以后,Direct IO 将会获得原生的支持,让咱们拭目以待吧!

总结

以上均是我的的实践积累而来的经验,有部分结论没有找到文献的支撑,因此若有错误,欢迎指正。关于 PolarDB 数据性能大赛的比赛分析,等复赛结束后我会专门另起一篇文章,分析下具体如何使用这些优化点,固然这些小技巧其实不少人都知道,决定最后成绩的仍是总体设计的架构,以及对文件IO,操做系统,文件系统,CPU 和语言特性的理解。虽然 JAVA 搞这种性能挑战赛并不吃香,但依旧是乐趣无穷,但愿这些文件 IO 的知识可以帮助你,等下次比赛时看到你的身影~

欢迎关注个人微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会获得回复,带来更多 Java 相关的技术分享。

关注微信公众号
相关文章
相关标签/搜索