Selector 多路复用器
Selector 是 NIO 实现单线程管理多连接的关键。它像一个观察者,同时盯着多个 Channel,一旦某个 Channel 准备好读写,就通知应用程序。
想象一个图书馆管理员:他不需要为每本书安排一个管理员,而是坐在前台,等待有人来借书或还书。当某个读者需要服务时,管理员才去处理,处理完继续等待。这个管理员就是 Selector。
Selector 的工作原理
flowchart TB
subgraph Selector
S["Selector\n管理多个 SelectionKey"]
end
subgraph Channel 集合
C1["SocketChannel A\n可读"]
C2["SocketChannel B\n可写"]
C3["SocketChannel C\n无事件"]
end
subgraph SelectionKey
K1["Key A\nOP_READ"]
K2["Key B\nOP_WRITE"]
K3["Key C\n无事件"]
end
C1 --> S
C2 --> S
C3 --> S
S --> K1
S --> K2
S --> K3
应用程序通过 Selector 监听多个 Channel 的就绪状态。当调用 select() 时,如果没有任何 Channel 就绪,当前线程会阻塞;有 Channel 就绪后,select 返回,应用程序遍历就绪的 Channel 进行处理。
SelectionKey:Channel 与 Selector 的绑定
当 Channel 注册到 Selector 时,会创建一个 SelectionKey。SelectionKey 包含了 Channel 和 Selector 的关联信息,以及感兴趣的事件类型。
Channel
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
Selector selector = Selector.open();
// 注册 Channel,返回 SelectionKey
SelectionKey key = serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("key.interestOps() = " + key.interestOps());
// 输出: key.interestOps() = 16 (OP_ACCEPT)
事件类型
SelectionKey 定义了四种事件类型:
可以组合多个事件:
// 同时监听可读和可写
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
channel.register(selector, interestSet);
SelectionKey 的常用方法
// 获取关联的 Channel
SocketChannel channel = (SocketChannel) key.channel();
// 获取关联的 Selector
Selector selector = key.selector();
// 检查事件类型
if (key.isReadable()) {
// 处理可读事件
}
if (key.isWritable()) {
// 处理可写事件
}
if (key.isAcceptable()) {
// 处理可接受事件
}
if (key.isConnectable()) {
// 处理连接完成事件
}
// 附加对象(可用于存储业务数据)
key.attach(new Object());
Object obj = key.attachment();
// 或在注册时附加
SelectionKey key = channel.register(selector, ops, new Object());
选择操作:select / selectNow / select(timeout)
Selector 提供了三种选择操作:
select():阻塞等待
int count = selector.select(); // 阻塞,直到至少有一个 Channel 就绪
最常用的方法。如果没有 Channel 就绪,调用线程会一直阻塞。可以被 wakeup() 中断。
selectNow():非阻塞
int count = selector.selectNow(); // 立即返回,不阻塞
立即返回当前就绪的 Channel 数量。如果没有任何 Channel 就绪,返回 0。
select(timeout):超时等待
int count = selector.select(1000); // 阻塞最多 1 秒
指定超时时间后阻塞。如果超时时间内有 Channel 就绪,返回就绪数量;如果超时,返回 0。
Selector 的完整使用流程
Selector
public class SelectorDemo {
public static void main(String[] args) throws IOException {
// 1. 打开 Selector
Selector selector = Selector.open();
// 2. 打开 ServerSocketChannel 并注册
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动,监听端口 8080...");
// 3. 事件循环
while (true) {
// 阻塞等待就绪事件
selector.select();
// 获取所有就绪的 SelectionKey
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
if (key.isAcceptable()) {
// 处理新连接
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
System.out.println("新连接: " + client.getRemoteAddress());
}
if (key.isReadable()) {
// 处理可读事件
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int read = client.read(buffer);
if (read > 0) {
buffer.flip();
System.out.println("收到数据: " + new String(buffer.array(), 0, buffer.limit()));
// 响应
buffer.clear();
buffer.put("ACK".getBytes());
buffer.flip();
client.write(buffer);
} else if (read == -1) {
client.close();
}
}
// 重要:处理完后移除
iterator.remove();
}
}
}
}
Selector 的三大缺陷
虽然 Selector 解决了 BIO 的线程开销问题,但它本身也有一些局限性:
缺陷一:最大文件描述符限制
在某些系统上,Selector 底层使用的 select() 系统调用有最大 fd 数量限制(Linux 上默认 1024)。虽然 epoll 没有这个限制,但 Java NIO 在某些场景下可能退化到 select。
缺陷二:遍历开销(针对 select/poll)
如果使用 select() 或 poll() 实现,Selector 返回就绪集合后,应用程序需要遍历所有注册的 Channel 来确认哪些就绪。这是 O(n) 的时间复杂度。
缺陷三:内核/用户空间数据拷贝
无论 select() 还是 poll(),都需要将文件描述符集合从用户空间拷贝到内核空间,再拷贝回来。这是额外的开销。
flowchart LR
subgraph 用户空间
U["应用程序"]
end
subgraph 内核空间
K["内核"]
end
U -->|FD 集合| K
K -->|就绪的 FD| U
epoll 通过红黑树和就绪链表解决了这些问题,但这是底层实现,Java NIO 对上层应用透明。
Selector 的性能调优
wakeup():强制唤醒
selector.wakeup() 可以强制让阻塞的 select() 方法立即返回。这在需要从其他线程关闭 Selector 时很有用。
从其他线程唤醒
Selector selector = Selector.open();
// ...
// 在另一个线程中
new Thread(() -> {
// 做些准备工作
selector.wakeup(); // 唤醒阻塞的 select()
}).start();
int count = selector.select(); // 如果上面线程没准备好,这里就会被打断
close():关闭 Selector
关闭 Selector 会取消所有注册的 Channel 的注册关系。
selector.close();
// 所有注册的 SelectionKey 都失效
keys() vs selectedKeys()
// keys(): 所有注册的 Channel,包括未就绪的
Set<SelectionKey> allKeys = selector.keys();
// selectedKeys(): 只有就绪的 Channel
Set<SelectionKey> readyKeys = selector.selectedKeys();
通常只使用 selectedKeys(),因为只有就绪的 Channel 才需要处理。
Selector 与 NIO 的关系
Selector 是 NIO 实现高性能 I/O 的核心。没有 Selector,NIO 就退化为"非阻塞 I/O + 轮询",反而比 BIO 更差。
flowchart TD
A["BIO\n一连接一线程"] --> B["线程开销大"]
B --> C["无法应对高并发"]
D["NIO - 无 Selector\n非阻塞 + 轮询"] --> E["CPU 浪费在无效轮询"]
F["NIO + Selector\n事件驱动"] --> G["单线程管理万级连接"]
style A fill:#ff6b6b
style D fill:#feca57
style F fill:#1dd1a1
本章小结
Selector 是 NIO 实现 I/O 多路复用的核心组件:
- SelectionKey 关联 Channel 与 Selector,包含感兴趣的事件类型
select() 阻塞等待,直到有 Channel 就绪
selectNow() 非阻塞,立即返回
- Selector 解决了 BIO 的线程开销问题,但 select/poll 本身有 O(n) 的遍历开销
下一章我们将深入学习 I/O 多路复用的底层实现,理解 select/poll/epoll/kqueue 的设计差异。
延伸思考
为什么 Selector 要设计 keys.remove() 或 iterator.remove() 这个步骤?
因为 selectedKeys 是一个 Set,不是 List。每次 select 返回后,应用程序处理完一个 key,必须显式移除,否则下次循环会重复处理。
这是一个常见的问题源:如果忘记移除,代码可能在单个事件上执行多次。Netty 等框架在更高层次封装了这个逻辑,避免了这个问题。