Java NIO 中的 Channel、Buffer 和 Selector 分别有什么作用?
简化版
Channel 是连接文件、Socket 等 I/O 资源的通道,Buffer 是数据读写的内存容器,Selector 是可选择通道的多路复用器。程序从 Channel 读数据到 Buffer,或从 Buffer 写入 Channel;一个 Selector 可以监听多个非阻塞 Channel 的就绪事件。
详细版
NIO 的三个核心角色是:
- Channel:表示 I/O 连接。
FileChannel、SocketChannel、ServerSocketChannel分别面向文件、TCP 客户端和 TCP 服务端。Channel 可以读、写或同时支持两者,具体能力由通道类型和打开方式决定。 - Buffer:装载数据。最常用的是
ByteBuffer,通过position、limit、capacity描述当前可读写区间。 - Selector:监听多个
SelectableChannel是否出现连接、接收、读、写就绪事件,让一个事件循环管理多个连接。
Selector 只告诉程序“哪些操作现在可以尝试”,不替程序读写数据,也不保证一次就能把数据全部读完或写完。
完整版教学
一、Channel 与传统 Stream 的差异
传统流通常以连续字节序列的方式读写;Channel 的常见 read / write 操作通过 Buffer 传递数据。Channel 体系还支持非阻塞网络 I/O、分散读取、聚集写入、文件映射和通道间传输等能力;其中映射和 transferTo 等特定操作不需要应用自己提供中间 Buffer。
不要把“Channel”简化成“一定双向”。SocketChannel 可读可写,但 ServerSocketChannel 主要接受连接,而一个只按 READ 方式打开的 FileChannel 也不能写。
二、Buffer 是数据状态机
刚创建的 ByteBuffer 通常处于写模式:position = 0,limit = capacity。Channel 将数据写入 Buffer 后,需要调用 flip() 把它切换到读模式;处理完后用 clear() 重用整个缓冲区,或用 compact() 保留没读完的数据。
Buffer 的状态是 NIO 代码的高频 bug 来源。忘记 flip() 可能导致读不到数据,不处理半包就 clear() 则可能丢数据。
三、Selector 怎么串起多个连接
try (Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open()) {
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
server.register(selector, SelectionKey.OP_ACCEPT);
while (!Thread.currentThread().isInterrupted()) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) continue;
if (key.isAcceptable()) {
SocketChannel client = server.accept();
if (client != null) {
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ,
ByteBuffer.allocate(4096));
}
}
}
}
}
通道必须先切换为非阻塞模式才能注册到 Selector。注册结果是 SelectionKey,它记录通道、Selector、关心的事件和已就绪事件。可以通过 attachment 绑定每个连接的 Buffer 或业务上下文。
四、实战中的几个坑
- 处理完
selectedKeys()中的 key 后要从集合中移除,否则下一轮可能重复处理旧事件。 - 读就绪不等于一条完整业务消息已经到达,TCP 可能出现拆包和粘包,应该由协议的长度字段、分隔符或固定长度解决边界。
write()可能只写出 Buffer 的一部分。剩余数据要保留,并注册OP_WRITE等待下次可写;没有待写数据时不应长期关心OP_WRITE,否则容易造成空转。FileChannel不是SelectableChannel,普通磁盘文件不能像 Socket 一样注册到 Selector。
五、用一次半包和部分写串起三者
假设每条消息由 4 字节长度头和 100 字节正文组成,而第一次可读事件只收到 60 字节。Channel 把 60 字节放入 Buffer;解码器读出长度后发现正文还差 44 字节,必须 compact() 保留未完成帧。下一次 OP_READ 到达后再拼接,不能把第一次 read 当作完整消息。
响应共 104 字节时,一次非阻塞 write 可能只写出 40 字节,Buffer 的 position 会推进 40。剩余 64 字节应保存在连接上下文并临时关注 OP_WRITE,写完后取消写兴趣,避免可写事件空转。
Selector 找就绪连接 → Channel 搬运字节 → Buffer 保留游标与半包状态
↑ ↓
SelectionKey attachment 保存每连接上下文
| SelectionKey 集合 | 含义 | 常见操作 |
|---|---|---|
| interest set | 应用关心哪些操作 | interestOps(...) |
| ready set | 本轮哪些操作已就绪 | readyOps() / isReadable |
| selected-key set | 待应用处理的 key | 迭代后移除 |
六、并发修改、wakeup 与背压
Selector 常由单一事件线程拥有,以避免多个线程同时修改连接状态。业务线程若产生响应,可先放入线程安全队列,再调用 wakeup() 让阻塞的选择操作及时返回,由事件线程统一修改 interest set;wakeup 是通知机制,不替代队列和内存可见性设计。
若写端持续慢于生产端,待发送 Buffer 会不断累积。假设每个连接积压 2 MiB、共有 500 个慢连接,待写数据就约为 1,000 MiB,因此必须设置高低水位、暂停读取或拒绝请求,而不是无界注册 OP_WRITE。
记忆钩子:Selector 管“谁能做”,Channel 管“和谁搬”,Buffer 管“搬到哪里以及搬到哪一步”;attachment 管“这个连接下一步做什么”。
七、常见误区与追问
- 误区:所有 Channel 都能注册到 Selector。 只有
SelectableChannel可以,普通FileChannel不属于这一体系。 - 误区:就绪表示操作一定一次完成。 就绪只表示可以尝试,read/write 都可能部分完成,非阻塞调用也可能返回 0。
- 误区:处理 selected key 后不需要移除。 传统 selected-key set 不会由 Selector 自动清空,不移除会重复处理旧 key。
- 误区:OP_WRITE 应从连接建立起永久注册。 大多数 Socket 经常可写,这会造成事件循环空转,应只在确有积压时关注。
- 追问:SelectionKey attachment 有什么用? 它把 Buffer、解码器、待写队列等每连接状态与注册关系绑定起来。
- 追问:跨线程修改兴趣集为什么常配合 wakeup? 让阻塞在 select 的事件线程及时返回并观察队列中的变更,降低响应延迟。
- 追问:Selector 如何处理业务背压? Selector 本身不提供完整策略,应用需限制队列、切换读兴趣、设置水位或关闭过慢连接。
八、加强记忆
按数据链理解三者:Selector 找出已就绪的可选择 Channel,Channel 负责连接 I/O 资源,Buffer 承载真正读写的数据。Selector 只报告就绪事件,不会替应用读完数据;半包、部分写和 Buffer 状态仍需要应用代码处理。