基础知识

一、Java IO 框架图

Java IO 流共涉及 40 多个类,这些类看上去很杂乱,但实际上很有规则,而且彼此之间存在非常紧密的联系,Java IO 流的 40 多个类都是从以下 4 个抽象类基类中派生出来的。

  • InputStream / Reader:所有的输入流的基类,前者是字节输入流,后者是字符输入流。
  • OutputStream / Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

f7fa4b7b98043c0bbeffc375164ca7f4.jpeg

二、IO 流分为几种?

  • 按照流的流向可分为输入流和输出流。
  • 按照操作单元可以划分为字节流和字符流。
  • 按照流的角色可分为节点流和处理流。

    三、为什么 I/O 流操作要分为字节流和字符流?

    以字节为单位输入输出数据,字节流按照8位传输,字节是给计算机看的。 以字符为单位输入输出数据,字符流按照16位传输。

字符流是由 JVM 将字节转换得到的,字符流转字节流是一个耗时操作,并且,如果我们不知道编码类型就很容易出现乱码问题。那么为了解决以上的问题,I/O 流提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。操作音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。

四、IO 中的一些概念

在了解 IO 模型之前我们有必要先了解一些概念。

4.1、同步与异步

需要了解

同步:如果有多个任务要发生,这些任务必须逐个地进行,一个任务的执行会导致整个流程的暂时等待,这些任务是不能同时执行的。
异步:如果有多个任务或者事件发生,这些事件可以并发地执行,一个事件或者任务的执行不会导致整个流程的暂时等待。
例子:假如有一个任务包括两个子任务A和B,对于同步来说,当A在执行的过程中,B只有等待,直至A执行完毕,B才能执行;而对于异步就是A和B可以并发地执行,B不必等待A执行完毕之后再执行,这样就不会由于A的执行导致整个任务的暂时等待。

4.2、阻塞与非阻塞

阻塞与非阻塞

阻塞:当某个事件或者任务在执行过程中,它发出一个请求操作,但是由于该请求操作需要的条件不满足,那么就会一直在那等待,直至条件满足。
非阻塞:当某个事件或者任务在执行过程中,它发出一个请求操作,如果该请求操作需要的条件不满足,会立即返回一个标志信息告知条件不满足,不会一直在那等待。
例子:假如我要读取一个文件中的内容,如果此时文件中没有内容可读,对于阻塞来说就是会一直在那等待,直至文件中有内容可读;而对于非阻塞来说,就会直接返回一个标志信息告知文件中暂时无内容可读。

4.3、同步与异步和阻塞与非阻塞的区别

同步与异步和阻塞与非阻塞的区别

同步和异步着重点在于多个任务的执行过程中,一个任务的执行是否会导致整个流程的暂时等待。而阻塞和非阻塞着重点在于发出一个请求操作时,如果进行操作的条件不满足是否会返会一个标志信息告知条件不满足。理解阻塞和非阻塞可以同线程阻塞类比地理解,当一个线程进行一个请求操作时,如果条件不满足,则会被阻塞,即在那等待条件满足。

4.4、阻塞IO与非阻塞IO

阻塞 IO 与非阻塞 IO

在了解阻塞 IO 和非阻塞 IO 之前,先看下一个具体的 IO 操作过程是怎么进行的。通常来说,IO操作包括:对硬盘的读写、对 Socket 的读写以及对外设的读写。以读请求操作为例,当用户线程发起一个IO请求操作,内核会去查看要读取的数据是否就绪,对于阻塞IO来说,如果数据没有就绪,则会一直在那等待,直到数据就绪;对于非阻塞IO来说,如果数据没有就绪,则会返回一个标志信息告知用户线程当前要读的数据没有就绪。当数据就绪之后,便将数据拷贝到用户线程,这样才完成了一个完整的IO读请求操作。

通过上文内容我们了解到一个完整的IO读请求操作包括两个阶段:

  1. 查看数据是否就绪;
  2. 进行数据拷贝(内核将数据拷贝到用户线程)

阻塞 IO 和非阻塞 IO 的区别就在于第一个阶段,如果数据没有就绪,在查看数据是否就绪阶段是一直等待,还是直接返回一个标志信息。Java中传统的 IO 都是阻塞IO,比如通过 Socket 来读数据,调用read()方法之后,如果数据没有就绪,当前线程就会一直阻塞在 read 方法调用那里,直到有数据才返回;而如果是非阻塞IO的话,当数据没有就绪,read()方法应该返回一个标志信息,告知当前线程数据没有就绪,而不是一直在那里等待。

4.5、同步IO与异步IO

同步 IO 与 异步 IO

同步 IO:如果一个线程请求进行IO操作,在IO操作完成之前,该线程会被阻塞。
异步 IO:如果一个线程请求进行IO操作,IO操作不会导致请求线程被阻塞。

同步IO和异步IO模型是针对用户线程和内核的,详情如下:
同步IO:当用户发出IO请求操作之后,如果数据没有就绪,需要通过用户线程或者内核不断地去轮询数据是否就绪,当数据就绪时,再将数据从内核拷贝到用户线程。
异步IO:只有IO请求操作的发出是由用户线程来进行的,IO操作的两个阶段都是由内核自动完成,然后发送通知告知用户线程IO操作已经完成。也就是说在异步IO中,不会对用户线程产生任何阻塞。

同步IO和异步IO的关键区别反映在数据拷贝阶段是由用户线程完成还是内核完成。所以说异步IO必须要有操作系统的底层支持。

同步IO和异步IO与阻塞IO和非阻塞IO是两组不同的概念。阻塞IO和非阻塞IO是反映在当用户请求IO操作时,如果数据没有就绪,是用户线程一直等待数据就绪,还是会收到一个标志信息。也就是说,阻塞IO和非阻塞IO是反映在IO操作的第一个阶段,在查看数据是否就绪时是如何处理的。

五、Java IO 模型

五种 IO 模型

5.1、阻塞 IO 模型

最传统的一种IO模型,在读写数据过程中会发生阻塞现象。

当用户线程发出 IO 请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,用户线程会处于阻塞状态,用户线程交出 CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除 block 状态。

5.2、非阻塞 IO 模型

当用户线程发起一个IO操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送IO操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。

在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。

对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
src=http___upload-images.jianshu.io_upload_images_3796264-dacaabbcfe7ac39a.jpg&refer=http___upload-images.jianshu.jpg

5.3、多路 IO 复用

多路复用IO模型是目前使用得比较多的模型。

Java NIO实际上就是多路复用IO,在多路复用IO模型中,会有一个线程不断去轮询多个socket的状态,只有当socket 真正有读写事件时,才真正调用实际的 IO 读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。

在Java NIO中,是通过selector.select()去查询每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式会导致用户线程的阻塞。

也许有朋友会说,我可以采用多线程+ 阻塞IO 达到类似的效果,但是由于在多线程 + 阻塞IO 中,每个socket对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。  

而多路复用IO模式,通过一个线程就可以管理多个 socket,只有当 socket 真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用 IO 比较适合连接数比较多的情况。

另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问socket状态时是通过用户线程去进行的,而在多路复用IO中,轮询每个socket状态是内核在进行的,这个效率要比用户线程要高的多。  

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

不过要注意的是,多路复用IO模型是通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。

5.4、信号驱动 IO 模型

信号驱动 IO 模型

在信号驱动IO模型中,当用户线程发起一个 IO 请求操作,会给对应的 socket 注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用 IO读写操作来进行实际的IO请求操作。

5.5、异步IO模型

异步 IO 模型是最理想的IO模型

在异步IO模型中,当用户线程发起 read 操作之后,立刻就可以开始去做其它的事。而另一方面,从内核的角度,当它收到一个 asynchronous read 之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何 block。然后,内核会等待数据准备完成,然后将数据拷贝到用户线程,当这一切都完成之后,内核会给用户线程发送一个信号,告诉它read操作完成了。用户线程完全不需要参与实整个IO操作,只需要先发起一个请求,当接收内核返回的成功信号时表示 IO 操作已经完成,可以直接去使用数据了。

在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步 IO 模型中,收到信号表示 IO 操作已经完成,不需要再在用户线程中调用 IO 函数进行实际的读写操作。

注意,异步IO是需要操作系统的底层支持,在Java 7中,提供了Asynchronous IO。
前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段都会引起用户线程阻塞,也就是内核进行数据拷贝的过程都会让用户线程阻塞。

待整理:https://zhuanlan.zhihu.com/p/344806011

参考

  1. Java基础知识 @JavaGuide
  2. Java NIO:浅析I/O模型 @ Matrix海子