Java开发中通常使用文件的读取API来进行文件读取,那么文件读取是如何实现的呢,又经历了哪些过程呢?
1.Java API
byte[] buffer = new byte[1024];
FileOutputStream fos = new FileOutpuStream("/tmp/1.txt")
fos.read(buffer);
以上是java读取文件的相关API,当然除了这种直接读取的方式,还有mmap等方式,但实际上查看源码会发现最终都会调用到native方法转到JDK中的C方法执行。而C方法中,其实调用的是来操作系统提供的C语言函数库。
2.系统调用
执行C语言函数库,会触发操作系统预先设置的中断表,从而触发系统调用。关于系统调用的两个额外性能消耗就不多说了,可以参考之前的文章:
- 系统调用如果传递指针参数,需要从用户空间拷贝数据到内核空间
- 会发生进程切换,从用户进程切换到操作系统进程
3.虚拟文件系统
经过系统调用,操作系统掌握了执行权,真正实现目录和文件的是文件系统,Linux中搭载了不同的文件系统,如ext2,ext3,tmpfs等。这种多实现通常都会有一套抽象接口,文件系统的抽象接口就是虚拟文件系统。虚拟文件系统定义了文件系统都需要实现的方法,比如目录的操作:
- 创建目录
- 移动目录
- 删除目录
比如文件的操作:
- 打开文件
- 读取文件
- 写入文件
4.Page Cache
都知道IO的性能是很低下的。比如机械硬盘读取数据,需要经过寻道时间、旋转延迟、磁头读取数据这三个耗时。相比SSD的闪存来说简直是一个单个一个火箭。所以在操作系统中,提供了一层Page Cache,其实就是内存的页管理,一个page大小是4k。
其实不仅仅是读可以使用page cache,写入也会先写page cache,既然写入有cache,必定会有缓存的淘汰策略:
- 系统有两个文件设置page cache大小的上限,超过这个限制会直接进入后续IO,不会经过page cache
/proc/sys/vm/dirty_bytes 表示page cache的最大字节数
/proc/sys/vm/dirty_ratio 标示page cache占用总内存的最大百分比
第一个策略只会保证脏页的数量不会太多。 2. 内核后台线程周期性检查page cache是否达到上限,其限制与1相同,不过是另外两个文件
- 前两条只能保证脏页上限,所以内核线程还会判断脏页的存活时间是否到达限制,其存活时间配置文件是,单位是百分之1秒。
当读取某个文件数据时候,如果该文件数据在page cache存在(可以用磁盘扇区的地址当做缓存的key),则直接将数据从page cache拷贝的用户空间直接返回。如果缓存不存在,则需要去读磁盘,在放入缓存。
此时说一个题外话:为什么Page Cache大小是4K?
说明这个问题之前,我们需要先说明为什么内存管理采用分页的方式?在分页之前,内存管理都是采用段+偏移地址的方式计算出内存的物理地址进行访问。然后这种方式有个问题:
如上图所示,有一块内存运行程序ABC。分别使用了10M、20M、30M内存空间分配给ABC进程。这时,进程B运行结束,释放20M内存。此时进程D开始执行,但是进程D需要30的内存空间,但是由于内存碎片的问题导致没有办法分配至一块连续的30M内存空间出来。
通过这个例子,我想大家已经看出了段+偏移地址访问内存的缺点,那就是分段情况下,CPU认为线性地址等于物理地址,而线性地址是由编译器编译出来,本身是连续的,所以物理地址也必须是连续的。而分段情况下物理地址不连续导致无法使用。而解决这个问题,我们只需要做一层映射关系,将连续的线性地址映射到不连续的物理地址即可。这个机制称为分页。
从图中可以看到,线性地址连续,但是物理地址可以不连续。用大小相等的块代替大小不等的段。因为块相比段更有利于内存管理,减少碎片。
我们可以做一下设想,比如每个小块都是1字节,对应到线性地址也是一字节。比如线性地址是0x01,则物理地址可以是0x01,也可以是0x02,只需要做一个映射即可,这个映射我们称为页表。但是困难在于4G的物理地址,需要有4G个映射,页表的每一项需要保存【第几个线性地址,物理地址】数据,称为页表项,因为地址大小是4G,所以每一项的大小是4byte(线性地址不需要保存,直接通过下标表示)。4G个页表项,其大小就是8byte * 4G = 16G。所以如果一个字节一个字节映射就需要16G内存来保存页表信息,这个方案完全不行。
那么如何减少页表大小呢?
看其原因,是因为选择的块的大小太小,导致页表项太多,所以可以适当减少页表项,比如32位可以分为两部分,高位地址和低位地址,高位地址表示内存块的数量,低位地址保存块的大小。只需要满足块数量 * 块大小=4G 即可。如果内存块数量太多会导致页表项太多,占用空间太大。内存块数量太小又会导致块太少切大,导致浪费内存。所以块的大小需要选择一个合适的值。操作系统作者就发现,块大小=2^12=4K的时候比较好,此时块的数量=2^20=1024*1024=1M个块。
再从磁盘的读写看,机械硬盘以扇区(512byte)为单位进行读写,page cache缓存大小4k=512 * 8 正好是其倍数,也利于磁盘的读写。
5.通用块管理层
机械硬盘的读写都是以一个扇区(512byte)为单位,而ssd也是沿用这种设计。所以为了更加通用的操作不同块存储介质,抽象出一层通用的块管理层接口。具体的实现则由真正的驱动实现。
6.IO调度算法
上面提到机械硬盘的读取和写入都需要经过
- 寻到耗时
- 旋转耗时
- 磁头读写数据耗时 三个加起来非常之慢,所以操作系统除了提供page cache的缓存机制提速。还使用了一些硬盘的特性来提速。比如机械硬盘有个特性:第一秒时需要向写入扇区1写入数据,第5秒时需要想扇区2写入数据。这会发生两次的IO耗时。假设第一秒不实际执行,等到第5秒时和第二次IO一起执行,由于扇区1、2连续,只需要进行一次IO耗时即可,处于这种特殊性质(比如先创建文件在删除文件)进行优化,Linux操作系统提出了IO调度层来做这个事情。
7.驱动程序
硬盘的驱动程序是真正与硬件进行交互的程序。
6.硬盘
通过驱动程序发送读取数据的指令,从硬盘中读取数据,再通过DMA可以无需操作系统即可将数据从硬盘寄存器拷贝的内核空间。
总结
page cache 的存在虽然可以极大加快IO速度,但也因为有cache会产生脏页,如果此时断电,数据就没了。所以数据库相关的中间件一定是有IO直接持久化的机制。比如MySQL的redo log。每次写redo log不可能会使用page cache,不然断电数据就没了。