关于 磁盘 I/O 的工作机制那些事

总有一些你我看不见的东西,存在与你我周围

《深入分析 javaW 技术内幕》 读书感悟

 作者 :淮左白衣

 写于2018年4月11日19:35:06


写在前面的话

本篇博客,是笔者的读书笔记,是笔者自己的一些见解+一些百度的知识点总结出来的,如果有错误的地方,请指正 ;

博客中只讨论 java I/O的工作机制 ,不讲解其中涉及到的一些 java的IO知识,如果,你看的过程中,对其中的java的一些 API 的知识点,还存在疑问,请关掉本博客,等以后再来读,现在它还不适合你 ;

— — 淮左白衣


字节与字符的转换桥梁

大家都知道在java中,有 一组类 可以完成字节与字符的转换: OutputStreamWriterInputStreamReader ;其实,他们两只是 表面选手,它们完成的工作都是 StreamDecoder 类完成的 ;

下面笔者自己来分析下源码,加深印象

我们先分析下 InputStreamReader 类的默认码表构造器,先看下源码:

  /**
     * Creates an InputStreamReader that uses the default charset.
     *
     * @param  in   An InputStream
     */
    public InputStreamReader(InputStream in) {
        super(in);
        try {
         // 这行很重要,我们跟进去看下
            sd = StreamDecoder.forInputStreamReader(in, this, (String)null);
        } catch (UnsupportedEncodingException e) {
            // The default encoding should always be available
            throw new Error(e);
        }
    }

我们发现,它创建构造器的时候,调用了 StreamDecoder 类的 forInputStreamReader 方法,除此之外,这构造器源码并没有什么有价值的信息 ;因此,我们需要跟进去看下 StreamDecoder 类的 forInputStreamReader 方法 ;

下面是 StreamDecoder.forInputStreamReader(in, this, (String)null); 的源码 ;

// 首先是三个参数,根据上面的源代码,我们可以看到传进来的参数具体是什么 ;
// 第一个参数是 我们new InputStreamReader 的时候,写的 输入流参数对象
// 第二个参数是 ,传进来的是this,代表着inputStream对象本身
// 第三个参数是 ,代表选用码表,默认码表的情况下,默认传 null 进来
public static StreamDecoder forInputStreamReader(InputStream var0, Object var1, String var2) throws UnsupportedEncodingException {
        String var3 = var2;
        // 如果没有传码表进来,则取默认的码表
        if (var2 == null) {
            var3 = Charset.defaultCharset().name();
        }

        try {
        // 判断传进来的码表,是否合法
            if (Charset.isSupported(var3)) {
           // 码表合法的话,就根据传进来的参数,创建一个 StreamDecoder对象
           //传过去的参数,分别是 输入流参数对象,inputStreamReader对象,码表
                return new StreamDecoder(var0, var1, Charset.forName(var3));
            }

        } catch (IllegalCharsetNameException var5) {
            ;
        }
 // 不合法的话,则抛出一个 不支持的码表 异常,
        throw new UnsupportedEncodingException(var3);
    }

我们接着跟进去,看到 StreamDecoder(InputStream var1, Object var2, Charset var3) 发现是this调用自身类的另外一个构造器了,this(var1,var2,var3.newDecoder().onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE));

 StreamDecoder(InputStream var1, Object var2, Charset var3) {
 // 将第三个参数由 CharSet 变为 CharsetDecoder 了,由码表变为解码器了
        this(var1, var2, var3.newDecoder().onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE));
    }

下面是 this(var1,var2,var3.newDecoder().onMalformedInput(CodingErrorAction.REPLACE).onUnmappableCharacter(CodingErrorAction.REPLACE)); 的源码,进行初始化 streamDecoder 对象的初始化 ;

//  三个参数,在上面代码的注释里,已经讲过都是什么了,不在累赘
StreamDecoder(InputStream var1, Object var2, CharsetDecoder var3) {
        super(var2);
        this.isOpen = true;
        this.haveLeftoverChar = false;
        this.cs = var3.charset();
        this.decoder = var3;
        if (this.ch == null) {
            this.in = var1;
            this.ch = null;
            this.bb = ByteBuffer.allocate(8192);
        }

        this.bb.flip();
    }

从上面的源代码一路分析下来,我们已经对 InputStreamReader 类的工作很清楚了,它主要是通过 streamDecoder 对象完成工作的 ;它读的还是字节流,只是在读的时候,进行解码了;至于它为什么能进行解码,是因为创建 streamDecoder 对象的时候,根据我们传进去的码表名称,底层加载了 对应码表的解码器,对字节进行了解码成字符 ;

OutputStreamWriter 也是一样的道理,内部封装了 streamEncoder 类,由这个复杂读取、编码 工作 ,跟 InputStreamReader 大同小异;

上面对源代码的分析,只是创建对象,到初始化的分析;read()方法的源码,并没有去分析,read()方法的源码,涉及的东西很多,展开讲太多了,我写这篇博客,初衷只是记录下,我读书的感悟,分析上面的代码,就已经有点扯远了。。。


用户空间 VS 内核空间

在讲解几种工作机制之前,有必要先了解下两个名词:内核空间、用户空间

  • 内核空间
    操作系统将系统运行内存分为了两部分,一部分是内核空间 ,内核空间运行着系统级的程序,一般都是一些维持系统正常运作的软件 ;
    运行在这里的程序,拥有对硬件设备绝对的访问权

  • 用户空间
    另一部分空间就是 用户空间了;我们平时打开QQ音乐、网易云这些软件的时候,这些软件就运行在用户空间 ;
    运行在这的程序,不可以直接访问硬件设备,只能通过请求系统接口,由系统来访问硬件设备 ;

内核空间和用户空间 是隔离的;为了安全起见,用户空间是不可以直接访问内核空间的;

举个例子网易云音乐想要播放存储在硬盘上的音乐

网易云音乐想要播放存储在硬盘上的音乐,音乐存储在硬盘上,网易云音乐是没有权限 直接去读取 硬盘的文件的,这时候,网易云就需要向系统发出请求,请求调用read()接口由系统读取硬盘上的音乐文件,到内核空间,再从内核空间 复制 到用户空间,再给网易云音乐用 ;

我们需要一个缓存机制

对于上面的操作,其实很浪费时间、资源,每次都要先将硬盘资源,读取到内核空间里面,再从内核空间复制到用户空间 ;计算机科学家,就在内核空间里面,设置了一个缓存机制,为每一个打开的文件,都缓存一份数据,这样下次,再次访问同样的数据就直接返回内核空间里面的缓存给用户空间,而不再去硬盘读取,省去了一步;

有个大概了解下,就可以了,对这部分知识,再讲下去,就是操作系统的知识了 ;


磁盘 I/O 的工作机制

操作系统一般都有下面这五种访问磁盘的方式,分别体现在五种不同的read()write()实现上;

具体选用何种方式,主要看应用程序自己选择哪一种方法实现;

  1. 标准访问文件的方式(带缓存的IO)

    最朴素的访问文件的方式了,是系统默认的实现方式老规矩,要想读写物理设备,必须调用系统提供的接口 read()write()

    先检查内核空间里面的缓存区,有没有需要的数据;

    如果没有 就先读取硬盘里面的数据,然后缓存到内核空间里面的缓存区,再复制到用户空间 ;
    如果有 我们需要的数据,则直接拿缓存 ;

    写操作的时候,应用程序只需要把数据,写到内核空间里面的缓冲区,就好了,此时应用程序认为,写操作已经完成了,至于真正的物理写盘操作,有操作系统决定什么时候写延迟写机制)。

    这种延迟写,操作系统会定期将数据写到磁盘上;但是和异步写,还有一点不一样,延迟写,写完数据的时候,不会通知应用程序;异步写,在数据写完之后是会通知应用程序的;

    延迟写,具体写的时间是不确定,有可能出现数据丢失的风险;数据在页缓存里面,还没真实的写盘呢。电脑断电,关机了。。。

    但是我们也可以显示的调用 sync 同步命令,让操作系统即刻进行 写盘 操作 ;一般还是不调用的好,因为假如同步写盘的话,那么应用程序会一直等待写盘完成,这个期间,应用程序不能做任何事;

    优点:内核页缓存的存在,减少读盘次数 ;

    缺点:数据每次都必须先写到内核页缓存里面,在复制到用户空间;这样的数据拷贝动作,会占用很高的 cpu 和内存 ;

  2. 直接 I/O 的方式(不带缓存的IO)

    还是一样,,每次访问硬盘都需要请求系统接口但是不需要再将第一次从硬盘里面读取的数据,缓存到内核空间里面的缓存区 而是将数据直接送到用户空间

    你可能会问,这样没有缓存,效率不是又变慢了吗?

    其实效率没变慢,还变快了因为一般使用这样方式读取文件,数据缓存由应用程序自己管理比如:数据库管理系统这类程序,应用程序知道,哪些数据只用一次,哪些数据要经常用,哪些数据需要预加载,它会对应的做出一些措施,比如将需要预加载的数据,提前加载到内存里面,这样当用到的这些数据的时候,程序的响应速度很快相对于内核空间里面的缓存,系统是无法知道,应用程序需要的数据,哪些是需要预加载的,它只是呆呆的为每一份第一次用到的数据,做个缓存

    但是这样做也有一个弊端就是我们访问数据的时候,当数据在我们的应用缓存里面是没有的,只能去请求系统接口 read()磁盘 里面读,

    注意,由于我们选择了直接IO标准IO的优势,返回页缓存数据的优势:页缓存数据是在内存中,对这里面数据进行读写是非常快的; 一旦我们选择了直接IO,我们将不再拥有这样的优势!因为我们是直接从磁盘进行读写的,读写磁盘的速度和读写内存的速度,根本不在级别上,后者甩前者十万八千里; 因此,一旦我们需要的数据,不在我们的缓存机制里面,那么我们的应用程序就只能读盘操作了,而直接 I/O 的读数据操作会造成磁盘的同步读,导致进程需要较长的时间才能执行完,造成线程阻塞,期间我们的应用程序什么事都响应不了

    因此,我们一般会结合 异步IO 一起用,让 异步IO来完成读盘这个操作异步IO,相当于回调似的,你告诉我,做什么就好了,你不需要等着我,我做完会通知你,这样,我们在进行读取的时候,可以响应其他事件 ;

  3. 同步访问文件的方式

    和第一条的标准访问文件方式,差不多唯一的区别,在于,进行写操作的时候,必须当数据被写到磁盘上,才算完成写操作,与标准访问文件的写到内核缓存中,不一样 ;

    这样做,性能非常低下,因为我们要慢慢的等着写盘操作结束,期间应用程序啥事也干不了;

    一般用于对数据安全性要求比较高的场景,以确保数据被写到磁盘中;

  4. 异步访问文件的方式异步IO

    跟回调一个道理线程不会等待;线程发出读写请求以后,不会等待读写完成,,而是先挂起需要读写的事件,改去处理其他逻辑,等读写完成以后,再继续处理需要读写的事件 ;

    这样做,可以提高程序的响应效率,但是对读写文件,还是一样的慢,只是这个慢,在后台慢,前台看不见

  5. 内存映射的方式

    将内存地址与硬盘地址做个映射关系,使他们关联起来;这样操作改变内存数据的同时,就会改变硬盘中的数据;因为做了映射,它们两的数据是共享的

    因为映射关系,修改内存中的数据,硬盘中对应的数据,会得到修改;

    与标准的访问文件的方式相比,内存映射方式可以减少标准访问文件方式中 read() 系统调用所带来的数据拷贝操作,即减少数据在用户地址空间和操作系统内核地址空间之间的拷贝操作。


原文地址:https://www.cnblogs.com/young-youth/p/11665737.html