Java NIO 概念梳理
看了很多零零散散的 NIO 文章,但没有形成系统的认识。今天尝试整理一下 NIO 的相关概念。
什么是 NIO
Java 提供的传统 IO 模型称为 BIO (阻塞 IO,Blocking IO),而 NIO 称为“非阻塞 IO” (Non-blocking IO)。传统 IO 是面向输入/输出流(InputStream / OutputStream)的,NIO 是面向通道(Channel)的。
在进一步介绍概念之前,我们先理解一下什么是 阻塞 。
阻塞从概念上理解,指的是当前工作卡在某个点无法进行下去。从编程模型上讲,阻塞的对象就是线程。线程从运行态转为阻塞态,不再占用 CPU 资源,就代表该线程被阻塞。
假如某线程要读取一个文件:
FileInputStream fis = new FileInputStream("/Users/tiger/test.txt");
byte[] buf = new byte[1024];
fis.read(buf); // 此方法会阻塞线程,在数据读取完成之前,下面的代码无法执行
System.out.println(new String(buf));
我们看到,在传统 BIO 模型下,InputStream
的 read()
方法会阻塞线程,此时 JVM 会调用操作系统读取文件,程序从用户态转为内核态,当操作系统将文件读取完成并且把数据从内核缓冲区拷贝到 JVM 的内存中后,该线程才会被唤起继续执行。大概的流程如下图:
NIO 和 BIO 的核心区别就是,调用 IO 操作的线程不再阻塞。我们使用伪代码的方式说明:
NonBlockingFileInputStream fis = new NonBlockFileInputStream("/Users/tiger/test.txt"); // 创建非阻塞读取流,这里是为了说明逻辑,实际上并没有这个类
byte[] buf = new byte[1024];
int read = fis.read(buf); // 此方法不会阻塞线程,而是根据返回值标识读取结果
while (read == -1) {
// 如果没有读到数据,循环
read = fis.read(buf);
}
System.out.println(new String(buf));
因为线程没有阻塞,所以该线程就有可能同时处理多个文件的读取了。比如有多个文件输入流,我们就可以这样管理:
List<NonBlockingFileInputStream> fisList = multiRead(); // 假设该方法同时读取多个文件
for (NonBlockingFileInputStream fis : fisList) {
byte[] read = read(fis); // 如果读没有取到内容,返回 null
if (read != null) {
handleReadResult(read); // 处理读取内容
}
}
上面的代码简单说明了如何使用非阻塞读取的方式通过一个线程管理多个读取任务。
这里有个问题,既然 BIO 已经可以实现 IO 操作,为什么还要推出一套 NIO 体系呢?要理解这个问题,我们需要通过网络 IO 来进行说明,因为 NIO 主要是为了优化网络 IO 模型的。
我们通常使用网络 IO 来实现 B/S 和 C/S 架构,就是浏览器和客户端访问网络服务器做数据交互。这些架构有个共同特点:客户端的数量远大于服务器的数量,一台服务器需要支持多个客户端的网络传输。使用传统的 BIO 模型,服务器在从网络 socket
中读取或写入数据时,当前线程是阻塞的,这时如果有其他客户端也在请求服务器,当前线程无法响应。这肯定是不可接受的。所以使用传统 BIO 模型,服务器端一定是通过多线程的方式支持并发请求。客户端一旦连接服务器,服务器就创建一个线程去单独处理该客户端的请求。但一台服务器能同时运行的线程数量是有限的,因为服务器内存是有限的。并且线程在处理网络 IO 的过程中也会阻塞,也就是说服务器的内存被大量占用,却没有得到高效的利用。这是 BIO 模型的弊端。
而 NIO 尝试解决 BIO 的弊端。NIO 在处理 IO 请求时,当前线程不会阻塞,这就可以实现通过一个线程管理多个客户端的 IO 请求,在一定程度上提高了服务器资源的利用率。但我们在上面的 NIO 例子中也会发现一个问题,就是无论当前有没有 IO 请求,线程会一直空转下去,这是对 CPU 资源的浪费。我们可以看一下JVM 是如何通过自己的 NIO 模型解决这个问题的。
NIO 的核心组件
NIO 有三大核心组件。分别是 Selector
、Channel
和 ByteBuffer
。我们分别介绍这些组件的作用。
ByteBuffer 缓冲区
ByteBuffer 顾名思义,就是在 IO 过程中的数据缓冲区,有些类似于我们在上面例子中的字节数组,但 ByteBuffer 封装了很多实用的方法,比字节数组功能更强大。我们先看一下 ByteBuffer
的类结构:
可以看到,ByteBuffer
是一个抽象类,它有两个实现体系,分别是 MappedByteBuffer
和 HeapByteBuffer
,其中以 “R” 为后缀的实现类表示只读缓存。从类名我们就可以看出端倪,HeapByteBuffer
是直接申请在 JVM 堆内存上的缓冲区,而 MappedByteBuffer
则是映射到系统内存上的缓冲区,被称作“直接缓存”,直接缓存是操作系统的内存空间,需要我们自行管理这部分内存,防止出现内存泄漏。
ByteBuffer 是 Buffer 体系下最常用的类。Buffer 还包括其他基本类型的实现,如 CharBuffer
、DoubleBuffer
等,具体可以参考 JVM 的 API 文档。
注意,ByteBuffer 不是线程安全的,多线程编程下需要注意状态的同步控制。
Buffer 的底层原理
Buffer 是所有缓冲区实现类的父类,它定义了一个线性的有限序列,用来存放原始数据类型。Buffer 有三个重要属性,分别是:
-
capacity
。表示当前缓冲区能存放的最大元素数量。缓冲区一旦创建,容量便不会更改。 -
limit
。表示当前缓冲区可以读/写的终点索引。范围是 [0, capacity]。 -
position
。表示当前缓冲区下一个可以读/写的元素索引。范围是 [0, limit]。
缓冲区有两种操作模式:读模式(get)
和 写模式(put)
。假设创建一个 8 字节的缓冲区:
ByteBuffer buffer = ByteBuffer.allocate(8);
我们用图表示一下缓冲区各个状态之间的变化:
初始化
初始化完成后字节数组中所有元素的起始值都是 0。position
= 0,limit
= capacity
。
写入数据
缓冲区写入部分数据后,position
向后移动,指向下一个要写入的位置。如果写入的数据超过缓冲区的容量,导致 position == limit
,此时再写入就会抛出 BufferOverflowException
。
切换到读模式
当缓冲区写入部分数据后,调用缓冲区的 flip()
方法,将缓冲区转换为读模式。flip()
方法做的事情很简单,我们直接看源码:
public final Buffer flip() {
limit = position; // 将 limit 重置为当前 position 的值,表示最多读到这里
position = 0; // position 重置为 0,表示从头开始读
mark = -1; // 辅助参数,这里不展开
return this; // 返回当前缓冲区对象
}
调用 flip()
方法后缓冲区变为:
切换到写模式
当缓冲区的数据读取完之后,调用 clear()
方法,将缓冲区重置为初始化的状态。
当读取了缓冲区部分数据后,如果此时想立即切换到读模式,可以调用 compact()
方法压缩缓冲区,把已经读取的部分移除,把未读取的部分往前移:
Channel 通道
Channel
相当于 NIO 模型中的流,表示 JVM 应用和一个支持 I/O 操作的设备或组件的连接,如硬盘、Socket、文件等。Channel
支持双向传输,既可以读,也可以写。程序不能直接访问 Channel
中的数据,需要通过 Buffer
作为中介。Channel
还提供了 map()
方法,支持将“一块”数据直接映射到系统内存中。常见的 Channel
实现包括:
- FileChannel。用于文件读写;
- SocketChannel & ServerSocketChannel。用于实现 TCP 客户端服务端通信;
- DatagramChannel。用于实现 UDP 通信;
如下图所示:
注意类图中的 SelectableChannel
类,该类表示可以被下面提到的选择器进行管理。FileChannel
没有继承该类,表示文件相关的 I/O 不支持选择器操作,也就是说不支持非阻塞操作。
默认情况下,Channel
对象都是工作在阻塞模式下的,可以通过下面的方法修改:
SocketChannel.configureBlocking(false); // 指定当前通道为非阻塞模式
Selector 选择器
SelectableChannel
对象的多路复用器。选择器可以同时管理多个通道对象,当发生通道关注的事件时,通知对应的通道对象进行处理。
SelectableChannel
的子类实现了 register()
方法,通道调用该方法,即可注册到指定的 Selector
对象上:
/**
* 注册到指定的选择器上
* @param sel 选择器对象
* @param ops 通道关注的事件
* @param att 附件
* @return SelectionKey 通道的注册结果,对象中包含对应的通道和选择器以及操作方法
*/
SelectionKey SelectableChannel.register(Selector sel, int ops, Object att);
Selector
支持 4 种事件:
SelectionKey.OP_READ = 1 << 0; // 读就绪,发生在 SocketChannel 和 DatagramChannel,表示当前通道中有数据了,可以执行读到做
SelectionKey.OP_WRITE = 1 << 2; // 写就绪,发生在 SocketChannel 和 DatagramChannel,表示可以向当前通道写入数据
SelectionKey.OP_CONNECT = 1 << 3; // 连接就绪,发生在 SocketChannel,表示客户端和服务端成功建立连接
SelectionKey.OP_ACCEPT = 1 << 4; // 连接就绪,发生在 ServerSocketChanne,表示服务端至少接入了一个客户端,可以通过 accept() 方法获取对应的 SocketChannel 了
关注事件可以在通道注册时指定,也可以通过 SelectionKey
对象添加:
SelectionKey SelectionKey.interestOps(int ops);
我们上面提到过非阻塞 I/O 的一个弊端就是会导致线程空转,浪费 CPU 资源。Selector
解决了这个问题。Selector
同时支持阻塞和非阻塞模式获取事件通知:
int selectNow(); // 该方法工作在非阻塞模式,无论有没有事件发生,调用该方法会立即返回
int select(); // 该方法调用后会阻塞,直到有关注的事件发生才会返回
int select(long timeout); // 该方法可以指定阻塞时间
NIO 的底层原理
我们首先了解一下操作系统常见的 5 种 I/O 模型。
- Blocking I/O 阻塞式 I/O;
- Non-blocking I/O 非阻塞式 I/O;
- I/O multiplexing I/O (select and poll) 多路复用;
- Signal driven I/O (SIGIO) 信号驱动 I/O;
- Asynchronous I/O (the POXIS aio_functions) 异步 I/O;
这 5 种 I/O 模型,使用 同步/异步
、阻塞/非阻塞
分类:
前面已经介绍过 BIO 和 NIO 的区别,而 Java 的 NIO 模型,实际上就是 多路复用。
目前主流的多路复用 I/O 实现主要有四种:select
、poll
、epoll
、kqueue
。下表是它们的一些重要特性和比较:
IO 模型 | 相对性能 | 关键思路 | 操作系统 | Java 支持情况 |
---|---|---|---|---|
select | 较高 | Reactor | Windows/Linux | 支持,Reactor模式(反应器设计模式)。Linux 操作系统的 kernels 2.4 内核版本之前,默认使用 select ;而目前 windows 下对同步 IO 的支持,都是 select 模型 |
poll | 较高 | Reactor | Linux | Linux 下的 Java NIO 框架,Linux kernels 2.6 内核版本之前使用 poll 支持。也是使用 Reactor 模式 |
epoll | 高 | Reactor/Proactor | Linux | Linux kernels 2.6 内核版本及之后使用 epoll 进行支持。另外需要注意,由于 Linux 下没有 windows 的 IOCP 技术提供真正的 异步 IO 支持,所以 Linux 下使用 epoll 模拟异步 IO |
kqueue | 高 | Proactor | Linux | 目前 Java 不支持 |
多路复用技术最适用于“高并发”场景,所谓高并发是指 1 ms 内至少同时有上千个连接请求准备好。其他情况下多路复用技术不能发挥优势。另一方面,使用 Java NIO 进行功能实现,相对于传统 Socket
实现要复杂一些。在实际应用中,要根据自己的业务需求进行技术选择。