在上期博客中,我们提到了 BIO 的局限性:每来一个客户端就开启一条新线程处理读写。这种写法简单直观,但存在线程数量膨胀、上下文切换开销剧增的问题。因此高并发网络编程往往采用 NIO 方案,其核心就在于IO 多路复用 的设计思想,这主要依赖于两大关键组件:Channel(通道) 与 Selector(多路复用器)。
一、Channel
1.概念
Channel 直译是 NIO 中负责数据双向传输的载体,地位等价于 BIO 中的 Socket / ServerSocket。但和传统 IO 不同的是:BIO 的流是单向的,InputStream 只能读、OutputStream 只能写;而 NIO 的Channel是双向的,可以实现全双工通信。
Channel 永远和 Buffer 配合工作:数据从 Channel 读入 Buffer,也从 Buffer 写入 Channel,所有数据交互都通过缓冲区完成。
2.应用
最常用的通道是 ServerSocketChannel 和 SocketChannel,ServerSocketChannel 是服务端监听通道,唯一职责是监听新的客户端连接,本身不参与数据读写。其初始化如下:
java
// 打开服务端通道
ServerSocketChannel server = ServerSocketChannel.open();
// 设置为非阻塞模式
server.configureBlocking(false);
// 绑定监听端口
server.bind(new InetSocketAddress(port));
通过 open() 方法创建实例后,再调用 configureBlocking(false)开启非阻塞模式。这是 NIO 的核心开关:非阻塞模式下, accept() 会立即返回结果,没有新连接就返回 null,不会阻塞线程。阻塞模式下,accept()、read()、connect() 都会一直等待,直到有结果才返回,一个线程只能管一个连接.非阻塞模式下,所有方法立即返回,线程不用原地等待,这才有了单线程管理多个通道的可能。
当有新连接接入时,通过 accept() 方法就能得到对应的数据通道:
java
SocketChannel client = server.accept();
SocketChannel 是真正负责数据读写的通道,对应 BIO 中的 Socket,客户端和服务端各持有一端,通过它完成双向数据交互。
客户端创建并发起连接的代码:
java
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
// 发起连接请求,非阻塞模式下立即返回
socketChannel.connect(new InetSocketAddress(ip, port));
读写操作也都围绕它完成:
java
// 从通道读数据,写入 Buffer
int readByte = client.read(buffer);
// 从 Buffer 读数据,写入通道
client.write(writeBuff);
二、Selector
1.概念
Selector(多路复用器),它允许单个线程同时监控多个 Channel:只有当通道上有就绪的 IO 事件(新连接、数据到达等)时,线程才被唤醒去处理。
2.SelectionKey与事件类型
当 Channel 注册到 Selector 时,需要明确指定要监听的事件类型。SelectionKey 作为注册凭证,完整记录了通道信息、监听事件类型以及附带资源等关键数据。
NIO 定义了 4 种标准网络事件,对应通道生命周期的不同阶段:
- OP_ACCEPT(连接就绪):适用于 ServerSocketChannel,有新客户端发起连接请求,可以执行 accept 时触发;
- OP_CONNECT(连接完成):适用于 SocketChannel,TCP 三次握手完成,连接建立成功时触发;
- OP_READ(读就绪):对端发来数据,通道中有数据可读时触发;
- OP_WRITE(写就绪):发送缓冲区有空闲,可以写入数据时触发。
在代码实现中:
- 服务端监听通道仅注册 OP_ACCEPT 事件,专门处理新连接请求;
- 客户端通道初始注册 OP_CONNECT 事件,待连接建立成功后切换为 OP_READ;
- 服务端接受的客户端通道直接注册OP_READ 事件,监听客户端数据发送。
3.Attachment
注册通道时,我们还可以传入一个Attachment(附加对象),最经典的用法就是绑定一个专属的 ByteBuffer:
java
// 给每个客户端通道绑定专属缓冲区作为附件
client.register(selector, SelectionKey.OP_READ, buffer);
处理事件时,直接通过 key 就能取出对应的缓冲区:
java
ByteBuffer buffer = (ByteBuffer) key.attachment();
如此每个通道都有自己独立的读写缓冲区,多连接之间数据完全隔离,不会串扰。
4.工作流程
Selector 的使用有一套几乎固定的模板,我们结合服务端代码拆解:
第一步:创建 Selector
java
Selector selector = Selector.open();
第二步:将通道注册到 Selector
java
// 服务端监听通道注册,只监听新连接事件
server.register(selector, SelectionKey.OP_ACCEPT);
只有将 Channel 设置为非阻塞模式后才能成功地将其注册到 Selector 上。如果尝试注册一个处于阻塞模式的 Channel,系统会立即抛出 IllegalBlockingModeException 异常。
第三步:阻塞等待就绪事件
java
int count = selector.select();
调用 select() 后,线程会阻塞挂起,直到至少有一个通道的监听事件就绪。返回值 count 表示本次新增的就绪通道数量。
第四步:遍历就绪事件集合
java
Set<SelectionKey> keySet = selector.selectedKeys();
Iterator<SelectionKey> iterator = keySet.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove(); // 必须手动移除已处理事件
// 根据事件类型分发处理
if (key.isAcceptable()) {
// 处理新连接
} else if (key.isReadable()) {
// 处理读事件
}
}
此处需要注意,必须手动调用 iterator.next() 移除已处理的 key。 Selector 不会自动清理 selectedKeys() 集合中的事件,如果不移除,下次 select() 时这个已处理的事件还会留在集合里,导致事件被重复触发,甚至出现死循环空转。
三、完整流程
有了上面的基础,我们把整套回声服务的运行流程串起来,就能彻底理解 Channel 与 Selector 的配合方式。
1. 服务端执行流程
1.初始化阶段:创建 ServerSocketChannel,开启非阻塞,绑定 8888 端口;创建 Selector,将监听通道注册上去,监听 OP_ACCEPT 事件。
java
public static void server() throws Exception {
int port = 8888;
//创建服务器对象
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(port));
//创建 Selector选择器,用来管理所有连接过来的客户端Channel
Selector selector = Selector.open();
//把channel 注册到选择器
//让选择器监听该管道的连接事件
server.register(selector, SelectionKey.OP_ACCEPT);
2.进入事件循环:调用 selector.select() 阻塞等待,没有事件时线程休眠,不消耗 CPU。
3.处理新连接(OP_ACCEPT):
- 有客户端发起连接,事件就绪,线程被唤醒;
- 调用 accept() 得到客户端的 SocketChannel,设置为非阻塞;
- 为该客户端创建专属 ByteBuffer,将通道注册到同一个 Selector,监听 OP_READ 事件,缓冲区作为附件绑定。
4.处理读事件(OP_READ):
- 客户端发送数据,通道读就绪;
- 从 key 中取出附件缓冲区,调用 read() 将数据写入缓冲区;
- 调用 flip() 切换 Buffer 为读模式,取出数据打印;
- 新建写缓冲区,将原消息写入后调用 write() 回发给客户端,完成回声效果。
java
while (true) {
int count = selector.select();
//遍历所有待处理事件
Set<SelectionKey> keySet = selector.selectedKeys();
Iterator<SelectionKey> iterator = keySet.iterator();
while (iterator.hasNext()) {
//按顺序取出所有 key
SelectionKey key = iterator.next();
iterator.remove(); //移除已处理的 key
//根据key 值实现对应处理
if (key.isAcceptable()) {
//获取连接过来的客户端管道
SocketChannel client = (SocketChannel) server.accept();
client.configureBlocking(false);
//给每个连接过来的客户端设置固定缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//选择器管理客户端管道,并且监听该管道的读事件
client.register(selector, SelectionKey.OP_READ, buffer);
System.out.println("客户端连接成功...");
} else if (key.isReadable()) { //服务器读取客户端数据
//获取该管道绑定的缓冲大小
ByteBuffer buffer = (ByteBuffer) key.attachment();
//读数据
SocketChannel client = (SocketChannel) key.channel();
int readByte = client.read(buffer);
if (readByte > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String msg = new String(data);
System.out.println("客户端:" + msg);
buffer.clear();
//先发给自己
ByteBuffer writeBuff = ByteBuffer.allocate(1024);
writeBuff.put(msg.getBytes());
writeBuff.flip();
client.write(writeBuff);
writeBuff.clear();
}
}
}
}
5.循环往复:处理完所有就绪事件后,回到 select() 继续等待下一批事件。
2. 客户端执行流程
客户端设计相比服务端多了一个关键的双线程结构,主要原因在于:
- Scanner.nextLine() 是阻塞方法,会一直等待用户输入;
- 若将其与 Selector 轮询放在同一线程,输入阻塞会导致无法及时接收服务端响应。
因此代码做了线程拆分:
1.初始化阶段:创建 SocketChannel,开启非阻塞,发起连接;创建 Selector,注册 OP_CONNECT 事件。
java
String ip = "127.0.0.1";
int port = 8888;
Selector selector = Selector.open();
//创建客户端的管道
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);//设置非阻塞通信
socketChannel.connect(new InetSocketAddress(ip, port));//绑定IP 和端口
//注册监听器事件
socketChannel.register(selector, SelectionKey.OP_CONNECT);
//缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
2.子线程:事件轮询与接收
- 监听 OP_CONNECT:连接建立完成后,调用 finishConnect() 确认连接,重新注册 OP_READ 事件,绑定专属读缓冲区;
- 监听 OP_READ:服务端回发数据到达时,读取并打印到控制台。
java
new Thread(new Runnable() {
@Override
public void run() {
try {
while (true) {
selector.select();
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isConnectable()) {
SocketChannel client = (SocketChannel) key.channel();
if (client.finishConnect()) {
//缓冲区(子线程接收服务端数据专用)
ByteBuffer buffer = ByteBuffer.allocate(1024);
//在客户端注册读事件
client.register(selector, SelectionKey.OP_READ, buffer);
}
} else if (key.isReadable()) {
ByteBuffer buffer = (ByteBuffer) key.attachment();
SocketChannel client = (SocketChannel) key.channel();
int readByte = client.read(buffer);
if (readByte > 0) {
buffer.flip();
byte[] data = new byte[buffer.remaining()];
buffer.get(data);
String msg = new String(data);
System.out.println("server:" + msg);
}
buffer.clear();
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}).start();
3.主线程:控制台输入发送
- 循环读取用户输入,将内容写入发送缓冲区;
- 调用 flip() 切换模式后,通过 SocketChannel 发送给服务端。
java
Scanner scanner = new Scanner(System.in);
while (true) {
String line = scanner.nextLine();
buffer.put(line.getBytes());
buffer.flip();//将缓冲区切换成读模式
//将数据写入管道
socketChannel.write(buffer);
buffer.clear();
}
3.结果测试
客户端发送信息并收到回传消息:

服务端接受并回传消息:

如此我们初步了解了IO多路复用的设计理念:Channel负责数据传输,Selector则用于事件调度。目前仅测试了服务器回传功能,后续将实现多客户端的广播和私聊功能。