IO学习笔记3

1.4 pageCache

pagecache是kernel中的一个折中方案。

可以没有pagecache,如果没有pagecache的话,那么如果应用想要访问文件的话,应用程序只需要调用kernel,然后kernel访问磁盘,拿到数据后直接返回就结束了,但是磁盘是比较慢的,为了提升效率所以加了pagecache这一层缓存。

就像我们使用java读文件时会使用bytebuffer提升效率一样,kernel中会使用pagecache来提升效率。

当我们的应用程序需要向文件写数据时,一般会经过如下流程:

每一个pagecache就对应着磁盘文件的4K的内容,因此如果需要从磁盘中读取数据时也是一样的流程:

  1. 应用程序调用读文件的方法。
  2. 进行系统调用,用户态切换到内核态。
  3. 从pagecache中读数据。当pagecache中没有这一页数据时,就会抛出缺页异常,然后操作系统从磁盘文件中加载数据到pagecache中,然后在将数据返回给应用程序。

缺页异常

在linux系统中,文件存放在磁盘中,是按照块存储的。一块大约4K。当创建一个文件时,就会为这个文件分配一个Inode对象,这个Inode中存放的就是这个文件的块地址映射信息。因此创建一个空的文件时,也会占用4K的大小,1个块,当这个块填满时,如果追加内容,那么则会重新开辟一个块来存储。

当应用程序从文件中读数据时,因为内存大小是有限的,需要给很多应用提供服务,因此不可能把所有的空间都用来存放加载的文件数据。因此内存中使用pagecache来缓存磁盘中加载的数据,每一个pagecache对应磁盘中的一个块,同时内存中维护一张表来存放已经加载的块。在内存中pagecache是不连续的。

看上图,应用程序默认是整个内存空间都是属于自己可用的。但是实际使用中,一般都是运行多个进程,多个进程共享一个内存空间。如果应用程序直接使用物理内存的话,那么就会发生如下问题:

  1. 多个进程抢占同一块内存空间。
  2. 多个进程内数据分配地址不连续,寻址慢。
  3. 因为直接访问物理内存,那么进程之间可以访问到其他进程的数据,这是不安全的。

为了解决上面的问题,所以抽象出了虚拟内存这一层概念。

每一个进程都有一个自己的虚拟内存空间,这个虚拟内存空间只有自己可以使用,这样就实现了进程间资源的隔离,同时也避免了多个进程抢占同一块内存空间。而且整个虚拟内存只有自己可用,那么数据在分配内存地址时,内存地址是连续的,提升了效率。

而虚拟内存中的地址,会映射到一个实际的物理内存地址。

那么当两个进程同时需要操作一个文件时,因为进程间内存不可互相访问,因此,这个磁盘中的文件内容就会被加载到内存中两次。

这样就会造成内存的浪费,同时可能两个进程只需要读取文件的一小部分数据,而加载整个文件到内存中,如果文件非常大的话,就会造成内存空间不够的问题。为了解决上面的问题,因此出现了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_bytesvm.dirty_background_ratio这两个参数只能指定一个,先设定的先生效,另一个会被清零。

vm.dirty_bytesvm.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类,查看OSFileIObyteBuffer方法:

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指针右移一位。当postitionlimit重合时表示缓冲区满了。

当需要从缓冲区读数据时,需要先调用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);
原文地址:https://www.cnblogs.com/Zs-book1/p/14693934.html