《Java 网络编程基础》6.1 Selector 就绪事件与 interestOps

《Java 网络编程基础》6.1 讲 Selector 的 OP_ACCEPT、OP_READ、OP_WRITE 与 interestOps/readyOps 的区别。用 linkd 的真实探针演示 selectedKeys 不 remove 导致就绪位残留、CancelledKeyException、wakeup 唤醒与 macOS kqueue 空轮询防御,给出单线程事件循环的骨架。

本节目标:分清 interestOps 与 readyOps,正确开关读写事件,并知道 selectedKeys、cancel 和 wakeup 各自会把代码带到哪个坑里。
适用版本:JDK 25(Temurin 25.0.5+8);Selector、SelectableChannel、SelectionKey。

承接的问题

linkd 到 5.3 为止的 Echo 服务仍是阻塞模型:accept() 到一个连接就开一个线程,线程里 read() 阻塞。连接数一多,瓶颈不是 CPU,而是线程。每个平台线程默认预留约 1 MB 栈空间,1000 个空闲连接就是 1000 个线程;它们绝大多数时间停在 socketRead,却仍然占用内核调度实体。3.3 的 ConnectionRegistry 能管住关闭顺序,但管不住线程数量。

真实场景里,这种空闲连接很常见:健康检查探针、浏览器预连接、移动端弱网留下的半开连接。它们长期不发数据,却各占一个线程。要让一个线程同时等 1000 个连接,需要把「等哪个连接可读」交给内核,Java 的入口是 Selector。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。macOS 的 Selector 由 kqueue 实现;本节所有输出都来自这台机器。

Selector、Channel 与 SelectionKey

三个对象分工明确:

  • SelectableChannel 是可注册的通道,SocketChannel、ServerSocketChannel、DatagramChannel 都实现了它。
  • Selector 是多路复用器,一个线程通常持有一个。它内部维护注册集合和就绪集合。
  • SelectionKey 是注册后返回的句柄,保存 channel、selector、interestOps、readyOps 和 attachment。

注册之前必须把通道设为非阻塞:

server.configureBlocking(false);
SelectionKey key = server.register(selector, SelectionKey.OP_ACCEPT);

如果对阻塞通道调用 register,JDK 会抛 IllegalBlockingModeException。OP_ACCEPT 只对服务端监听通道有意义;OP_CONNECT 只对客户端连接过程有意义;OP_READ 和 OP_WRITE 对已连接的 SocketChannel 有意义。

一个最小的 accept 循环:

try (Selector selector = Selector.open();
     ServerSocketChannel server = ServerSocketChannel.open()) {
    server.bind(new InetSocketAddress("127.0.0.1", 9090));
    server.configureBlocking(false);
    server.register(selector, SelectionKey.OP_ACCEPT);
    while (true) {
        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 channel = server.accept();
                channel.configureBlocking(false);
                channel.register(selector, SelectionKey.OP_READ);
            }
        }
    }
}

select() 阻塞到至少一个注册事件就绪,或有线程调用 wakeup(),或当前线程被中断。返回值是本次更新的 key 数量,可能是 0。selectedKeys() 返回的是 selector 自己持有的集合,不是副本,所以处理完必须移除。

select 有三个常用重载,用途不同:

  • select() 无限阻塞,适合常驻事件循环,停机靠 wakeup()。
  • select(long timeout) 最多等指定毫秒,适合需要定期做空闲检测的循环。
  • selectNow() 不阻塞,立即返回当前就绪集合,适合在任务队列非空时顺便收割一次事件。

不要用 select(1) 轮询来代替 wakeup():那会让循环每秒醒几百次,CPU 不高但也没有必要。跨线程通知的正规入口仍是 wakeup()。

非阻塞模式下,accept() 在一次可读事件里可能返回多个连接,也可能在竞争下返回 null。稳妥的写法是循环到 null:

SocketChannel channel;
while ((channel = server.accept()) != null) {
    channel.configureBlocking(false);
    channel.register(selector, SelectionKey.OP_READ);
}

只 accept() 一次不会丢连接,但会把剩余连接留到下一次 select,增加一次唤醒。循环接受完当前 backlog 更紧凑。

本机用一个探针依次注册 accept、读一个连接、再切到写,输出如下:

provider=sun.nio.ch.KQueueSelectorProvider
acceptKey interestOps=16 readyOps=0
select#1=1 readyOps=16 acceptable=true
select#2=1 readyOps=1 readable=true
read n=5 data=hello

四行信息对应四个事实:macOS 的 provider 是 KQueueSelectorProvider;注册后 interestOps=16 就是 OP_ACCEPT;select 之前 readyOps=0;accept 就绪后 readyOps=16。第二轮的 readyOps=1 是 OP_READ,读到 5 个字节的 hello。

常量值可以记,但代码里应该用符号名:OP_READ=1、OP_WRITE=4、OP_CONNECT=8、OP_ACCEPT=16。数值稳定,但直接写 16 会让读者不知道你在等什么。

interestOps 与 readyOps:登记什么,发生什么

interestOps 是「我关心哪些事件」,由应用设置;readyOps 是「这次 select 发现哪些事件已经就绪」,由 selector 更新。两者不是一回事。

OP_WRITE 的特殊之处是:只要 socket 发送缓冲区还有空间,它几乎总是就绪。把 OP_WRITE 长期留在 interestOps 里,select() 会立刻返回,循环空转。本机把一条已连接通道只注册 OP_WRITE,连续调用 1000 次 select(1000):

1000 selects, selectedKeys=1000 elapsedMs=3

1000 次带 1 秒超时的 select 总共只花了 3 毫秒,说明每次都没有等到超时,而是立刻返回。这就是 100% CPU 的空转,不是「高性能」。

正确做法是:连接注册时只关心 OP_READ;读到数据、写队列非空时,才把 OP_WRITE 打开;写队列排空后立刻关掉。

public void enqueue(ByteBuffer out) {
    if (out == null) return;
    if (closed || !key.isValid() || !out.hasRemaining()) { pool.release(out); return; }
    outbound.add(out);
    try { key.interestOps(key.interestOps() | SelectionKey.OP_WRITE); }
    catch (CancelledKeyException e) { close(); }
}

boolean flush() throws IOException {
    while (!outbound.isEmpty()) {
        ByteBuffer buffer = outbound.peek();
        channel.write(buffer);
        if (buffer.hasRemaining()) return false;
        outbound.poll();
        pool.release(buffer);
    }
    if (key.isValid()) key.interestOps(key.interestOps() & ~SelectionKey.OP_WRITE);
    return true;
}

enqueue 用按位或打开 OP_WRITE,保留已有的 OP_READ;flush 用按位与取反关闭 OP_WRITE,不影响读。channel.write 可能只写出一部分,甚至返回 0;只要 buffer.hasRemaining(),就保留缓冲区和 OP_WRITE,等下一次可写再继续。不要在 flush 里循环重试到写完,那会把事件循环卡在一个慢客户端上。

一张表把事件和动作对上:

就绪事件interestOps 变化事件循环里的动作
OP_ACCEPT监听通道一直保留accept 到 null,新连接注册 OP_READ
OP_READ不变读到 -1 关闭;读到数据则入写队列并打开 OP_WRITE
OP_WRITE写队列空时关闭调用 flush,排空后去掉 OP_WRITE

readyOps 可能同时包含多个位。一个连接既可读又可写时,先处理读、再处理写是常见顺序;如果读分支关闭了连接,写分支必须重新检查 key.isValid(),否则会对已取消的 key 调用 flush。

selectedKeys 必须 remove:一次真实的就绪位残留

selectedKeys() 返回的集合由 selector 复用。每次 select() 把新就绪的 key 加进去,应用处理完必须移除。如果只 clear() 而不处理,或者只遍历不 remove(),下一次循环会再次看到同一个 key,甚至看到上一次的 readyOps。

本机的探针里,第二轮读完数据后把 interestOps 改成只剩 OP_WRITE。如果不在每轮之后清空 selectedKeys,第三次 select 的 readyOps 是 5(读 1 加写 4);加上移除之后是 4:

select#3=1 readyOps=5 writable=true   # 未 remove,读就绪位残留
select#3=1 readyOps=4 writable=true   # 已 remove

这不是 selector 算错了,而是应用没有把上一轮的就绪记录清掉。后果是 isReadable() 在根本没有数据时仍返回 true,读分支会反复执行,读到 0 字节后什么都不做,循环继续空转。

正确的遍历模板只有一处关键动作:

Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
    SelectionKey key = iterator.next();
    iterator.remove();
    if (!key.isValid()) continue;
    // 处理 key
}

key.cancel() 也不会立刻把 key 从 selectedKeys 里拿走。取消是延迟生效的:key 先被标记为无效,下一次 select 才清理。因此在取消之后继续调用 interestOps、attachment 之外的通道操作,会抛 CancelledKeyException。本机的最小复现:

after cancel valid=false
interestOps after cancel: java.nio.channels.CancelledKeyException

isValid() 为 false 是预期的;此时不要再碰这个 key,直接关闭通道并从集合里移除。关闭通道本身也会取消 key,两条路径最终一致。

wakeup、线程归属与空轮询

Selector.select() 是阻塞调用。另一个线程要让它立刻返回,唯一安全的入口是 wakeup()。wakeup() 是 Selector 上少数可以从任意线程调用的方法;如果当前没有 select 在等待,它会把「下一次 select 立即返回」记下来。

本机用一个线程睡 200 毫秒后调用 wakeup(),主线程的 select() 返回:

wakeup select=0 elapsedMs=210

select 返回 0,因为没有任何通道就绪,但调用确实被唤醒了。这个机制有两个用途:停机时让循环退出;其他线程要提交任务(比如注册新连接)时,让循环尽快处理任务队列。

public void execute(Runnable task) {
    tasks.add(task);
    selector.wakeup();
}

public void shutdown() {
    running = false;
    selector.wakeup();
}

如果任务队列是 ArrayDeque,它只能由事件循环线程自己读写;多线程提交要换成并发队列,6.3 会改。Selector 本身不是线程安全的:register、interestOps、selectedKeys 都应该在持有 selector 的线程里操作。wakeup() 和 close() 是例外。

空轮询是另一个边界。正常情况下,带超时的 select(1000) 在没有事件时会等满约 1 秒再返回 0。某些平台和内核组合出现过「没有就绪事件却立刻返回 0」的历史问题(JDK-6670302),表现为 CPU 空转。本机在 macOS kqueue 上连续 5 次空闲 select(1000):

select#1=0 elapsedMs=1000
select#2=0 elapsedMs=1001
select#3=0 elapsedMs=1000

每次都等满了超时,没有复现空轮询。但不能因为这台机器正常就假设所有环境正常。防御性写法是记录「连续零就绪且耗时极短」的次数,超过阈值时告警并重建 selector;重建要把注册的通道和 attachment 迁移过去,成本不低,所以先告警、再决定是否自动重建。不要看到 select 返回 0 就重建:带超时返回 0 是正常现象。

在 linkd 里落地

linkd 把上面的机制收进两个入口:

  • com.plume.linkd.core.EventLoop.register(SocketChannel, Handler):设非阻塞、注册 OP_READ、把 Connection 和 Handler 放进 SelectionKey 的 attachment。
  • com.plume.linkd.core.Connection.enqueue(ByteBuffer):把待写缓冲区放进队列,并打开 OP_WRITE。

启动代码只表达「谁接受连接、谁处理连接」:

EventLoop loop = new EventLoop();
loop.listen(9090, channel ->
        loop.register(channel, new ReactorEchoHandler(loop.buffers())));
Runtime.getRuntime().addShutdownHook(new Thread(loop::shutdown, "linkd-shutdown"));
loop.run();

listen 内部注册的是 OP_ACCEPT;ChannelAcceptor 在 accept 之后决定把新连接交给哪个 Handler。单线程版里,这个 lambda 直接调用 register,因为它就在事件循环线程上执行。换成多线程版时,这个 lambda 不能直接跨线程 register,要先 execute 到目标循环,6.3 会展开。

线程归属可以用一句话检查:SelectionKey 和 Connection 的可变状态,只在拥有该 selector 的线程里读写。 其他线程想改变它们,只能提交任务并 wakeup()。这条规则比任何锁都简单,也更容易验证。

attachment 的生命周期也要跟着连接走。不要把全局共享的业务对象直接放进 SelectionKey.attach():一个 key 对应一条连接,attachment 应该放这条连接自己的状态,比如 Connection、解码器和写队列。连接关闭时,attachment 随 key 一起被丢弃;如果 attachment 里还有池化缓冲区,要在 Connection.close() 里释放,不能等 GC。

读分支还有一个容易忽略的返回值:非阻塞 read 返回 0 表示这次没有读到数据,不是连接结束。连接结束的唯一信号是返回 -1。如果读到 0 还继续在同一个事件里循环读,就会在事件循环线程里空转;正确做法是直接返回,等下一次可读事件。

一张表把 selector 上常见操作的线程安全性摆在一起:

操作能否跨线程调用说明
wakeup()能让阻塞的 select 立刻返回
close()能关闭 selector,阻塞的 select 会返回
register(...)不建议应在 selector 所属线程调用
interestOps(...)不能与 select 竞争,可能丢事件
selectedKeys()不能返回内部集合,遍历时修改有风险

这张表不是 JDK 的正式线程安全承诺,而是使用约束:除了 wakeup() 和 close(),其余操作都收在事件循环线程里,代码最容易推理。

常见错误与边界

错误一:遍历 selectedKeys 不 remove。 本机表现为 readyOps 从 4 变成 5,读分支在没有数据时仍然执行。每次 select 之后都要用迭代器移除已处理的 key。

错误二:一直登记 OP_WRITE。 发送缓冲区有空闲时 OP_WRITE 几乎总是就绪,本机 1000 次 select(1000) 只花了 3 毫秒。只在写队列非空时打开,排空后关闭。

错误三:cancel() 之后继续操作 key。 本机会抛 java.nio.channels.CancelledKeyException。取消后先检查 isValid(),不要对无效 key 调 interestOps。

错误四:把阻塞通道拿去注册。 register 会抛 IllegalBlockingModeException。ServerSocketChannel 和 SocketChannel 都要先 configureBlocking(false)。

错误五:从别的线程直接 register 或改 interestOps。 Selector 没有承诺这些操作线程安全,可能出现注册丢失、阻塞或状态错乱。跨线程只提交任务并 wakeup()。

边界:一个 Selector 对应一个事件循环线程。 多个线程共用一个 selector 不会自动变快,反而把线程安全问题引进来。要并行,就开多个 selector,每个线程一个,6.3 的主从模型就是这么做的。

边界:select 返回 0 不一定是空轮询。 带超时的正常返回也是 0;只有「零就绪且几乎不耗时」连续出现,才值得怀疑。macOS kqueue 上本节没有复现该问题,不要照搬其他平台的结论。

小结

  • Selector 把「等哪个连接就绪」交给内核,一个线程可以管理大量连接;macOS 上由 kqueue 实现。
  • interestOps 是应用登记的关注事件,readyOps 是本次就绪的事件;OP_WRITE 在发送缓冲区有空闲时几乎总是就绪。
  • 写事件只在写队列非空时打开,flush 排空后关闭;部分写要保留缓冲区和 OP_WRITE。
  • selectedKeys 由 selector 复用,必须用迭代器 remove;不移除会让旧就绪位残留,本机表现为 readyOps=5 而不是 4。
  • cancel() 延迟生效,取消后继续操作 key 会抛 CancelledKeyException;先检查 isValid()。
  • wakeup() 是跨线程唤醒 select 的安全入口;空轮询要按「零就绪且耗时极短」识别,不能把正常超时当故障。

到这里,Selector 的四个基本动作——注册、等待、处理、唤醒——都跑通了。下一节把 accept、read、write 三次事件处理拼成一个完整可运行的单线程 Reactor,并把线程归属写成代码约束。

阅读导航:上一节:5.3 FileChannel、Scatter/Gather 与零拷贝 · 下一节:6.2 单线程 Reactor 实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发