Java NIO的Selector通过操作系统多路复用机制(如epoll/kqueue)实现单线程管理千级非阻塞通道,依赖SelectionKey跟踪就绪事件,需手动清理selectedKeys并按事件类型分角色处理。

Java NIO 的 Selector 本质是把“等连接”“等数据”这类被动等待,变成主动轮询就绪事件的策略。它不靠开一堆线程去守着每个连接,而是让一个线程统一盯住成百上千个通道的状态变化,只在真正有事(比如可读、可写、新连接来了)时才出手处理——这才是单线程支撑千级连接的核心逻辑。
Selector 依赖非阻塞通道与操作系统底层多路复用
Selector 不能和阻塞通道一起工作。所有注册的 Channel(如 ServerSocketChannel、SocketChannel)必须先调用 configureBlocking(false) 切换为非阻塞模式。这是前提,否则注册会失败或行为不可控。
Selector 本身不直接轮询,它背后调用的是操作系统提供的高效机制:Linux 下用 epoll,macOS/BSD 用 kqueue,Windows 用 IOCP 模拟。这些系统调用能以 O(1) 或接近常数的时间复杂度监听大量文件描述符,避免传统 select 的遍历开销。
- 每次调用 select(),线程会挂起,内核负责监控所有已注册通道的底层 fd
- 只要任意一个通道的指定事件就绪(例如客户端发来数据,触发 OP_READ),内核就唤醒线程并返回就绪数量
- Selector 将就绪通道封装为 SelectionKey,放入内部的 selectedKeys 集合供程序遍历
SelectionKey 是事件与通道之间的绑定凭证
每个 register() 调用都会生成一个 SelectionKey,它不是一次性票据,而是长期有效的状态句柄。Key 中包含三类关键信息:
- 关联的 Channel:知道该处理哪个连接
- 兴趣集(interestOps):当初注册时说“我想知道哪些事”,比如 OP_READ | OP_WRITE
- 就绪集(readyOps):现在实际发生了什么,比如当前真有数据可读(OP_READ 位被置位)
处理时要始终用 key.isReadable()、key.isAcceptable() 等方法判断就绪状态,而不是直接按兴趣集硬执行——因为兴趣集不会自动更新,就绪集才是实时快照。
事件循环必须手动清理 selectedKeys 集合
每次 select() 返回后,就绪的 SelectionKey 会被添加进 selector.selectedKeys() 这个 Set。这个集合不会自动清空,也不会自动剔除已处理项。
如果不手动调用 keyIterator.remove() 或 selectedKeys.clear(),同一事件可能在下一轮循环中被重复处理;更严重的是,未清理会导致集合持续膨胀,最终引发内存泄漏或逻辑错乱。
- 推荐在遍历 selectedKeys 时使用 Iterator,并在处理完每个 key 后立即调用 remove()
- 不要用 for-each 循环直接操作集合,否则会抛 ConcurrentModificationException
- 即使某个 key 对应的连接已断开,也要先 cancel() 再 remove(),确保资源及时释放
不同事件类型对应不同通道角色与处理逻辑
OP_ACCEPT、OP_CONNECT、OP_READ、OP_WRITE 并非通用,它们严格绑定通道类型和使用阶段:
- OP_ACCEPT 只用于 ServerSocketChannel,表示有新客户端 connect 到来,此时应调用 accept() 创建新 SocketChannel,并将其注册到同一 Selector(通常监听 OP_READ)
- OP_CONNECT 只用于 SocketChannel(客户端),表示 TCP 三次握手完成,之后可立即注册 OP_READ 或 OP_WRITE
- OP_READ 表示通道有数据可读,但不保证一次能读完全部,需循环 read() 直到返回 -1(关闭)或 0(暂时无更多数据)
- OP_WRITE 多数时候无需主动注册——因为 socket 发送缓冲区几乎总是可写的;它主要用于流控场景,比如写半包后因缓冲区满而 write() 返回 0,此时注册 OP_WRITE,等可写再继续










