美文网首页
从Linux内核理解Java中的IO

从Linux内核理解Java中的IO

作者: 万古Meth | 来源:发表于2020-07-05 16:54 被阅读0次

    前言

    刚接触 Java IO 的时候, 一直有一个 困惑:为什么 BufferedInputStreamFileInputStream 快? 随着对 Linux 了解,这个问题也得到解决。最近也在看 Linux 内核 方面的书,想了解程序在 Linux 上运行的过程,感觉收获还是很多的。

    基于安全考虑,只有 Linux内核 才能权限去访问计算机的硬件,Linux内核会提供一些接口(系统调用)让我们可以和硬件交互。不过数据一般都是从硬件内核态 ,再从 Linux内核 复制到 用户态 进程的内存空间中,这样进程才能对读取的数据进行处理。

    image-20200704231239764

    本文内容:

    • Linux 中的虚拟文件系统介绍
    • Page Cache 和 Dirty Page
    • Java api 写入的数据,什么时候会被刷新到磁盘中

    Linux 中 虚拟文件系统(VFS)

    虚拟文件系统(Virtual File System,简称VFS)是Linux内核的子系统之一,它为操作文件(普通文件,socket 等)提供了统一的接口,屏蔽不同的硬件差异和操作细节。我们只需调用 openreadwriteclosefsync 这些系统调用,达到操作文件的目的。

    我们实际看到的 linux的目录,实际就是 VFS 中的路径,我们可以通过将硬盘中的分区挂载到 linux 中的路径下,访问虚拟文件系统中的路径既可以访问硬盘中的内容。

    df -i 可以看到 VFS 中路径挂载的分区。

    image-20200704233624173
    # 将分区挂载到虚拟文件系统的 /boot 目录下
    mount /dev/sda1 /boot
    
    # 卸载分区
    umount /boot
    

    操作系统会将硬盘分成两个区域,一个是数据区,用于保存文件的数据;还有一个 Inode 区用于保存文件的元数据(文件创建者,文件创建时间,文件权限,文件大小,块位置等)。

    硬盘的最小存储单位叫做"扇区"(Sector),每个扇区储存512字节(相当于0.5KB)。Linux 内核 从硬盘读取内容时,不会一个扇区一个扇区读,而是一次性读取多个扇区,即一次性读取一个 块(Block)。文件的数据内容储存在 中。

    基于以上介绍,可以知道,实际一个文件必须占有一个 Inode 和 至少一个 block

    df -i 可以查看分区中,inode 的使用情况和分区对应 Linux 下的文件路径。

    查看文件的 Inode 的基本大小(一般 4KB)

    image-20200705002110818

    当应用程序调用系统调用 open,会返回一个文件描述符 (简称 FD,File Decsriptor)。我们可以把 FD 理解为文件的指针,这个指针会指向一个Inode 。多个 FD 可以指向同一个 Inode,FD 会维护一个对文件内容操作的偏移量(读写到什么地方了)。FD 是上层应用程序使用的,Inode 是内核维护使用的。

    但是进程打开的 FD 是有限制的,所以我们需要关闭流(实际上就是释放申请的计算机资源),不然 FD 不释放,程序发起系统调用没有 FD可用就会报错。

    ulimit -n 可以查看系统限制的进程打开 FD 的数量,当程序并发很高的时候,需要调大此值,不然会报 (Too many open files)

    public class ErrorOpenFile {
        public static void main(String[] args) throws IOException, InterruptedException {
            final Path path = Paths.get("/root/testfileio/out.txt");
            int count = 0;
            while (true) {
                // 为了查看 FD 的增长,所以设置阻塞五秒
                Thread.sleep(5000);
                count++;
                Files.newBufferedReader(path);
                System.out.println("打开一个文件描述符");
            }
        }
    }
    

    /proc/pid/fd 下可以看到一个进程打开的 FD,其中的 0、1、2 是默认输入(System.in),输出(System.out),错误输出(System.err),每个程序都会有。

    image-20200705004254331

    为什么 BufferedInputStreamFileInputStream 快?

    下面的程序,FileOutputStreamBufferedOutputStream 循环 10000 次,写入相同大小的数据,FileOutputStream 用时 468 毫秒。BufferedOutputStream 用时 3 毫秒。

    public class IoOperation {
        static byte[] data = "1234567890\n".getBytes();
        static String path = "/root/testfileio/out.txt";
        static int count = 0;
        public static void main(String[] args) throws Exception {
            switch (args[0]) {
                case "0":
                    testBasicFileIO();
                    break;
                case "1":
                    testBufferedFileIO();
                    break;
                default:
    
            }
        }
        // 468 毫秒执行完 
        public static void testBasicFileIO() throws Exception {
            File file = new File(path);
            FileOutputStream out = new FileOutputStream(file);
            final long start = System.currentTimeMillis();
            while (count < 10000) {
                out.write(data);
                count++;
            }
            System.out.println(System.currentTimeMillis() - start);
            out.close();
        }
        // 3 毫秒执行完 
        public static void testBufferedFileIO() throws Exception {
            File file = new File(path);
            BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file));
            final long start = System.currentTimeMillis();
            while (count < 10000) {
                out.write(data);
                count++;
            }
            System.out.println(System.currentTimeMillis() - start);
            out.close();
        }
    }
    

    VFS 抽象出来的系统调用(open,read,write,close)是让应用程序调用的。我们可以在 Linux 中使用 man open(read/write/close) 查看系统调用的意思

    也可以在 Linux 手册 https://man7.org/linux/man-pages/dir_section_2.html 看系统调用。

    ssize_t write(int fd, const void *buf, size_t count);
    

    write 系统调用,是把缓存区 buf 中的前 count 个字节写入到 fd 中,返回的是实际写入到文件中的字节数 ssize_t,ssize_t 可能小于 count。

    write 系统调用 会触发进程从用户态切换到内核态,Cpu 需要保存进程用户态的上下文(代码执行到哪里了,相关数据等),再执行内核代码,执行完内核代码,还要切换回用户态,将进程的上下文再还原,相对来说进程态的切换是比较消耗 Cpu 资源的,我们应该减少 Cpu 资源的切换。

    # 执行上面代码,并追踪系统调用
    strace -ff -o /root/testfileio/out java com.fly.io.IoOperation $1
    
    image-20200705122935804

    FileInputStream 会调用 10000 次系统调用,进程用户态到内核态切换了 10000 次,所以代码执行时间比较长。

    BufferedOutputStream 有一个 8192 字节的缓冲区,当调用 BufferedOutputStream.write 会先写入这个缓冲区,在这个缓冲区满的时候,会将这个缓冲区的数据发起系统调用,这样减少了系统调用,所以用时比较少。

    Page Cache 和 Dirty Page

    文件数据的持久化,也被称为 落盘内存 的速度是 硬盘 N 倍,他俩不是一个量级的。 所以 Linux 引入 Page Cache 来作为数据的缓存,当 Page Cache 被修改之后变为了 Dirty Page,Linux 会在适当时机(可以通过参数调节),将脏页的数据,刷新到硬盘中。也可以调用系统调用(fsync),将脏页刷新到硬盘。

    JAVA 程序 调用 FileOutputStream.write 的时候,实际是将用户态的数据,写入到了内核态中的 Page Cache (一个 Page Cache 大小为 4KB 左右),当我们调用 FileOutputStream.close 的时候,实际只是调用了系统调用 close,而没有落盘,这时对计算机断电,数据是没有持久化的。

    当我们调用了 FileOutputStream.getFD().sync() 会触发系统调用 fsync,将数据落盘。

    image-20200704201130013

    Linux 内核进行 Io 调度,来控制数据落盘,时机是:

    1. 当空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
    2. 当脏页在内存中驻留时间超过一个特定的阈值时,内核必须将超时的脏页写回磁盘吧
    3. 用户进程调用sync(2)fsync(2)fdatasync(2)系统调用时,内核会执行相应的写回操作。

    一下是内核参数配置,进行控制内核的调度

    sysctl -a | grep dirty 可以查看当前系统生效的配置

    #若脏页占总物理内存10%以上,则触发flush把脏数据写回磁盘。内核后台线程写。
    vm.dirty_background_ratio = 10
    vm.dirty_background_bytes = 1048576
    # 向内存写 pagecage 时,内核判断当前脏页占用物理内存的百分比,当超过这个值, 内核会阻塞掉写操作,并开始刷新脏页
    vm.dirty_ratio = 10
    vm.dirty_bytes = 1048576
    # flush每隔5秒执行一次
    vm.dirty_writeback_centisecs = 5000
    #内存中驻留30秒以上的脏数据将由flush在下一次执行时写入磁盘
    vm.dirty_expire_centisecs = 30000
    

    代码验证 FileOutputStream.close 不会引起数据的落盘。为避免 Linux Io 调度的影响,我修改了内核的配置参数,这样数据只要没有调用系统调用 fsync 就不会触发系统调用。

    # 编辑配置文件,将参数配置填入文件中
    vim /etc/sysctl.conf
    
    # 使配置生效
    sysctl -p
    
    vm.dirty_background_ratio = 90
    vm.dirty_ratio = 90
    vm.dirty_expire_centisecs = 300000
    vm.dirty_writeback_centisecs = 50000
    

    代码的逻辑为:往一个文件中写数据,然后关闭流,但是阻塞程序停止,程序停止,数据会刷新到磁盘中,然后模拟断电关闭虚拟机。

    当打印 没有落盘的时候cat /root/testfileio/out.txt 是可以看到数据的,当我断电重启之后,数据就没有了。说明 close 不能触发数据的落盘。

    public class IoOperation1 {
        static byte[] data = "1234567890\n".getBytes();
        static String path = "/root/testfileio/out.txt";
        static int count = 0;
    
        public static void main(String[] args) throws Exception {
            File file = new File(path);
            final FileOutputStream out = new FileOutputStream(file);
            while (count < 10) {
                out.write(data);
                count++;
            }
            out.close();
            System.out.println("没有落盘");
            Thread.sleep(1000000);
        }
    }
    

    当我们调用系统调用进行落盘的时候,断电重启虚拟机,发现 out.txt 是有数据的。

    public class IoOperation1 {
        static byte[] data = "1234567890\n".getBytes();
        static String path = "/root/testfileio/out.txt";
        static int count = 0;
    
        public static void main(String[] args) throws Exception {
            File file = new File(path);
            final FileOutputStream out = new FileOutputStream(file);
            while (count < 10) {
                out.write(data);
                count++;
            }
            // 发起了系统调用 fsync,进行数据的落盘
            out.getFD().sync();
            out.close();
            System.out.println("落盘");
            Thread.sleep(1000000);
        }
    }
    

    本文由 张攀钦的博客 http://www.mflyyou.cn/ 创作。 可自由转载、引用,但需署名作者且注明文章出处。

    如转载至微信公众号,请在文末添加作者公众号二维码。微信公众号名称:Mflyyou

    相关文章

      网友评论

          本文标题:从Linux内核理解Java中的IO

          本文链接:https://www.haomeiwen.com/subject/nmbvqktx.html