Java文件IO(全是干货)

250 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情

目的

从Java文件相关API自下而上聊一聊Java开发中文件读写的常用操作,如果你的需求有文件的相关操作,这篇文章会对你有很大帮助。

Java读写文件方式

Java中提供了三个方式操作文件:

Java 方式Java对应API对应操作系统的操作
普通IOJava基本的文件IO,如FileWrite、FileReader、RandomAccessFile正常文件读写,如 file = open("xxx");file.readfile.write
FileChannelFileChannel fileChannel =newRandomAccessFile(newFile("db.data"),"rw").getChannelsendFile
MMPFileChannel fileChannel =newRandomAccessFile(newFile("db.data"),"rw").getChannel MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,0, filechannel.sizevoid *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

三者都能够满足基本的文件操作,下面是个人视角说明一下三者的区别。

机械硬盘

作为Java开发的你,有没有想过,如果给你一块机械硬盘,你要怎么才能使用它来读写数据?

image.png

机械硬盘结构

image.png

机械硬盘的输入输出

机械硬盘读取与写入数据的最小单位是扇区,很早前一个扇区=512byte,现在基本是4k。 因为写入是以扇区为基本单位,所以这个地方要注意写入之前可能需要把整个扇区读出来,修改内容在写入进去。

而确定一个扇区的位置需要指定三个参数:

  1. 盘片
  2. 磁头
  3. 磁道
  4. 扇区

如何使用代码控制机械硬盘

比如主板的bios从第一块磁盘的0x7c00位置读取操作系统的代码,实际该如何编写代码,运行代码?

机械硬盘插入主板后,就可以通过汇编指令控制机械硬盘。

image.png

机械硬盘性能

说到性能一定离不开指标,每秒读多少数据,每秒写多少数据则是机械硬盘的性能指标。

写一个扇区的耗时=旋转延迟+寻道时间(加速、惯性、减速、停放时间)+ 磁头写入时间

那么写2个扇区的耗时=2 * 写一个扇区的耗时 ? 答案是:No,要分情况。

如果两个扇区不相邻,则是需要 * 2。但如果两次扇区相邻(同个磁道、相邻扇区),则只需要进行一次寻道耗时,旋转延迟则是 * 2。 这就是磁盘层面顺序写入性能优于随机写入。顺序读则是同样原理。

小结

机械硬盘这里可能说的不够详细,比如柱面有几个,扇区有几个,顺序写这部分内容可以参考《操作系统导论》一书。实际代码控制机械硬盘可以参考《30天自制操作系统》。由于不同的硬盘,他对应的参数、调用方法不一样,所以操作系统通常会抽象出来一层接口,然后由机械硬盘的厂商提供对应实现,这个实现就是驱动程序。用个图来总结一下:

image.png

磁盘IO调度

机械硬盘的耗时=寻道耗时+旋转耗时,其中寻道耗时对性能的影响尤为明显,所以如果每次对机械硬盘的读取和写入都需要进行寻道就不是很好,解决这一问题的方式可以聚合写入,比如第一次写先保留,等到后面如果在来一次写并且这次写入的磁道都是同个,扇区还是相邻,就可以使用顺序写入,从而提高写入性能。

对于这种磁盘的IO调度优化,操作系统抽象出了IO调度层来解决这个问题。所以我们的图现在是这样:

image.png

文件系统

有了硬盘和驱动,我们能够存储我们的数据,但是数据如何分类,多个数据如何管理呢? 基于文件和目录的文件系统解决了数据管理的问题。

现在问题来了,给你一块硬盘,来帮忙设计一个文件系统。 juejin.cn/post/699260… 这篇文章有一个简单的文件系统设计。

但实际上文件系统也是有很多的实现,所以操作系统又是搞了一层抽象,VFS(虚拟文件系统)。 加上文件系统,新图如下:

image.png

pagecache

用记事本打开文件,他会把文件一次全加载进内存吗? NO,只会加载一部分,当你往下看才会继续加载。对于这个场景,每次加载都会进行一次寻道+旋转耗时。 我这有个办法,我一次加载2部分到,显示一部分,当你继续往下看,第二部分直接从内存读取,是不是就减少了一次寻道+旋转的耗时?

写入也是同理,写入我先把写入的内容存储道内存,攒一波之后在一起写入,把同个磁道,相邻扇区的写入进行聚合,岂不是美滋滋。

这一层就是操作系统的page cache,但他与IO调度层是有很大不同的,page cache完全就是内存,很明显会断电丢数据,IO调度则是不同,会持久化。

另外一个问题是page cache放在图中哪个位置呢?

如果放在文件系统上,那么page cache进行缓存的对象就是文件、目录。比如预读的缓存,就需要把文件当作缓存的key,文件内容作为缓存的value。

如果放在IO调度层上面一层,page cache进行缓存的对象就是更加底层的磁道+扇区。

个人感觉选第二种更加通用一些:

image.png

应用程序

最后就是应用程序直接使用操作系统提供的库函数,可以对文件进行操作。

image.png

sendFile

sendFile是操作系统提供的API,可以直接将文件流怼进socket流,将socket流直接怼进文件流,从而减少了在读文件写入socket和读socket写入文件两个场景的上下文切换以及数据拷贝(说优化不讲场景的都是刷流氓😄)。

对应Java API中,使用方式如下

FileChannel fileChannel =newRandomAccessFile(newFile("db.data"),"rw").getChannel

对于单独的read、write而言,FileChannel会比FileReader、FileWriter会更有性能优势吗?

我个人觉得没有性能优势,但使用优势很明显:

  • 使用ByteBuffer API,操作字节数据更方便
  • sendFile,普通的FileWriter没有这个API

网上也有说page cache的,我感觉根本站不住脚。因为不论普通文件API,还是FileChannel,底层都是一套文件系统,都会走page cache。

内存映射

内存映射则是真正的黑科技了。Java中使用如下:

FileChannel fileChannel =newRandomAccessFile(newFile("db.data"),"rw").getChannel 

MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE,0, filechannel.size)

这个API有个特点,指定1G的size,在map执行完后会立即创建1g大小的文件。

相比FileChannel,内存映射少了一次copy+用户态到内核态的上下文切换,所以如果你的Java应用程序需要频繁调用read、write,则可以试试MMAP,没准可能会更快。

另外是MMAP的回收非常麻烦:

总结

三者对比以及使用场景:

正常IOFileChannelMMAP
回收方式close()close()各个jvm不一样使用netty的platform工具类最方便
一次write上下文切换次数110
一次write数据拷贝次数221
是否使用page cache类似page cache
预读大小默认4k默认4k默认32k
额外内存消耗占用页表内存
是否具备字节对齐刷盘特性
小字节写入更快
支持ByteBuffer
必须指定文件大小

如果你需要文件读写,下面最佳实践:

  1. 只要是文件读写,一上来直接用FileChannel
  2. 写完代码没事干,可以试试MMAP,当然还是要抓住特性下面几个特性:
    • 你的文件不需要指定大小
    • 小字节写入
    • 频繁使用write或者read

一个经典的实战例子是MQ消息的存储,存储消息使用FileChannel,不需要分割文件,无限追加。索引则使用MMAP,因为索引是小字节。两者结合可能是一个最佳实践。