本文介绍 Java 的 3 种 IO 模型

何为 I/O?

I/O 描述了计算机系统与外部设备之间的通信过程。用户进程需要执行 IO 操作的话,需要通过系统调用来访问内核空间,即具体 IO 的执行由操作系统的内核来完成。

平常接触最多的是磁盘 IO(读写文件)网络 IO(网络请求和响应)

当程序发起 I/O 调用后,会经历两个步骤:

  1. 内核等待 I/O 设备准备好数据
  2. 内核将数据从内核空间拷贝到用户空间

UNIX 系统下, IO 模型一共有 5 种:同步阻塞 I/O同步非阻塞 I/OI/O 多路复用信号驱动 I/O 和异步 I/O

  • 同步非阻塞 I/O:一直调用 read 请求数据,如果没有数据会直接返回,直到数据准备好了。在等待数据过程中,会频繁在用户态和内核态之间切换。

Java 中 3 种常见的 IO 模型

BIO

BIO 属于同步阻塞 IO 模型

同步阻塞 IO 模型中,应用进程在发起 IO 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。

image.png

stream 相关的 API 都是阻塞的

NIO

NIO 是支持面向缓冲的,基于通道的 IO 操作方法。Java 中的 NIO 是 I/O 多路复用模型。

image.png

IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。

IO 多路复用模型,减少无用的系统调用,降低了对 CPU 资源的消耗。

目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。

  • select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
  • epoll 调用:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。

使用 NIO 不一定意味着高性能,其性能优势体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO


三大组件

三大组件包括 Channel、Buffer、Selector

Channel

用于读写数据的双向通道,可以从 channel 中读取数据到 buffer,也可以将 buffer 的数据写入 channel。

常用的 Channel 有:

  • FileChannel:文件传输通道
  • DatagramChannel:UDP 传输通道
  • SocketChannelServerSocketChannel:TCP 传输通道
Buffer

用于缓冲读写数据,常用的是 ByteBuffer

ByteBuffer 食用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
try (FileChannel channel =
new FileInputStream(".\\data.txt").getChannel()) {
// 准备缓冲区
ByteBuffer buffer = ByteBuffer.allocate(5);

int len; // 从 channel 中读取数据,向 buffer 写入
while ((len = channel.read(buffer)) != - 1) {
buffer.flip(); // 切换到读模式
while (buffer.hasRemaining()) { // 是否还有剩余未读数据
byte b = buffer.get(); // 从 buffer 中读数据
System.out.println("Get character from buffer " + (char) b);
}
buffer.clear(); // 切换到写模式
}
} catch (IOException e) {
e.printStackTrace();
}
Buffer 结构

Buffer 中有三个重要的属性:capacitypositionlimit。有两种模式:读模式写模式。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。

默认状态:
image.png

写入 4 个字节的状态:
image.png

调用 flip() 方法,position 移到读取位置,limit 切换到读取限制:
image.png

调用 clear() ,回到默认状态;调用 compact(),把未读完的向前移,切换到写模式:
image.png

Seletor

用来配合一个线程管理多个 Channel,获取这些 Channel 上发生的事件。

一个多路复用器 Selector 可以同时轮询多个 Channel,底层 JDK 使用了 epoll() 调用。

在注册 Channel 到 Selector 上时,同时需要 Channel 添加独立的 Buffer。将 Channel 注册到 Selector 上,会返回一个 SelectionKey,用来标识某个 Channel,并且会监听该 Channel 上的事件,如 acceptconnectreadwrite。不同类型的 Channel 关注的事件不同,需要给 SelectionKey 绑定关注的事件。当没有事件发生时,线程会阻塞

select 方法,没有事件发生时线程阻塞,有事件发生才会恢复运行。在事件未处理时,线程不会阻塞,因此事件要么处理,要么取消,否则线程会陷入死循环。

selector 在事件发生后,会向 selectedKeys 集合中加入对应的 key,但不会主动删除,因此处理完某个 key 应该主动移除,防止 NPE。因为涉及到删除操作,应该使用迭代器遍历

零拷贝

零拷贝是指计算机执行 IO 操作时,CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。

传统的 IO 问题

假设我们现在需要将一个文件通过 socket 写出,伪代码如下:

1
2
3
4
5
6
7
8
File f = new File("data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");

byte[] buf = new byte[(int)f.length()];
file.read(buf);

Socket sc = ...;
sc.getOutputStream().write(buf);

内部的工作流程如下图:

image.png

  1. 调用 read 方法后,程序会从用户态切换到内核态,由内核将数据读到内核缓冲区,这时候用户线程阻塞,CPU 不参与
  2. 从内核态切换会用户态,将数据从内核缓冲区拷贝到用户缓冲区(即buf),此时 CPU 参与拷贝
  3. 将数据从用户缓冲区写入 socket 缓冲区,CPU 参与拷贝
  4. 从用户态切换到内核态,调用内核将 socket 缓冲区中的数据写入网卡,CPU 不参与

以上过程经历 4 次上下文的切换和 4 次数据拷贝

mmap 优化

image.png

使用内存映射,将堆外内存映射到 JVM 内存,减少了一次数据的拷贝。用户态与内核态的切换次数没有减少

  • 堆外内存不受 GC 的影响,因此内存地址固定,有助于IO 读写
  • java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分成两步
    • DirectByteBuf 对象被垃圾回收,将虚引用加入引用队列
    • 通过专门线程访问引用队列,根据虚引用释放堆外内存
sendFile 优化

image.png

将数据读到内核缓冲区后,无需切换回 Java,直接可以将数据复制到 socket 缓冲区,数据拷贝了 3 次,上下文切换 2 次

  • Java 调用 transferTo 方法后,从用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 cpu
  • 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
  • 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 cpu
sendFile + DMA gather copy

image.png

将数据读到内核缓冲区后,通过 DMA 直接将数据发送到网卡,整个过程不需要 CPU 的参与,数据拷贝了 2 次,上下文切换 2 次

  • Java 调用 transferTo 方法后,从用户态切换至内核态,使用 DMA 将数据读入内核缓冲区,不会使用 cpu
  • 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  • 使用 DMA 将内核缓冲区的数据写入网卡,不会使用 cpu

AIO

AIO 即 NIO2,是异步 IO 模型

异步 IO 是基于事件和回调机制实现。应用操作之后会直接返回,不会造成阻塞,当后台处理完成后操作系统会通知响应的线程进行后续操作