NIO 的三大核心组件:Buffer(缓冲区)、Channel(通道)和 Selector(选择器)
Java NIO(New I/O,在 Java 1.4 中引入)与传统的 BIO(Blocking I/O,阻塞型 I/O)相比,是一个面向缓冲区、基于通道、非阻塞的 I/O 模型。
NIO 的核心在于用极少的线程来高效处理大量的连接,而实现这一目标的关键就是它的三大核心组件:Buffer(缓冲区)、Channel(通道) 和 Selector(选择器)。
下面我们逐一深入解析这三大组件及其协同工作原理。
一、 Buffer(缓冲区)
在传统的 BIO 中,数据的读写是面向流(Stream)的(一次读写一个或多个字节,且不能前后移动指针)。而在 NIO 中,所有数据的读写都是通过Buffer(缓冲区)进行的。
1. 什么是 Buffer?
Buffer 实质上是一个数组(通常是字节数组 ByteBuffer),但它被封装成了特定对象,并提供了一组方法来方便地跟踪和记录缓冲区状态。
2. Buffer 的核心属性
为了管理读写状态,Buffer 内部维护了 4 个核心索引属性:
- Capacity(容量):Buffer 能容纳的最大数据量,在创建时设定,不能改变。
- Limit(界限):缓冲区中无法读写的第一个元素的索引。
- 写模式下:limit 等于 capacity。
- 读模式下:limit 表示最多能读出多少数据(等于写模式下的 position)。
- Position(位置):下一个要读写的元素的索引。随着读写操作自动向后移动。
- Mark(标记):一个备忘位置。调用
mark()可以记录当前 position,调用reset()可以让 position 回到 mark 的位置。
关系大小限制:
3. Buffer 的核心方法(模式切换)
Buffer 在“写数据”和“读数据”之间切换时,指针变化非常关键:
flip()(翻转):从写模式切换到读模式。它将limit设为当前position,然后将position置为 0。准备读取刚才写入的数据。clear()(清空):清空缓冲区,切换回写模式。它将position置为 0,limit置为capacity(数据其实没被真正删除,只是指针复位了,后续写入会覆盖旧数据)。compact()(压缩):只清除已读数据。未读数据移动到缓冲区开头,position设在未读数据后面,准备继续写入。
二、 Channel(通道)
1. 什么是 Channel?
Channel 是一个通道,代表与某个实体(如文件、套接字 Socket)的连接。它类似于传统 I/O 中的“流”,但有本质区别:
- 双向性:流是单向的(例如
InputStream只能读,OutputStream只能写);而通道是双向的,既可以读,也可以写。 - 非阻塞:通道可以设置为非阻塞模式(FileChannel 除外)。
- 与 Buffer 交互:通道不能直接读写数据,它必须和 Buffer 结合使用。
- 读操作:Channel Buffer(通道把数据读入缓冲区)
- 写操作:Buffer Channel(把缓冲区的数据写入通道)
2. 常用的 Channel 实现
FileChannel:用于文件的数据读写(注意:FileChannel 无法设置为非阻塞模式,它总是阻塞的)。DatagramChannel:用于通过 UDP 协议读写网络中的数据。SocketChannel:TCP 客户端通道,用于读写 TCP 网络协议中的数据。ServerSocketChannel:TCP 服务端通道,用于监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个SocketChannel。
三、 Selector(选择器)
Selector 是 Java NIO 实现高并发、非阻塞 I/O 的灵魂组件。
1. 什么是 Selector?
Selector 是一个多路复用器。它允许一个线程去监听多个通道(Channel)上的 I/O 事件(如:连接打开、数据到达、写就绪等)。
在传统 BIO 中,一个连接需要一个线程去处理,如果连接很多但大多处于空闲状态,会造成极大的线程资源浪费。而 Selector 使得单线程管理成千上万个连接(通道)成为可能。
2. 核心工作原理
- 注册(Register):首先将通道(如
SocketChannel)设置为非阻塞模式,然后将其注册到 Selector 上,并指定关注的事件。 - 选择(Select):线程调用 Selector 的
select()方法。这个方法会阻塞,直到注册的通道中至少有一个发生了关注的事件。 - 处理(Handle):一旦有事件发生,
select()返回,线程可以通过selectedKeys()获取所有就绪的事件集合(SelectionKey),遍历集合并进行相应的 I/O 操作。
3. 关注的事件类型(SelectionKey 的四个常量)
OP_ACCEPT:有新的连接请求准备好被接受(通常用于ServerSocketChannel)。OP_CONNECT:连接已建立(通常用于客户端SocketChannel)。OP_READ:通道中有数据可读。OP_WRITE:通道已准备好写入数据。
四、 三大组件的协同工作流程
我们用一个“网络聊天室/服务器接收数据”的经典场景,来看看这三者是如何协同工作的:
plaintext
+------------------+
| Thread | <--- 1个线程掌控全局
+------------------+
|
v
+------------------+
| Selector | <--- 轮询哪些通道有事件发生
+------------------+
/ | \
/ | \ (监听事件:OP_READ, OP_WRITE...)
v v v
+---------+ +---------+ +---------+
| Channel | | Channel | | Channel | <--- 多个非阻塞通道
+---------+ +---------+ +---------+
^ ^ ^
| 读写数据 | 读写数据 | 读写数据
v v v
+---------+ +---------+ +---------+
| Buffer | | Buffer | | Buffer | <--- 数据必须通过 Buffer
+---------+ +---------+ +---------+
- 初始化:服务器启动,创建
ServerSocketChannel,将其设置为非阻塞,并注册到Selector上,监听OP_ACCEPT(连接就绪)事件。 - 监听:主线程调用
selector.select()进入等待。 - 客户端连接:客户端发起连接。Selector 感知到
OP_ACCEPT事件,select()返回。 - 建立连接并注册新通道:服务器接受连接,获得对应的
SocketChannel。将这个新通道也设为非阻塞,并注册到Selector上,监听OP_READ(可读)事件。 - 数据传输:
- 当客户端发送数据时,Selector 监听到对应的
SocketChannel有OP_READ事件。 - 线程被唤醒,分配一个
ByteBuffer(缓冲区)。 - 通道(Channel)调用
read(buffer),把网络中的数据读入缓冲区。 - 调用
buffer.flip()切换为读模式,线程开始从缓冲区中读取数据进行业务处理。
- 当客户端发送数据时,Selector 监听到对应的
总结
- Buffer:负责装载数据,是数据的临时避风港(解决了“数据怎么存”的问题)。
- Channel:负责传输数据,是数据的双向传送带(解决了“数据往哪走”的问题)。
- Selector:负责多路复用和调度,是整个架构的指挥官(解决了“什么时候读写,用多少线程读写”的问题)。
右滑查看面试常问