java nio操作实践

java文件中文件IO主要包括普通IO,FileChannel以及MMap。本文主要介绍FileChannel以及MMap的一些原理以及使用,理解他们最好需要了解有关pageCache,内存零拷贝,堆外缓存的一些知识。

有关pageCache可见 PageCache和DirectIO , 有关零拷贝可见 零拷贝问题

获取方式

1
2
3
4
//获取FileChannel
FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();
//获取MMap
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size());

FileChannel 写

``

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写
byte[] data = new byte[4096];
long position = 1024L;
//指定 position 写入 data中 的数据
fileChannel.write(ByteBuffer.wrap(data), position);
//从当前文件指针的位置写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data));

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

FileChannel+ByteBuffer可以达到写入速度比较快,要是没有缓冲区的存在,FileChannel写入速度并不比普通IO,一般来说缓冲区的大小是由磁盘决定的。

那么,FileChannel是直接把ByteBuffer写到磁盘的吗?

img

不是,中间还隔着一个PageCache。当ByteBUffer是堆内内存时,数据需要经历ByteBuffer->内核空间->PageCache。当ByteBufefr是堆外,则省略到了从用户空间到内核空间的复制,直接ByteBuffer->PageCache,然后再从PageCache写回磁盘。

我们都知道磁盘 IO 和内存 IO 的速度可是相差了好几个数量级。我们可以认为 filechannel.write 写入 PageCache 便是完成了落盘操作,但实际上,操作系统最终帮我们完成了 PageCache 到磁盘的最终写入(这是异步的),理解了这个概念,你就应该能够理解 FileChannel 为什么提供了一个 force() 方法,用于通知操作系统进行及时的刷盘。

例如,RocketMQ刷盘方式:

  • 异步刷盘方式:在返回写成功状态时,消息可能只是被写入了内存的pageCache,写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入

  • 同步刷盘方式:在返回写成功状态时,消息已经被写入磁盘。具体流程是,消息写入内存的PAGECACHE后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,返回消息写成功的状态。

MMap读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写
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);

mmap是把文件映射到用户空间里的虚拟内存,这样就省去了从用户空间到内核空间的拷贝,这样,当我们需要向文件中写入数据时,先看虚拟内存中有没有对应的地址,即有没有将物理地址映射到虚拟内存,要是有的话,可以像操作内存一样操作这个文件,没有的话,产生缺页,加载相对应的页。

mmap直接将pageCache映射到了用户态的地址空间里,所以写数据的时候就相当于直接操作pageCache。

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

但是,MMap是不适用于大量数据的。

  • 因为一次map的大小在1.5G左右,要是大量数据的话必然要进行多次MMap,重复的map会带来虚拟内存回收,重新分配的问题。
  • MMAP 使用的是虚拟内存,和 PageCache 一样是由操作系统来控制刷盘的,虽然可以通过 force() 来手动控制,但这个时间把握不好,在小内存场景下会很令人头疼。
  • MMAP 的回收问题,当 MappedByteBuffer 不再需要时,可以手动释放占用的虚拟内存,但非常麻烦。

所以,对于小数据量刷盘的情况下,可以使用MMap,例如索引,但是其他场景,FileChannel+DirectByteBuffer完全可以替代,并且性能跟MMap差不多。

堆内内存与堆外内存

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

对于堆外内存,可使用池+堆外内存组合。例如:ThreadLocal<ByteBuffer>ThreadLocal<byte[]>

Unsafe

Referrence

IO操作读写测试

pageCache的一些问题