Selector:interestOps、就绪集合与连接关闭
Selector 把多个非阻塞通道的就绪状态集中交给事件循环,但不会替应用保存待发送数据,也不会自动结束连接。理解它的关键,是把“想监听什么”“本次发现什么”和“连接还剩什么工作”分开。前置可读 Buffer 与 Channel,IO 基础见 字节、字符与资源管理。
注册的是兴趣,不是任务
通道先执行 configureBlocking(false),再 register(selector, ops)。监听套接字支持 OP_ACCEPT,已连接的 SocketChannel 通常监听 OP_READ;非阻塞连接尚未完成时监听 OP_CONNECT,就绪后仍需 finishConnect() 确认结果。给通道注册不支持的事件会抛出异常。
interestOps(int) 替换整个集合;interestOpsOr(OP_WRITE) 添加写兴趣;interestOpsAnd(~OP_WRITE) 只移除写兴趣,保留其他位。不要为了开启写而意外覆盖读。修改发生在选择操作进行期间时,要到下一次选择才生效。跨线程提交工作时,可以先把任务加入线程安全队列,再调用 selector.wakeup(),由事件循环取任务、更新连接状态;唤醒本身不传递任务。
| 状态 | interestOps | 应用动作 |
|---|---|---|
| 等待请求 | OP_READ | read,保存尚未组成消息的数据 |
| 有待发送字节 | OP_READ 与 OP_WRITE,或仅 OP_WRITE | 保留输出 Buffer,按 position 继续写 |
| 输出已排空 | 移除 OP_WRITE | 等待后续请求或进入关闭流程 |
| 输入 EOF 且响应已排空 | 取消 key、关闭 channel | 清理应用状态 |
以上兴趣集合与 readyOps() 不同。后者是 Selector 发现的就绪提示,不能当作完整业务请求或发送成功的证明。
selectedKeys 为什么必须清理
这里讨论返回集合的 select() 系列;带 Consumer 的选择方法不向 selected-key 集合添加 key。普通 select() 不会每轮清空 selectedKeys()。已经留在集合中的 key,其就绪位还可能与下一轮发现的位合并。select() 返回数表示就绪集合被更新的 key 数,也不能简单理解为集合大小。
事件循环的典型骨架如下,完整可运行实现见后文源码:
selector.select();
var it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
if (!key.isValid()) continue;
// 检查 ready 位,执行非阻塞读写,更新连接状态
}先移除再处理,可以避免后续业务异常留下旧通知;但移除只表示消费这条通知,既不注销通道,也不清空业务 Buffer。读写就绪也可能伴随错误;仍须检查 read/write 的返回值并处理 IOException。读写两种事件可以同时成立,不应无条件用互斥的 else-if 丢掉其中一种。
OP_WRITE 为什么容易空转
TCP 发送缓冲区有空间时,通道往往持续可写。如果没有业务数据却一直订阅 OP_WRITE,选择操作便可能迅速返回,循环反复处理“可写但无事可做”,造成 CPU 空转。这不是 selectedKeys 清理能够解决的问题:即使每轮移除了 key,下一轮仍会再次发现可写。
生产代码通常先尝试写一次;若 Buffer 仍有剩余,再打开写兴趣。后续写回调继续发送,排空后立即关闭写兴趣。write() 可能只写一部分,甚至返回 0,此时要保留原 Buffer 的 position,返回事件循环等待下一次机会,不能用 while 强行自旋写完。多连接场景还需要每轮写入预算和输出队列上限,防止大响应独占线程或慢客户端耗尽内存。
EOF 与关闭要按协议排序
read() 返回 0 是暂时没有读到数据,返回 -1 才表示输入结束。对端 shutdownOutput() 只关闭它的发送方向,仍可接收响应。因此,看到 EOF 就直接关闭,可能丢弃应用尚未发送的响应。
cancel() 立即使 key 无效,但集合中的注销清理在下一次选择操作进行;它不会关闭通道。关闭 channel 会取消相关 key;关闭 Selector 则不会替应用关闭注册通道。真实服务应在单连接异常路径中取消 key、关闭 channel、释放附件及输出队列,让其他连接继续工作;服务退出时再统一关闭所有连接。
Java 21 最小本地运行
SelectorDemo.java 只绑定 127.0.0.1 的临时端口,无第三方依赖。为了把代码集中在生命周期上,连接建立和 accept 使用阻塞调用,随后仅将服务端连接改为非阻塞并注册 Selector;它不是多连接服务器。客户端半关闭触发 EOF,服务端明确切换到写兴趣,排空四字节响应后关闭。循环带超时,末尾断言客户端正文、EOF 和注册集合清空。
从仓库根目录执行:
javac --release 21 -encoding UTF-8 -d docs/upgrade-100/checks/nio-selector/classes examples/java-advanced/nio-selector/SelectorDemo.java
java -cp docs/upgrade-100/checks/nio-selector/classes SelectorDemo
java -cp docs/upgrade-100/checks/nio-selector/classes SelectorDemo stale
java -cp docs/upgrade-100/checks/nio-selector/classes SelectorDemo busy正常模式应输出兴趣切换和 PASS。stale 故意漏掉 iterator.remove,预期以 AssertionError 非零退出;busy 故意保留空队列的写兴趣,在有界的二十轮选择后断言失败。后者记录重复可写次数,不把运行耗时当作 CPU 基准。四字节响应不能保证触发真实部分写或 write=0;示例保留剩余 Buffer 的逻辑,但未声称覆盖慢客户端背压。
实测命令、JDK 版本、stdout/stderr 与退出码保存在 运行证据。不涉及数据库或 Redis,也未执行相关环境测试。继续阅读 FileChannel 与复制边界,可比较网络就绪通知与文件传输返回值的区别。
官方资料
- Java 21 Selector:集合维护、选择操作与 wakeup。
- Java 21 SelectionKey:兴趣位、就绪提示与取消语义。
- Java 21 SocketChannel:部分读写、连接与半关闭。
