1.4 pageCache
pagecache是kernel中的一个折中方案。
可以没有pagecache,如果没有pagecache的话,那么如果应用想要访问文件的话,应用程序只需要调用kernel,然后kernel访问磁盘,拿到数据后直接返回就结束了,但是磁盘是比较慢的,为了提升效率所以加了pagecache这一层缓存。
就像我们使用java读文件时会使用bytebuffer提升效率一样,kernel中会使用pagecache来提升效率。
当我们的应用程序需要向文件写数据时,一般会经过如下流程:
每一个pagecache就对应着磁盘文件的4K的内容,因此如果需要从磁盘中读取数据时也是一样的流程:
- 应用程序调用读文件的方法。
- 进行系统调用,用户态切换到内核态。
- 从pagecache中读数据。当pagecache中没有这一页数据时,就会抛出缺页异常,然后操作系统从磁盘文件中加载数据到pagecache中,然后在将数据返回给应用程序。
缺页异常
在linux系统中,文件存放在磁盘中,是按照块存储的。一块大约4K。当创建一个文件时,就会为这个文件分配一个Inode
对象,这个Inode
中存放的就是这个文件的块地址映射信息。因此创建一个空的文件时,也会占用4K的大小,1个块,当这个块填满时,如果追加内容,那么则会重新开辟一个块来存储。
当应用程序从文件中读数据时,因为内存大小是有限的,需要给很多应用提供服务,因此不可能把所有的空间都用来存放加载的文件数据。因此内存中使用pagecache来缓存磁盘中加载的数据,每一个pagecache对应磁盘中的一个块,同时内存中维护一张表来存放已经加载的块。在内存中pagecache是不连续的。
看上图,应用程序默认是整个内存空间都是属于自己可用的。但是实际使用中,一般都是运行多个进程,多个进程共享一个内存空间。如果应用程序直接使用物理内存的话,那么就会发生如下问题:
- 多个进程抢占同一块内存空间。
- 多个进程内数据分配地址不连续,寻址慢。
- 因为直接访问物理内存,那么进程之间可以访问到其他进程的数据,这是不安全的。
为了解决上面的问题,所以抽象出了虚拟内存这一层概念。
每一个进程都有一个自己的虚拟内存空间,这个虚拟内存空间只有自己可以使用,这样就实现了进程间资源的隔离,同时也避免了多个进程抢占同一块内存空间。而且整个虚拟内存只有自己可用,那么数据在分配内存地址时,内存地址是连续的,提升了效率。
而虚拟内存中的地址,会映射到一个实际的物理内存地址。
那么当两个进程同时需要操作一个文件时,因为进程间内存不可互相访问,因此,这个磁盘中的文件内容就会被加载到内存中两次。
这样就会造成内存的浪费,同时可能两个进程只需要读取文件的一小部分数据,而加载整个文件到内存中,如果文件非常大的话,就会造成内存空间不够的问题。为了解决上面的问题,因此出现了pagecache。
当两个进程都需要访问文件X时,其实内存中会维护一个pagecache的表,表示缓存的文件块范围。如果当前没有缓存要访问的内容,那么就会发生缺页异常,中断,从用户态切换到内核态。然后读取数据到pagecache中,然后返回给应用程序。多个进程访问同一块pagecache时,各自会有一个fd文件。
dirty脏页
文件会优先加载到pagecache中,只要内存还够用,那么就会一直加载。使用脚本查看文件的pagecache缓存状态。
import java.io.*;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
/**
* @author 赵帅
* @date 2021/4/20
*/
public class OSFileIO {
private static final String PATH = "/root/testfileio/output.txt";
private static final byte[] CONTENT = "123456789
".getBytes();
public static void main(String[] args) throws Exception {
String operation = args[0];
switch (operation) {
case "0":
basicFileIO();
break;
case "1":
bufferFIleIO();
break;
case "2":
randomAccessFileIO();
break;
case "3":
byteBuffer();
break;
default:
break;
}
}
private static void basicFileIO() throws Exception {
File file = new File(PATH);
FileOutputStream os = new FileOutputStream(file);
while (true) {
os.write(CONTENT);
}
}
private static void bufferFIleIO() throws Exception {
File file = new File(PATH);
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(file));
while (true) {
bos.write(CONTENT);
}
}
private static void randomAccessFileIO() throws Exception{
RandomAccessFile raf = new RandomAccessFile(PATH, "rw");
raf.write("hello world".getBytes());
raf.write("hello java".getBytes());
System.out.println("write ok........................");
// 阻塞
System.in.read();
raf.seek(4);
raf.write("ooxx".getBytes());
System.out.println("seek finished.....................");
System.in.read();
FileChannel rafchannel = raf.getChannel();
MappedByteBuffer map = rafchannel.map(FileChannel.MapMode.READ_WRITE, 0, 4096);
map.put("@@@".getBytes());
System.out.println("map--put--------");
}
private static void byteBuffer() {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
System.out.println("postition: " + buffer.position());
System.out.println("limit: " + buffer.limit());
System.out.println("capacity: " + buffer.capacity());
System.out.println("mark: " + buffer);
buffer.put("123".getBytes());
System.out.println("-------------put:123......");
System.out.println("mark: " + buffer);
buffer.flip(); //读写交替
System.out.println("-------------flip......");
System.out.println("mark: " + buffer);
buffer.get();
System.out.println("-------------get......");
System.out.println("mark: " + buffer);
buffer.compact();
System.out.println("-------------compact......");
System.out.println("mark: " + buffer);
buffer.clear();
System.out.println("-------------clear......");
System.out.println("mark: " + buffer);
}
}
准备上面的java文件,将这个文件运行在linux中,编辑启动脚本:
#!/bin/bash
# author: 赵帅
if [ $# -lt 1 ];
then
printf "Usage ./start.sh {0|1|2}
"
exit
fi
operation=$1
rm -rf ./out.*
file_name='OSFileIO'
class_file="${file_name}.class"
if test ! -e $class_file ; then
javac ${file_name}.java
fi
strace -ff -o out /root/jdk1.8.0_221/bin/java $file_name $operation
执行启动脚本,并新开一个窗口不断执行ll -h&& ./pcstat output.txt
。
可以看到pagecache缓存页数在不停的增加。
但是内存是有限的,当没有可用内存用来新增pagecache时,就要清理掉一部分pagecache用来增加新的pagecache。该清理掉哪儿写部分呢?这时就有一个dirty
脏页的概念。
什么是脏页?
当应用程序向文件中写数据时,优先写在pagecache中,只要pagecache中的数据被修改了,而且修改的内容还没有被刷写到磁盘中,那么这一页pagecache就会被操作系统标记为dirty
脏页。脏页中的数据什么时候写入磁盘?
[root@node01 testfileio]# sysctl -a |grep dirty
sysctl: reading key "net.ipv6.conf.all.stable_secret"
sysctl: reading key "net.ipv6.conf.default.stable_secret"
sysctl: reading key "net.ipv6.conf.ens33.stable_secret"
sysctl: reading key "net.ipv6.conf.lo.stable_secret"
# 控制脏页内存数量,超过dirty_background_bytes时,内核的flush线程开始回写脏页
vm.dirty_background_bytes = 0
# 控制脏页占可用内存(空闲+可回收)的百分比,达到dirty_background_ratio时,内核的flush线程开始回写脏页。默认值: 10
vm.dirty_background_ratio = 10
# 控制脏页内存数量,达到dirty_bytes时,执行磁盘写操作的进程开始回写脏页
vm.dirty_bytes = 0
# 控制脏页所占可用内存百分比,达到dirty_ratio时,执行磁盘写操作的进程自己开始回写脏数据。默认值:20
vm.dirty_ratio = 30
# 这个值表示page cache中的数据多久之后被标记为脏数据。只有标记为脏的数据在下一个周期到来时pdflush才会刷入到磁盘,这样就意味着用户写的数据在30秒之后才有可能被刷入磁盘,在这期间断电都是会丢数据的。
vm.dirty_expire_centisecs = 3000
# 5s的时间内核flush线程就会被唤起去刷新脏数据
vm.dirty_writeback_centisecs = 500
可以通过以上参数控制脏页的刷写。
vm.dirty_background_bytes
和vm.dirty_background_ratio
这两个参数只能指定一个,先设定的先生效,另一个会被清零。
vm.dirty_bytes
和vm.dirty_ratio
也是只能设置一个。
可以将上面配置写入/etc/sysctl.conf
文件中。
从上面参数可以看出,dirty的刷写是由阈值或者指定大小控制的,在页面上看到的写入的数据并不一定被写入了磁盘。加了一层pagecache本来是为了提高IO效率,但是响应的就会带来一个丢数据的风险。
pagecache就是一个4K大小的内存空间,linux内核中会为每一个pagecache维护一个索引,dirty标记就是在索引中
butebuffer
首先了解为什么bufferedIO比普通的IO快。
执行./start.sh 0
,等几秒钟后停止:
[root@node01 testfileio]# ./start.sh 0
^C[root@node01 testfileio]# ll
总用量 3032
-rw-r--r--. 1 root root 1922 4月 21 09:51 OSFileIO.class
-rw-r--r--. 1 root root 1563 4月 21 09:50 OSFileIO.java
-rw-r--r--. 1 root root 9909 4月 21 17:17 out.7466
-rw-r--r--. 1 root root 261066 4月 21 17:17 out.7467
-rw-r--r--. 1 root root 2710 4月 21 17:17 out.7468
-rw-r--r--. 1 root root 1255 4月 21 17:17 out.7469
-rw-r--r--. 1 root root 1084 4月 21 17:17 out.7470
-rw-r--r--. 1 root root 2121 4月 21 17:17 out.7471
-rw-r--r--. 1 root root 5660 4月 21 17:17 out.7472
-rw-r--r--. 1 root root 3295 4月 21 17:17 out.7473
-rw-r--r--. 1 root root 960 4月 21 17:17 out.7474
-rw-r--r--. 1 root root 15397 4月 21 17:17 out.7475
-rw-r--r--. 1 root root 1832 4月 21 17:17 out.7476
-rw-r--r--. 1 root root 3770 4月 21 17:17 output.txt
-rwxr-xr-x. 1 root root 2759061 4月 20 14:01 pcstat
-rwxr-xr-x. 1 root root 312 4月 20 18:55 start.sh
[root@node01 testfileio]# jps -l
7478 sun.tools.jps.Jps
[root@node01 testfileio]#
最大的out文件就是主线程的文件,打开out.7467
,搜索123456789
可以看到如下内容:
................
write(4, "123456789
", 10) = 10
write(4, "123456789
", 10) = 10
write(4, "123456789
", 10) = 10
write(4, "123456789
", 10) = 10
write(4, "123456789
", 10) = 10
write(4, "123456789
", 10) = 10
..........
会看到非常多的write(4, "123456789
", 10) = 10
,每一个write
调用都是一次系统调用,都会产生用户态和内核态的切换,而每次都只写了8个字节的数据。
再执行./start.sh 1
,重复上面操作,可以看到如下内容:
..............
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
write(4, "123456789
123456789
123456789
12"..., 8190) = 8190
...........
可以看到调用bufferedIO后,每次调用write方法时,写入的数据为8KB,相比一般IO每次系统调用只写8字节相比。BufferedIO减少了非常多的用户态和内核态的切换过程。所以bufferedIO比一般IO更快。
上面都是阻塞式IO,即传统的BIO(java.io.*)
,现在java有了nio(java.nio.*)
,下面尝试使用nio来操作文件。首先了解byteBuffer类,查看OSFileIO
的byteBuffer
方法:
private static void byteBuffer() {
// 创建一个堆内内存的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 创建一个堆外内存的缓冲区
//ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
System.out.println("postition: " + buffer.position());
System.out.println("limit: " + buffer.limit());
System.out.println("capacity: " + buffer.capacity());
System.out.println("mark: " + buffer);
buffer.put("abc".getBytes());
System.out.println("-------------put:abc......");
System.out.println("mark: " + buffer);
buffer.flip(); //读写交替
System.out.println("-------------flip......");
System.out.println("mark: " + buffer);
buffer.get();
System.out.println("-------------get......");
System.out.println("mark: " + buffer);
buffer.compact();
System.out.println("-------------compact......");
System.out.println("mark: " + buffer);
buffer.clear();
System.out.println("-------------clear......");
System.out.println("mark: " + buffer);
}
首先查看ByteBuffer
内的属性,执行System.out.println("mark:" + buffer)
后输出如下:
mark: java.nio.HeapByteBuffer[pos=0 lim=1024 cap=1024]
有三个属性:
postition
: 可以理解为头指针limit
: 尾指针capacity
:容量
刚初始化时,bytebuffer内部就长着个样子。当调用put方法存入字节数时buffer.put("123".getBytes());
mark: java.nio.HeapByteBuffer[pos=3 lim=1024 cap=1024]
每放一个字节,postition
指针右移一位。当postition
与limit
重合时表示缓冲区满了。
当需要从缓冲区读数据时,需要先调用buffer.flip();
进行翻转。翻转后的指针状态为:
mark: java.nio.HeapByteBuffer[pos=0 lim=3 cap=1024]
postition
表示下一个可读的字节位置,limit
表示最后一个可读的字节位置。每调用一次get
方法,postition
就会右移一位。
compact
: 压缩缓冲区,当在上面的基础上取出第一个字节后,postition
就会移动到b
所在下标,那么此时如果要继续写数据的话,第一个字节位置就空出来了,可以调用compact方法压缩空间,调用后内部结构如下:
调用压缩方法后,就可以继续写入数据了。
mmap
在pagecache中写到每个进程都会分配一个Heap堆,这个堆事C语言的堆,也就是为这个进程分配的堆。那么在运行java应用程序时。这个堆也就是为java这个进程分配的堆。但是java的运行是多线程的。会有一个jvm的守护线程。这个线程也会有一个堆,java程序的对象都存放在这个堆中。那么关系图就是下面这样:
前面说了进程写入文件的所有数据最终都写入了pagecache中,那么当java进程想要将数据写入文件时,会有这么一个过程:
如果写在堆内,就会经过一个堆内数据向堆外数据的拷贝过程,因此堆外比堆内的效率更高。
在文件IO中,还有一种性能更高的方案 mmap。直接写入堆外内存,可以减少一次copy的过程,但是从堆外内存写入pagecache中,仍然存在用户态到内核态的切换,使用mmap就可以在不切换内核态的情况下,在用户态直接将数据写入pagecache中。
什么是mmap
只有文件IO才会有mmap,mmap是通过在应用程序的虚拟地址空间创建一个映射,直接映射到内存的一块pagecache中。这样应用程序写的数据可以直接送到pagecache中,避免了系统调用。
mmap虽然可以直接映射到pagechche中,不需要状态的切换,但是在pagecache将数据刷写到磁盘时,仍然还是需要切换到内核态的,而且使用mmap仍然存在pagecache可能丢数据的缺点。
mmap使用方式:
// 输入输出流获取fileChannel
FileInputStream fis = new FileInputStream();
FileChannel fc = fis.getChannel();
// fc获取mmap
MappedByteBuffer map = rafchannel.map(FileChannel.MapMode.READ_WRITE, 0, 4096);