内核中PageCache和java文件系统io+nio以及内存中缓冲区的作用

1,520 阅读8分钟

准备工作

Linux磁盘IO关于脏页数据写入磁盘的方式的配置,是可以通过配置文件配置的:

[root@node1 ~]# sysctl -a | grep dirty

vm.dirty_background_ratio = 0
vm.dirty_background_bytes = 1048576
vm.dirty_ratio = 0
vm.dirty_bytes = 1048576
vm.dirty_writeback_centisecs = 5000
vm.dirty_expire_centisecs = 30000

解释一下,这几个配置的含义:

  • vm.dirty_background_ratio:内存可以填充脏数据的百分比。脏数据大小达到指定的内存的百分比的时候,才会写入磁盘。比如内存大小为10G,配置该项值为90,意思是可以有10G*90%=9G的脏数据待在内存,超过9G才会有后台进程来清理(写入磁盘)。
  • vm.dirty_ratio:可以用脏数据填充的绝对最大系统内存量,当系统到达此点时,必须将所有脏数据提交到磁盘,同时所有新的I/O块都会被阻塞,直到脏数据被写入磁盘。这通常是长I/O卡顿的原因,但这也是保证内存中不会存在过量脏数据的保护机制。
  • vm.dirty_background_bytesvm.dirty_bytes是另一种指定这些参数的方法。如果设置_bytes版本,则_ratio版本将变为0,反之亦然。
  • vm.dirty_expire_centisecs: 指定脏数据能存活的时间。
  • vm.dirty_writeback_centisecs:指定多长时间清理脏数据的进程会唤醒一次,然后检查是否有缓存需要清理。

配置

[root@node1 testfileio]# vi /etc/sysctl.conf
# 后台方式,内存假设可用10个G,在程序使用IO的时候,一直到占用了9个G的时候,才会真正写入到磁盘(这时还会继续IO,只是会再起一个线程把数据写入到磁盘)---可能会丢数据
vm.dirty_background_ratio = 90
# 假设程序疯狂地向内核写数据,达到可用内存的90%,就不会继续写
vm.dirty_ratio = 90
# 任务线程时间的纬度 
# 50s一次写入磁盘
vm.dirty_writeback_centisecs = 5000
# 300s延时,也就是说虚拟机突然断电,这个时间应该不会写入磁盘,这个时间配置为了演示pagecache会丢数据
vm.dirty_expire_centisecs = 30000

使配置生效

[root@node1 ~]# sysctl -p

开始操练

内核中PageCache与Java文件系统IO

1. 为了方便,写一个shell脚本 test.sh
rm -rf *out*
/root/soft/jdk1.8.0_131/bin/javac OSFileIO.java
strace -ff -o out /root/soft/jdk1.8.0_131/bin/java OSFileIO $1

这个脚本的意思就是执行OSFileIO这个Java程序,并用strace追踪Java程序运行过程中与磁盘IO交互的过程,并记录到out文件中。

2. 先看程序main方法
public static void main(String[] args) {
    //whatIsByteBuffer();
    switch (args[0]) {
        case "0":
            basicFileIO();
            break;
        case "1":
            bufferFileIO();
            break;
        case "2":
            randomAccessFileWrite();
            break;
        default:
            break;
    }
}

调用:./test.sh 0

表示运行java程序,并传入参数0,也就是执行case "0"分支。

3. 最基本的File IO

基本File IO的写操作

/**
 * 最基本的File写操作
 */
public static void basicFileIO(){
    File file = new File(path);
    try {
        FileOutputStream out = new FileOutputStream(file);
        //不停写入数据,配合给虚拟机断电,观察pagecache
        while (true) {
            out.write(data);
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

执行 ./test.sh 0,观察out.txt文件大小变化(程序不停的向out.txt文件写数据):

[root@node1 testfileio]# ./test.sh 0

再开启一个连接这台虚拟机的标签页,用命令ll -h && pcstat out.txt观察被写入的文件out.txt的大小变化,以及它在OS中的缓存情况。由于basicFileIO方法写的是死循环不停的写入,可以不停的执行命令观察。下面截取三个时间点的运行情况:

从图中暂时可以得出一个结论:用基本File IO的方式,文件写入的速度不快。(实际操作观察时发现,每次ll -h查看文件大小增长不快。)

此时直接给虚拟机断电,由于前面我们配置的是脏数据在内存中占到90%的时候才写入磁盘,而此时才写到10几M左右,数据仍在内存中,所以大胆猜测一下:断电后写入到out.txt文件中的数据将丢失!!!

再次启动虚拟机,验证一下,依然是执行ll -h && pcstat out.txt

out.txt appears to be 0 bytes in length 数据全部丢失了!

因此可以得出结论:PageCache是优化IO性能的东东,但是也会丢失数据的。

4. buffer 文件IO

演练一下,看看Buffer IO是否比上面基本的File IO速度快点。

public static void bufferFileIO() {
   File file = new File(path);
    try {
        BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file));
        while (true) {
            out.write(data);
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

执行./test.sh 1,运行bufferFileIO方法,用ll -h && pcstat out.txt观察文件大小变化。

文件增长太快,来不及截图了。。。

进行断电操作,同样能验证pagecache会丢失数据的特点。

此时又能得出一个结论了:Java使用Buffered IO(比如BufferedBufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file)))操作比基本的(比如FileOutputStream out = new FileOutputStream(file))文件操作速度快。

原因:

JVM中使用Buffer时会开辟一个8KB大小的字节数组,程序是每次写10个字节,这10个字节并没有交给内核,而是放在了JVM开辟的字节数组,8KB满了以后,才会调用一次内核的syscall write

而普通的文件IO操作,是写满10字节后直接调用内核的syscall write

也就是说在用户态与内核态的切换上,Buffer IO操作明显比普通的文件IO操作少,所以它快一些。

Java新的文件系统NIO(java.nio)

由前面的结论得知Java IO的Buffer操作性能好,Java NIO很多新的功能也是基于buffer的,先来看一下ByteBuffer这个东东。

ByteBuffer

看代码

public static void whatIsByteBuffer(){
    //在JVM堆上分配
    //ByteBuffer buffer = ByteBuffer.allocate(1024);
    //在JVM堆外分配(直接内存)
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

    System.out.println("================ ByteBuffer ==============");
    System.out.println("position:" + buffer.position());
    System.out.println("limit:" + buffer.limit());
    System.out.println("capacity:" + buffer.capacity());
    System.out.println("bytebuffer mark:" + buffer);

    //写
    buffer.put("666".getBytes());
    System.out.println("=============== buffer put 666 ============");
    System.out.println("bytebuffer mark:" + buffer);

    //读写交替
    buffer.flip();
    System.out.println("=============== buffer flip ============");
    System.out.println("bytebuffer mark:" + buffer);

    //读
    byte b = buffer.get();
    System.out.println("=============== buffer get ============");
    System.out.println("buffer get :" + b);
    System.out.println("bytebuffer mark:" + buffer);

    buffer.compact();
    System.out.println("=============== buffer compact ============");
    System.out.println("bytebuffer mark:" + buffer);

    buffer.clear();
    System.out.println("=============== buffer clear ============");
    System.out.println("bytebuffer mark:" + buffer);
}

运行结果

ByteBuffer可以理解为一个字节数组。

ByteBuffer的两种内存分配方式ByteBuffer.allocate(1024)ByteBuffer.allocateDirect(1024)不影响执行api结果。

  • position 偏移指针
  • limit 大小限制
  • capacity 总容量大小

bytebuffer初始状态:

buffer.put("666".getBytes())后,position指针移动三个字节:

所以put 3字节后运行结果:java.nio.DirectByteBuffer[pos=3 lim=1024 cap=1024]

如果想要读取bytebuffer,必须先flip一下,将position指针移动到0的位置,limit指针移动到之前写入的位置:

所以flip运行结果 java.nio.DirectByteBuffer[pos=0 lim=3 cap=1024]

filp以后就可以get了,每次get不传参数的话,get一个字节:

所以get后运行结果:java.nio.DirectByteBuffer[pos=1 lim=3 cap=1024]

由于前面flip将limit指针移动到最近一次写入的位置,如果想要继续使用剩余的bytebuffer空间进行写入,需要调用compact,将前面get到的挤压掉,position来到剩余空间的开始位置,limit回到最大的位置:

运行结果:java.nio.DirectByteBuffer[pos=2 lim=1024 cap=1024]

调用清除clear就好理解了。

RandomAccessFile & FileChannel & MappedByteBuffer

RandomAccessFile可以随机访问文件的内容,可通过seek来定位内容位置,并可以直接write数据到文件。

FileChannel 文件通道,入门Java NIO了!

MappedByteBuffer 只有文件通道才有mmap映射,socket通道没有。mmap是堆外的和文件映射的东西。

来看一段代码

/**
 * 文件NIO
 */
public static void randomAccessFileWrite() {
    try {
        RandomAccessFile raf = new RandomAccessFile(path, "rw");

        raf.write("hello world\n".getBytes());
        raf.write("hello china\n".getBytes());
        System.out.println("-------------- RandomAccessFile written ---------------");
        System.in.read();//阻塞住,按回车键继续执行

        raf.seek(4);
        raf.write("xxoo".getBytes());
        System.out.println("-------------- RandomAccessFile seek ---------------");
        System.in.read();

        //Java NIO来了!!!
        FileChannel channel = raf.getChannel();
        //mmap jvm堆外的 和文件映射的
        MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, 4096);
        map.put("@@@".getBytes());//不是系统调用,但是数据会到达内核的pagecache
        System.out.println("------------- MappedByteBuffer map put ------------");
        System.in.read();

        raf.seek(0);
        ByteBuffer buffer = ByteBuffer.allocate(8192);
        //ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

        int read = channel.read(buffer);   //buffer.put()
        System.out.println(buffer);
        buffer.flip();//翻转一下才能读取
        System.out.println(buffer);

        for (int i = 0; i < buffer.limit(); i++) {
            Thread.sleep(200);
            System.out.print(((char)buffer.get(i)));
        }

    } catch (Exception e) {
        e.printStackTrace();
    }
}

程序在等待着输入,这时看一下文件内容:

那么此时out.txt的内容在磁盘上吗?不在,在pagecache,因为还没有做刷入的操作。

按回车键,继续往下执行:

说明seek完了,再来看一下out.txt:

执行完

raf.seek(4);
raf.write("xxoo".getBytes());

这两句代码后,发现文件内容从seek的位置重新写入了。 这就是RandomAccessFile的随机读写能力。

此时程序还在继续运行(用System.in.read()阻塞住了),用jps查看java进程,并使用lsof -p查看进程产生的一些文件描述:

由图中可以看出,out.txt并没有mem的描述,说明 还没有建立起内存与文件的映射。

回到程序运行界面,按下回车,继续运行:

//Java NIO来了!!!
FileChannel channel = raf.getChannel();
//mmap jvm堆外的 和文件映射的
MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, 4096);
map.put("@@@".getBytes());//不是系统调用,但是数据会到达内核的pagecache
System.out.println("------------- MappedByteBuffer map put ------------");

运行完了,再来lsof -p

这次有文件内存映射了!!!

这个时候看一下out.txt的内容:

“@@@”字符写入了(map.put("@@@".getBytes())),并且文件大小涨到了4096(channel.map(FileChannel.MapMode.READ_WRITE, 0, 4096)

说一下map.put

它不是系统调用syscall,但是数据会到达内核的pagecache 之前我们是需要out.write()这样的系统调用,才能让程序的data进入内核的pagecache,也就是说之前必须有用户态内核态切换。

但是mmap的内存映射,依然是内核的pagecache体系所约束的!!!也就是说会丢数据。

C语言写的jni扩展库,可使用linux内核的Direct IO---直接IO。直接IO是忽略linux的pagecache的。它是交给了程序自己开辟一个字节数组当作pagecache,动用代码逻辑来维护一致性/dirty等一系列复杂问题。

小结

  • PageCache是内和维护的中间层,其使用内存、延时时间等可以进行配置,并且有数据淘汰,也会丢失数据
  • Java IO的基本IO操作比Buffer IO操作性能低,原因是基本的IO操作用户态与内核态之间的切换次数比使用buffer多。
  • Java NIO的MappedByteBuffer只能是文件的NIO才有内存文件映射。
  • mmap写入数据会直接到达pagecache,不需要系统调用,没有用户态内核态的切换,但是依然会丢数据。