Zohar's blog

Java - 基础:IO 与 NIO

Javajavaionio

所有程序都离不开输入输出,计算机就是为了响应输入,处理数据,输出结果的,因此 IO 对于一门编程语言而言是至关重要的。

IO 模型

IO 操作,即输入输出,指的是将数据由程序进程的内存区域输出到其他设备或者由其他设备读取数据输入到进程的内存区域。

阻塞 IO 模型

最传统的 IO 模型,即在读写数据时会发生阻塞,这个阻塞发生在两个阶段,一个是等待设备可操作,一个是进行操作并等待操作完成。例如在向 Socket 输出数据时,程序会等待网卡直到网卡可写,在网卡可写时将数据从内存写入网卡缓冲区。或者是读取数据时,程序需要等待网卡接收缓存中数据到达,在数据到达时将数据读取至操作内存。

这就像我们在外卖平台上下单之后,就跑到小区门口去等外卖到。外卖到了我们再拿回家。这个过程中我们什么都没做,浪费了很多时间。

基于这种模型的程序通常会为每个 Socket 建立一条线程,因此进行 IO 系统调用会导致整个线程阻塞,如果线程中有多条 Socket 需要处理,则全部都将阻塞。

Java IO 包所采用的模型就是阻塞 IO 模型,即 Blocking IO,因此 Java IO 包被称为 BIO。

非阻塞 IO 模型

当程序进行 IO 操作时,并不需要等待,直接就会获取一个结果。如果结果是 error,就表示设备还不可用。因此我们可以通过轮询的方式进行 IO 操作。设备准备好了,我们就会获取到 success 的返回值,此时我们再将数据输入或输出到操作内存。因此这个过程中等待设备可用的这个阶段是非阻塞的,我们可以有间隔的轮询,在间隔期间我们可以进行其他操作。

这种模式依赖于内核提供的包装器来进行,类似于 select 系统调用,可以用来判断进行某个系统调用是否会发生阻塞。如今基本所有操作系统都支持包装器。

这就像我们在外卖下单之后,每隔几分钟我们就看一眼外卖软件看外卖到了没,当显示到了的时候我们再去小区门口取外卖。这个过程我们等外卖是否到了这一步我们可以去做其他事情。

多路复用 IO 模型

在多路复用 IO 模型中,会有一个线程专门不断去轮询多个 Socket 的状态,只有当 Socket 真正有读写事件时,才真正调用 IO 读写操作。在此模型中,只需要一个线程就可以管理多个 Socket,系统不需要为每一个 Socket 建立一个线程,也不需要去维护这些线程。

多路复用模型比非阻塞模型效率高是因为在非阻塞模型中,由用户线程自行进行轮询操作判断 socket 的状态,而多路复用模型使用的是额外的线程。

这就像我们好几个人外卖下单后,专门找一个人在软件平台上看每个人外卖是不是到了,到了的时候就跟到了的那个人说外卖到了,这时我们直接去小区门口取外卖就好了。

Java NIO 包所采用的模型就是多路复用 IO 模型,是非阻塞 IO 模型的一种,因此叫 Non-Blocking IO(NIO),也叫 New IO。

信号驱动 IO 模型

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

这就好比我们外卖下单之后,我们会预留我们的手机号码,然后我们直接去做其他事情。等外卖到了的时候,外卖员会自己打电话给我们让我们去取外卖。

多路复用模型与信号驱动模型的区别在于,多路复用模型不需要系统额外支持,由用户程序可以自行实现。而信号驱动模型需要系统支持。

异步 IO 模型

异步 IO 模型也被称为 AIO。在 AIO 中,当线程发起 IO 操作之后,就立即去做其他事情。而内核在接收到一个异步 IO 请求之后,会立即返回,表示请求成功发起。在数据准备完成之后,内核会主动将数据拷贝到用户内存,一切操作完成之后,内核会向用户线程发送一个信号,表示 IO 操作已完成,此时用户线程可以直接操作数据。

这就像我们外卖下单之后,会预留手机号码和具体的门牌号。外卖到了小区门口之后,小区内部也有一个外卖员,将外卖送到我们家门口,然后直接打电话让我们拿外卖,这个过程中我们可以干任何事,连拿外卖的功夫都省了。

异步 IO 模型与上述四个 IO 模型的区别在于,上诉四个 IO 模型,在实际读写数据时用户线程都会发生阻塞,虽然这个速度非常快。但是异步 IO 模型两个阶段都不会阻塞,收到信号就表示 IO 完成,用户线程只关心数据是否可用,不关心整个 IO 过程是怎么进行的。但很显然易见,异步 IO 模型也需要操作系统的支持。

IO 模型比较

BIO

在 Java 基础 IO 包中,把不同类型的输入、输出抽象为流(Stream)。这个流被称为数据流,指输入输出的数据。

IO 流分类

IO 流可以通过两个维度进行分类:

  • 按照流的方向分为输入流输出流

    由程序操作内存向外部设备转移的数据流称为输出流。由外部设备向程序操作内存转移的数据流称为输入流。

  • 按照流中的数据类型分为字节流字符流

    按字节读取的数据流称为字节流。按字符读取的数据流称为字符流。

    字符流并不总是按两个字节读取的。Java 采用 UTF-8 字符编码,为可变长字符编码,因此一个字符的长度可能是一、二甚至三个字节,有些文字符号甚至由两个字符组成。

      public static void main(String[] args) {
          String a = "12345";
          System.out.println(a + "\nCHAR SIZE: " + a.toCharArray().length + ", BYTE SIZE: " + a.getBytes(StandardCharsets.UTF_8).length);
          String b = "ĀĀĀĀĀ";
          System.out.println(b + "\nCHAR SIZE: " + b.toCharArray().length + ", BYTE SIZE: " + b.getBytes(StandardCharsets.UTF_8).length);
          String c = "一二三四五";
          System.out.println(c + "\nCHAR SIZE: " + c.toCharArray().length + ", BYTE SIZE: " + c.getBytes(StandardCharsets.UTF_8).length);
          String d = "𡃁𡃁𡃁𡃁𡃁";
          System.out.println(d + "\nCHAR SIZE: " + d.toCharArray().length + ", BYTE SIZE: " + d.getBytes(StandardCharsets.UTF_8).length);
          String e = "👽👽👽👽👽‍";
          System.out.println(e + "\nCHAR SIZE: " + e.toCharArray().length + ", BYTE SIZE: " + e.getBytes(StandardCharsets.UTF_8).length);
      }
    

    结果:

      12345
      CHAR SIZE: 5, BYTE SIZE: 5
      ĀĀĀĀĀ
      CHAR SIZE: 5, BYTE SIZE: 10
      一二三四五
      CHAR SIZE: 5, BYTE SIZE: 15
      𡃁𡃁𡃁𡃁𡃁
      CHAR SIZE: 10, BYTE SIZE: 20
      👽👽👽👽👽‍
      CHAR SIZE: 11, BYTE SIZE: 23
    

所有字节输入流都继承 InputStream 类,所有字节输出流都继承 OutputStream,所有字符输出流都继承 Reader 类,所有字符输出流都继承 Writer 类。

IO 流特点

Java 基础 IO 流对于输入输出以流进行抽象,向从管道中流入和流出的水流一样,只能从管道口取出数据,因此 Java 原始的的 IO 流只能固定地从流的头部逐个字节或逐个字符取出数据,而无法从中间截断获取出中间某部分的数据。

IO 流类型

Java IO 流类型

字节输入流

InputStream 是一个抽象类,规定了字节输入流的基础操作。

基础操作

方法 作用
read():int 从字节流中读取 1 字节数据
read(byte[]):int 从字节流中读取多个字节数据到参数数组中
read(byte[], int, int) 从字节流中读取多个字节数据到参数数组的指定范围中
skip(long):long 跳过字节流中指定数量的数据,放回跳过的数据量
available():int 返回字节流中可读取的数据量
mark(int readlimit):void 标记当前字节流读取的位置,在读取 readlimit 这么多字节数据之前保持标记生效,标记生效期间,可随时使用 reset() 方法将流读取位置重置到标记那里
reset():void 将流读取位置重置到 mark 标志位处

由于 IO 流只能读取和操作流首部,因此这个标记和重置操作必须通过保存 readlimit 内的数据才能实现。

实现类

已废弃的输入流不予以记录:

  • FileInputStream 文件输入流。用于处理文件。

  • FilterInputStream 用于为字节输入流添加额外功能,通过装饰模式实现。

    FilterInputStream 内部维护一个 InputStream in 数据成员,通过构造方法传入,通过对该成员进行各种操作实现对字节输入流功能的增强。

    实现类 作用 实现方式
    BufferInputStream 缓存输入的字节 内部维护一个 byte[] buf 字节数组作为缓存将输入流预先缓存到内存中,通常可以加快读取速度
    DataInputStream 可以直接从流中读取整型、长整型等基本数据类型 封装一系列 API
    PushBackInputStream 可以将已读出的字节压回输入流中 内部维护一个 byte[] buf 实现,压回的数据并非真正压回流中,而是放入 buf
  • ObjectInputStream 被称为反序列化流。用于从流中放序列化读取对象,类似于 FilterInputStream,将输入流作为自己的数据成员进行操作。

  • PipedInputStream 管道输入流,与管道输出流搭配使用,用于线程间的数据通信,二者使用 connect 方法进行连接。当管道中没有数据的时候,如果尝试从输入流中读取数据会引起线程阻塞。

  • SequenceInputStream 可以将两个字节输入流合并起来,读取的时候,先读取第一个输入流的数据,第一个输入流读取完毕再读取另一个输入流的数据。

  • ByteArrayInputStream 字节数组输入流。将已有的字节数组包装为输入流,使得可以通过处理流的方式处理输入流。