文件操作之 FileChannel 与 mmap
Java 中的文件读写
Java 中原生读写方式大概可以被分为三种:普通 IO,FileChannel(文件通道),mmap(内存映射)。
FileChannel
- 可以在文件的特定位置进行读写操作(操作文件指针);
- 可以直接将文件的一部分加载到内存中(mmap);
- 可以以更快的速度从一个通道传输文件数据到另一个通道(转入/转出其他通道);
- 可以锁定文件的一部分,以限制其他线程访问(文件锁);
- 为了避免数据丢失,我们可以强制将对文件的写入更新立即写入存储(force 刷盘);
FileChannel API
方法 | 描述 |
---|---|
open | 创建 FileChannel |
read/write | 读写 |
force | 强制刷盘 |
map | mmap 内存映射 |
transferTo/transferFrom | 转入/转出 通道 |
lock/tryLock | 获取文件锁 |
打开FileChannel
1 |
|
FileChannel 读写数据
1 |
|
刷盘
FileChannel#force
方法用于这个 Channel 更新的内容直接写入文件 ,而不是 pagecache。
FileChannel 间数据传输
FileChannel#transferTo 以及 FileChannel#transferFrom 用于文件通道的内容转出到另一个通道或者将另一个通道的内容转入当前通道。
1 |
|
该两个方法可以实现通道字节数据的快速转移,不仅在简化代码量(少了中间内存的数据拷贝转移)而且还大幅到提高了性能。
内存映射
Java中
FileChannel
提供了map方法,把文件映射成内存映射文件:
1 |
|
- position: 文件开始
- size:映射的文件区域大小
-
mode: 访问该内存映射文件的方式,取值可以为:
- READ_ONLY(只读)
- READ_WRITE(读写)
- PRIVATE,这种方式的更改不会传播到文件,而是创建一个修改副本
文件锁
一旦某个进程(比如说JVM实例)对某个文件加锁,则在释放这个锁之前,此进程不能再对此文件加锁,就是说JVM实例在同一文件上的文件锁是不重叠的(进程级别不能重复在同一文件上获取锁)。
1 |
|
其中,false 意味着独占模式,true 则对应共享模式。
- 文件锁 FileLock 是被整个 JVM 持有的,即 FileLock 是进程级别的,所以不可用于作为多线程安全控制的同步工具。
-
虽然上面提到 FileLock 不可用于多线程访问安全控制,但是多线程访问是安全的。如果线程 1 获取了文件锁 FileLock(共享或者独占),线程 2 再来请求获取该文件的文件锁,则会抛出
OverlappingFileLockException
- 一个程序获取到 FileLock 后,是否会阻止另一个程序访问相同文件具重叠内容的部分取决于操作系统的实现,具有不确定性。FileLock 的实现依赖于底层操作系统实现的本地文件锁设施。
- 以上所说的文件锁的作用域是文件的区域,可以时整个文件内容或者只是文件内容的一部分。独占和共享也是针对文件区域而言。程序(或者线程)获取文件 0 至 23 范围的锁,另一个程序(或者线程)仍然能获取文件 23 至以后的范围。只要作用的区域无重叠,都相互无影响。
关于 FileChannel 的读写效率
由于 FileChannel 采用了 ByteBuffer 这样的内存缓冲区,让我们可以非常精准的控制写盘的大小, 这样在写入 4KB(linux 默认一页的大小) 的整数倍的时候, 效率会非常高。
- 对于文件的读写操作, 实际上并不是直接作用于磁盘的, 中间是有一层 pagecache。
-
操作系统有一个预读机制(read ahead), 会提前预读一部分到 pagecache 中(读取的粒度由操作系统控制, 采取快速窗口扩张算法, 首次预读一般是
readahead_size * 2
) - 这样读写都是和内存(pagecache)打交道, 减少了IO次数, 所以提高了读写效率
补充: pagecache 监控工具: https://github.com/brendangregg/perf-tools
ByteBuffer
FileChannel 是通过控制 ByteBuffer 来完成读写的. 整体 UML 如下:
创建方式
-
1
2
3
4
5// 普通 ByteBuffer
ByteBuffer buffer = ByteBuffer.allocate(10);
// 堆外内存
ByteBuffer buffer = ByteBuffer.allocateDirect(10); -
1
2
3
4
5// 直接 wrap
byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);
// 等同于上面的方式
ByteBuffer buffer = ByteBuffer.wrap(bytes, 0, bytes.length);
基本索引使用
- capacity: buffer 元素的最大容量
- limit: read 或 write 的最大索引位置
- position: 当前 read 或 write 的索引位置
- mark: 用于标记的索引位置
1 |
|
以一个新创建的 ByteBuffer 为例说明各指针的位置:
1 |
|
mark 和 reset
1 |
|
clear, Flip, Rewind 和 Compact
clear, Flip, Rewind, Compact 这几个方法的作用有很多相似的地方和一些细微的差别, 对比如下:
- clear: limit=capacity, position=0, mark=-1。 相当于初始化 buffer, 为下一次读写做准备
- flip: limit=position, position=0, mark=-1。 切换读写模式, 但是要避免连续两次调用 flip, 这样会把 limit 设置到 0, 变得无法读写。
- rewind: position=0, mark=-1. 用于重头开始读数据。
- compact: limit=capacity, position=remaining(还未处理的部分), mark=-1。 用于将未处理的部分(limit-position)拷贝到 buffer 的头部, 然后从未处理数据的尾部开始写(等于只覆盖已处理的字节部分)
1 |
|
clear 将 limit 设置为 capacity 位置, mark 设置为 -1, position 设置为 0。 一般在重新填充 buffer 前调用
1 |
|
flip 将 limit 设置到 position 位置, position 设置为0, mark 设置为 -1。
1 |
|
rewind 让 limit 保持不变, 将 position 设置为 0, mark 位置为 -1。 相当于重头读写 buffer
1 |
|
compact 将 limit 设置到 capacity 位置, position 指向还未处理的位置(limit-position), mark 置为 -1
1 |
|
堆外内存和堆外内存
ByteBuffer 可以通过
ByteBuffer.allocate
来分配堆内内存, 使用
ByteBuffer.allocateDirect
来分配堆外内存。 这两者的比较:
- | 堆内内存 | 堆外内存 |
---|---|---|
底层实现 | 数组,JVM堆内存 |
unsafe.allocateMemory(size)
分配直接内存
|
分配大小限制 |
-Xms-Xmx
限制
|
可以通过
-XX:MaxDirectMemorySize
从JVM层面限制,同时也受到物理内存限制
|
垃圾回收 | gc 回收 |
DirectByteBuffer 不再被使用的时候, 会发出内部的 cleaner 钩子, 保险通过:
((DirectByteBuffer)buffer)).cleaner().clean()
来手动回收
|
拷贝方式 | 用户态和内核态之间来回拷贝 | 内核态 |
HeapByteBuffer 复制的问题
FileChannel 操作的时候使用 ByteBuffer, 默认是使用的堆内内存
HeapByteBuffer
, 一般代码操作如下:
1 |
|
上述代码讲文件中的数据缓存到了内存中,在多线程的场景下, 控制线程数每个线程分 50MB 缓存是没问题的。 但是如果直接使用上面的代码, 很可能有内存溢出的问题。
FileChannel 使用的是 IOUtil 来进行读操作的, 源码如下:
1 |
|
可以发现如果是堆内内存会走这个逻辑:
Util.getTemporaryDirectBuffer(var1.remaining());
, 封装的源码如下:
1 |
|
- 使用 HeapByteBuffer 读写都会经过 DirectByteBuffer, 读取数据的流转方式是: DisK->PageCache->DirectByteBuffer->HeapByteBuffer, 写入数据的过程正好相反
- 使用 HeapByteBuffer 读写会申请一块跟线程绑定的 DirectByteBuffer(IOUtil里面的ThreadLocal变量)。这意味着,线程越多,临时 DirectByteBuffer 就越会占用越多的空间。
所以在线程过多的时候, 就容易引起 DirectByteBuffer 上升导致堆外内存溢出.
解决方案可以借鉴 IOUtil 复制的思路, 因为从磁盘到堆内的复制是省略不了堆外内存的复制, 那就把直接内存控制在自己的逻辑上, 从而避免被 FileChannel 复杂的内部逻辑左右:
1 |
|
堆外内存的回收
上面说了使用 HeapByteBuffer 的时候要经过 DirectByteBuffer, 而不是直接从堆内内存写入 PageCache 然后刷盘, 这种设计的考量根据R大的解释:
- 为了方便 GC 的实现, DirectByteBuffer 指向的 native memory 是不受 GC 管辖的
- HeapByteBuffer 背后使用的是 byte 数组,其占用的内存不一定是连续的,不太方便 JNI 方法的调用
DirectByteBuffer 直接内存的回收
观察堆外内存的回收可以通过
Java VisualVM
安装
MBeans 和 Buffer Pools
两个插件即可
-
1
2
3
4
5
6
7
8public static void systemGC() throws IOException, InterruptedException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.in.read();
buffer = null;
// 手动触发GC可以回收堆外内存
System.gc();
new CountDownLatch(1).await();
} -
1
2
3
4
5
6
7public static void cleanerGC() throws InterruptedException, IOException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.in.read();
// 通过 cleaner 来回收
((DirectBuffer) buffer).cleaner().clean();
new CountDownLatch(1).await();
}
mmap 的使用
1 |
|
java 中 mmap
java 中主要通过 FileChannel 来进行 mmap(内存映射), 通过
MmapedByteBuffer
来进行内存相关操作:
1 |
|
1 |
|
mmap 零拷贝与刷盘效率问题
1 |
|
方法一: 写 4KB 缓冲刷盘, 实测写 1GB 大概 2120ms
方法二: 写 1byte 缓冲刷盘,实测写 1GB 文件, 1min 写了大概 9MB 左右, 差距巨大。
使用 mmap 其底层提供了映射能力, 不需要内核态和用户态的切换, 使用如下代码:
1 |
|
实测这种方法大概在 1200ms 左右, 说明了 在一次写入小数据量场景下, 瓶颈不在于IO, 而在于用户态和内核态的切换
mmap 内存的回收
与 DirectByteBuffer 类似(实际上DirectByteBuffer 是 MappedByteBuffer 的子类), 通过 cleaner 来回收:
1 |
|
mmap 使用场景
如果 IO 非常频繁,数据却非常小,推荐使用 mmap,以避免 FileChannel 导致的切态问题。例如索引文件的追加写。