网络与IO知识扫盲(3)- 内核中PageCache、mmap作用、Java文件系统IO、NIO、内存中缓冲区作用

在这里插入图片描述
线性地址和物理地址的映射
程序在物理上是不连续的
程序在运行的时候有虚拟地址,是线性地址空间
映射依赖于CPU的MMU单元,以页(4KB)为单位
程序运行的时候,可以预分配一些空间,但不会做全量分配。如果程序跑着跑着,想要访问一个地址的时候发现没有,此时会产生缺页异常,一个软中断,CPU去把缺的页补上之后,从内核态回到用户态,才能继续运行。

程序跑起来的步骤:

  • 程序是硬盘上的一个文件,二进制的,包括代码段,假设整个程序运行起来需要用到 10*4kB 空间
  • 程序运行时,可能是先加载 1*4kB 进来,后面用到的时候再加载其他的 4kB(并不是一口气全部加载进来了)
  • 两个进程同时访问同 1 个文件时,共享的是这 1 个文件的唯一的pagecache,只不过是两个进程里面的fd各自维护了自己的不同的访问位置偏移量。这样是比较省内存的。在fork子进程的时候,只需要开辟一个线性的地址空间,然后将PCB创建出来,实际上fd中指向的还是同一个文件。

在这里插入图片描述
比如,bash 是一个程序
在这里插入图片描述
pagecache使用多大内存?是否淘汰?(一致性问题)是否延时/丢数据?

搞一些事情
在这里插入图片描述
查看默认的配置项
在这里插入图片描述
修改如下

vm.dirty_background_ratio = 90
(后台异步执行)当文件系统缓存脏页数量达到系统内存百分之多少时(如90%)就会触发pdflush/flush/kdmflush等后台回写进程运行,将一定缓存的脏页异步地刷入外存,由内核完成从内存到磁盘的写过程,才会真正写入硬盘

vm.dirty_ratio = 90
(前台执行)当文件系统缓存脏页数量达到系统内存百分之多少时(如90%),系统不得不开始处理缓存脏页(因为此时脏页数量已经比较多,为了避免数据丢失需要将一定脏页刷入外存);在此过程中很多应用进程可能会因为系统转而处理文件IO而阻塞

上面这两个达到阈值之后,都会使用LRU策略进行淘汰

vm.dirty_expire_centisecs = 30000 过期时间,单位:10ms
vm.dirty_writeback_centisecs = 5000 脏页写回的时间频率,单位:10ms

修改完之后用sysctl -p让它生效
写一个mysh小脚本,待会儿使用的时候给 $1 传参数

在这里插入图片描述
OSFileIO.java

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;

public class OSFileIO {

    static byte[] data = "123456789\n".getBytes();
    static String path =  "/root/testfileio/out.txt";


    public static void main(String[] args) throws Exception {
        switch ( args[0]) {
            case "0" :
                testBasicFileIO();
                break;
            case "1":
                testBufferedFileIO();
                break;
            case "2" :
                testRandomAccessFileWrite();
            case "3":
//                whatByteBuffer();
            default:
        }
    }


    //最基本的file写
    public static  void testBasicFileIO() throws Exception {
        File file = new File(path);
        FileOutputStream out = new FileOutputStream(file);
        while(true){
            Thread.sleep(10);
            out.write(data);
        }
    }

    //测试buffer文件IO
    //  jvm  8kB   syscall  write(8KBbyte[])
    public static void testBufferedFileIO() throws Exception {
        File file = new File(path);
        BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file));
        while(true){
            Thread.sleep(10);
            out.write(data);
        }
    }
// ...
}

第一次测试
执行刚才的shell脚本mysh,传入参数0
在这里插入图片描述
只要内存够,cache就一直给你放到缓存

注:下面这个pcstat命令需要一个二进制文件pcstat,把它下载下来放进环境变量自带的 /bin 目录下就可以了

在这里插入图片描述

可以查看到这个文件的大小是多大(Size),实际总页数是多少(Pages),被cached到内核中的pagecache有多少页(Cached),缓存的百分比是多少(Percent)

此时强制关机拔电源
在这里插入图片描述
重启之后,刚才在缓存中的数据丢失了,因为并没有来得及刷写到磁盘中。
在这里插入图片描述
1、BufferedIO 和普通的IO谁快?BufferedIO
2、BufferedIO 为什么快?BufferedIO 在 JVM 的进程中默认暂存一个 8kB 的数组,8kB满了之后,调用内核中的 syscall write,把这个数组写进去。BufferedIO 与普通的IO在内核中切换的次数不一样。

第二次测试
执行刚才的shell脚本mysh,这次传入的参数是1
看文件增长的速度可以发现,BufferedIO 明显比第一次测试使用的普通 IO 文件增长的速度快,这也是为什么我们在使用 Java 进行文件IO的时候,一定要用 BufferedIO。

在这里插入图片描述
超过90%内存的阈值后,一部分数据已经被持久化了
在这里插入图片描述
将原有的 out.txt 改名为 ooxx.txt(修改文件名只是修改了元数据,不会影响缓冲区中的内容)
再重新跑起 mysh 脚本,我们发现,根据LRU策略,新创建的 out.txt 将原有 ooxx.txt 在缓冲区的数据淘汰掉了

在这里插入图片描述
第三次测试
我们的内存总共有3个多G
在 out.txt 大于 900M 的时候关电源
在这里插入图片描述
再开机之后,out.txt 都丢了

这么看来,如果你把redis或mysql的持久化规则设置为跟随操作系统的话,当你突然断电的时候,你会丢失很多数据。

我们的一些结论
什么是脏数据?
可以理解为缓存中的数据与磁盘中的数据不一致的时候,这个数据就是脏的

  • 一个页刚被create的时候,是脏的
  • 这个页被写到磁盘中后,就不是脏数据了
  • 又修改了这个页中的内容,又回到了脏的状态
    在这里插入图片描述
    硬件、内核、进程都有缓存,这三个缓存都有可能丢数据。

pagecache本来是用来优化IO的性能(优先走内存),但它的缺点是刷写硬盘不及时,在突然关机或异常断电时,有丢失数据的可能

为什么Java程序员不要使用直接IO,而要使用Buffered形式的IO?

使用直接IO
这里截取的是OSFileIO.java的一部分

    //普通IO:最基本的file写
    public static  void testBasicFileIO() throws Exception {
        File file = new File(path);
        FileOutputStream out = new FileOutputStream(file);
        while(true){
            Thread.sleep(10);
            out.write(data);
        }
    }

查看strace产生的out文件,追踪的是应用程序到内核的系统调用。每一行都是用户态到内核态切换的过程。
所以为什么说直接IO比而要使用BufferedIO速度更慢,就是因为每次只写一个123456789,进行了过多的从用户态到内核态的切换。

在这里插入图片描述
使用BufferedOutputStream
这里截取的是OSFileIO.java的一部分

    //测试buffer文件IO
    //  jvm  8kB   syscall  write(8KBbyte[])
    public static void testBufferedFileIO() throws Exception {
        File file = new File(path);
        BufferedOutputStream out = new BufferedOutputStream(new FileOutputStream(file));
        while(true){
            Thread.sleep(10);
            out.write(data);
        }
    }

通过追踪可以看出,一次写一个缓冲区大小,减少了调用write的次数,只不过是每一次写入的数据量比较大。减少了用户态到内核态的来回切换。
因此我们说,BufferedOutputStream它之所以快,根本原因并不是因为使用了缓冲,而是因为使用Buffer让数据能够批量写入,减少系统调用带来的内核切换导致的性能损耗
在这里插入图片描述
测试 NIO
在jdk新版,除了传统IO,又出现了新的IO,也就是NIO,也暴露了新的API,ByteBuffer

ByteBuffer
position:偏移量,默认指向0
flip: 让position归零,limit移到前面
limit:读状态时指向能读的最大位置;写状态时指向能写的最大位置
cap:代表文件总大小

在这里插入图片描述

    @Test
    public void whatByteBuffer() {

//        ByteBuffer buffer = ByteBuffer.allocate(1024);  // 这种方式 内存开销是在JVM中的
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);  // 堆外内存 开销在JVM之外,以就是系统级的内存分配


        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());//put写入123后,pos偏移量前移3个



        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);

    }

输出:

postition: 0
limit: 1024
capacity: 1024
mark: java.nio.DirectByteBuffer[pos=0 lim=1024 cap=1024]
-------------put:123......
mark: java.nio.DirectByteBuffer[pos=3 lim=1024 cap=1024]
-------------flip......
mark: java.nio.DirectByteBuffer[pos=0 lim=3 cap=1024]
-------------get......
mark: java.nio.DirectByteBuffer[pos=1 lim=3 cap=1024]
-------------compact......
mark: java.nio.DirectByteBuffer[pos=2 lim=1024 cap=1024]
-------------clear......
mark: java.nio.DirectByteBuffer[pos=0 lim=1024 cap=1024]

Process finished with exit code 0

put操作
进行buffer.put(“123”.getBytes());之后,pos向右移动了三个字节的位置。

flip操作
flip操作,将偏移量置为0,限制指针置为3,这是为了从写模式改为读模式,防止读的超过了可用的总大小,因此将limit限制为当前可读的最大值。
limit变成pos,pos归0
get操作:
读取1个字节操作,会移动pos偏移量1
在这里插入图片描述

compact操作:
pos 指向未读
limit 指向最后

clear操作:
一切重置成最开始的状态

测试NIO RandomAccessFile随机读写

    //测试文件NIO RandomAccessFile随机读写
    @Test
    public void testRandomAccessFileWrite() throws Exception {
        RandomAccessFile raf = new RandomAccessFile(path, "rw");

        raf.write("hello mashibing\n".getBytes());  // 普通的写入
        raf.write("hello seanzhou\n".getBytes());
        System.out.println("write------------");
        System.in.read();  // 在这里阻塞的时候,去看文件是可以看到内容的,但是还没有刷写到磁盘上,只是在pagecache中

        raf.seek(4);  // RandomAccessFile的随机读写能力:可以修改指针的偏移为第4个字节
        raf.write("ooxx".getBytes());  // hello mashibing -> helooxxashibing
        System.out.println("seek---------");
        System.in.read();

        FileChannel rafchannel = raf.getChannel();
        //用mmap得到堆外的且和文件映射的ByteBuffer   是byte,not object(没有对象的概念)
        //文件被称为块设备。只有文件可以做内存映射,只有文件的Channel才会有mmap,其它流是没有的
        MappedByteBuffer map = rafchannel.map(FileChannel.MapMode.READ_WRITE, 0, 4096);

        map.put("@@@".getBytes());  //通过FileChannel得到的MappedByteBuffer调用put不是系统调用,但是数据会到达内核的pagecache,因为和内核进行了映射
        // 曾经我们是需要out.write()这样的系统调用,才能让程序的 data 进入内核的 pagecache。换言之,曾经必须有用户态内核态切换
        // 但是现在,如果有了MappedByteBuffer,就有了mmap的内存映射,数据可以直接到pagecache中。但是mmap的内存映射依然是内核的pagecache体系所约束的!换言之,还是会丢数据
        // 目前Java还没有能力去让你逃离pagecache。你可以去github上找一些 其他C程序员写的JNI扩展库,使用linux内核的Direct IO,
        // 直接IO是忽略linux的pagecache的。是把pagecache交给了程序自己开辟一个字节数组当作pagecache,动用代码逻辑来维护一致性、脏等等一系列复杂问题。
        // 相当于自己去维护pagecache,相比于直接去修改内核的pagecache配置来说,粒度更细一些。
        // 比如数据库一般会使用Direct IO。

        System.out.println("map--put--------");
        System.in.read();

//        map.force(); //  强制刷写flush

		// 这后面自己随便分配一个seek buffer,主要是为了演示ByteBuffer怎么使用的。
        raf.seek(0);

        ByteBuffer buffer = ByteBuffer.allocate(8192);  // 使用普通的 ByteBuffer,堆上的
//        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);  // 堆外的

        int read = rafchannel.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)));
        }
    }

执行到map.put("@@@".getBytes());时,文件大小4096
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
可以看到txt类型的,就是我们的java代码段。

在这里插入图片描述

  • 多了一个mem内存映射,4086大小
  • 另外仍然有一个文件描述符4
    你可以继续使用文件描述符4,或者用新开的内存映射,进行文件的读写。

有2个文件描述符,第一个是基于mmap的生成的文件描述符,第二个是传统IO的需要通过write,read操作的文件描述符。

总结一下
Java是用C开发的一个程序,它的级别是Linux下的进程。
进程会被分配一个堆,里面包含进程该有的一切。
txt文件会申请JVM分配一个heap。
如果你是用ByteBuffer的allocate这种方式,是将字节数组分配到了堆上,是JVM堆内的线性地址空间。
如果你是用allocateDirect这种方式,会将字节数组分配到JVM的堆外内存中,是C语言可以直接访问的。

mmap将用户的线性地址空间直接映射到了内核的pagecache地址,如果是脏的需要写的话,依然受pagecache的影响,才能最终刷写到磁盘中去。

在这里插入图片描述
在linux中,每个进程都有一份完整独立的text区,data区,堆区,栈区,mmap映射区。
而java其实本质就是一个普通的linux进程,所以自然也有上述东西。

堆内
在linux级别的堆上分配了一块固定大小的专属于JVM使用的堆,这个就叫做堆内空间。 堆内如果想要写IO到磁盘,需要先复制一份到堆外内存。

堆外
在JAVA进程中的堆内存里,除了被JVM管辖固定大小的那块JVM堆外,剩下的堆内存,都叫堆外内存。

而上述两者不管如何,都还是基于用户态程序内存空间中的,想要实现IO调用,依然需要走系统调用,也就是内核态切换!

MMAP内存映射
在一个linux进程中,与堆区这些同等维度存在的,还有一个mmap映射区,通过它调用的IO操作,可以直接一一对应映射到内核中的PageCache中(虽然堆内堆外本质也是会最终缓存在pagecache,但映射关系不是一一对应的),这个过程不存在系统调用,而pagecache再通过与磁盘文件本身的映射关系,进行写入,这个时候的IO操作就是像是直接通过用户态来完成了以往的内核态敏感操作。 本质是内核和用户态的线性地址和逻辑地址映射到同一物理地址

最终,说到底,还是受内核pagecache的约束,也就不难得出结论:没有完全可靠的IO操作,因为pagecache本身的刷写机制本身就存在丢失的风险性。

操作系统没有绝对的数据可靠性。它什么要设计pagecache,是为了减少硬件的IO的调用,想要优先使用内存,这样能够提速。如果追求性能,就要在一致性、可靠性之间做出权衡。

从大方面来看,在现在的分布式系统当中,如果你追求数据存储的可靠性(保持缓存和磁盘的强一致,对于每一次对数据的微小改变,都要去刷写磁盘),仍然避免不了单点故障的问题。单点故障会让你为了保持强一致而耗费的能损耗一毛钱收益都没有。

这就是为什么我们使用主从复制、主备HA
这就是为什么Kafka,ES都有副本的概念,而副本是从socket得到的。副本又分为同步的异步的区别,这些都是后话了,我们以后再讲…

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值