在Day62中,我们讲了Buffer(缓冲区)和Channel(通道)。
这是NIO的数据载体和传输管道。
但如果只有这两个东西,NIO最多也就是一个支持双向读写的IO而已,还不能解决高并发的问题。
真正让NIO封神的,是第三个组件Selector(选择器)。
他是实现IO多路复用的关键。
今天我们就来了解下这个组件,看看他是怎么用一个线程处理成千上万个连接的。
一、为什么需要Selector?
1.1 BIO的痛点
我们回忆一下传统的BIO服务端模型。
当有一个客户端连接请求时,服务端就需要启动一个线程去处理这个连接。
如果这个客户端连上后不发数据,服务端的这个线程就得阻塞在read()方法上,一直傻等着。
java
// BIO伪代码
while(true) {
Socket socket = serverSocket.accept();
new Thread(() -> {
socket.getInputStream().read();
}).start();
}
如果我们有1万个连接,但只有10个在真正发数据,BIO还是需要创建1万个线程。妥妥的浪费资源。
操作系统在几千个线程之间来回切换上下文,CPU的时间全浪费在切换上了,真正干活的时间很少。
就像我们去餐馆吃饭,每去一桌客人,餐馆就安排一个服务员站我们面前,我们看菜单点菜,点了一个小时,那服务员就在旁边等了一个小时,啥也不干,完全不合常理。
1.2 NIO多路复用
NIO引入了Selector,他的作用就是用一个线程去监控多个Channel的状态。
只有当Channel真正有事做(比如有数据来了、连接建立了)的时候,Selector才会通知线程去处理。如果大家都不说话,线程就歇着,或者去干别的事。
这就好比刚才我们去的餐馆升级了,只雇一个服务员当Selector,在大厅里面巡视,我们招手说要点菜的时候,他就过来服务我们这一桌。服务完继续巡视。哪怕有一千桌客人,只要不是同时点菜,一个Selector就能应付得过来。

二、Selector核心概念
Selector一般称之为选择器,也可以叫多路复用器。
2.1 核心组件关系
要使用Selector,不仅需要Selector本身,还需要把Channel注册到Selector上,并指定我们要监听的事件。
这里面涉及到三个核心的类。
只有继承了SelectableChannel这个类的Channel才能被注册。我们常用的SocketChannel和 ServerSocketChannel都是。
要注意的是FileChannel是不能注册的,因为他不仅是阻塞的,还没有事件这一说。

Selector是IO事件的监视器和调度器。像一个指挥官,同时监视成千上万的通道,但只在有实际工作(事件发生)时才唤醒应用程序线程。
这些是他的核心方法:

SelectionKey是Channel和Selector之间注册关系的凭证。包含了这次注册的所有元信息,当事件发生时,Selector返回的就是这些Key。
相当于是个令牌,当Channel注册到Selector上时,会返回一个SelectionKey,代表Channel和Selector之间的注册关系。
2.2 监听事件
我们在注册Channel的时候,必须告诉Selector,对什么事情感兴趣。SelectionKey定义了四种事件:
OP_CONNECT连接就绪。客户端连接服务端成功。
OP_ACCEPT接收就绪。服务端准备好接收新连接了。
OP_READ读就绪。通道里有数据可读了。
OP_WRITE写就绪。通道的发送缓冲区有空闲,可以写数据了。
三、案例
NIO的代码写起来确实比BIO要麻烦一些,但是逻辑还算清晰。
我们一起看一下一个示例代码,客户端发什么,服务端就回什么。
3.1 服务端
java
package com.lazy.snail.day63.bio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
/**
* @ClassName NioServer
* @Description TODO
* @Author lazysnail
* @Date 2025/11/25 10:45
* @Version 1.0
*/
public class NioServer {
public static void main(String[] args) throws IOException {
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false);
Selector selector = Selector.open();
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("NIO Server started on port 8080...");
while (true) {
if (selector.select() == 0) {
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
handleAccept(key);
} else if (key.isReadable()) {
handleRead(key);
}
}
}
}
private static void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(key.selector(), SelectionKey.OP_READ);
System.out.println("New Client Connected: " + client.getRemoteAddress());
}
private static void handleRead(SelectionKey key) {
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
int len = client.read(buffer);
if (len > 0) {
buffer.flip();
String msg = new String(buffer.array(), 0, len);
System.out.println("Received: " + msg);
client.write(ByteBuffer.wrap(("Echo: " + msg).getBytes()));
} else if (len == -1) {
client.close();
}
} catch (IOException e) {
try {
System.out.println("Client Disconnected unexpectedly.");
client.close();
} catch (IOException ex) {
ex.printStackTrace();
}
}
}
}
我还是画一张流程图来梳理服务端代码的流程:

示例代码可以分成几快来看。
服务器的初始化阶段
首先创建ServerSocketChannel相当于传统BIO中的ServerSocket。
绑定到8080端口,开始监听客户端连接请求。
关键的是调用configureBlocking(false)把通道设置成非阻塞模式,不然后续注册到Selector的时候就会抛出IllegalBlockingModeException。
非阻塞模式下,accept()方法会立即返回,没有新连接时返回null,而不是一直阻塞等待。
接着创建Selector实例,这是整个NIO模型的核心调度器。
然后把服务端通道注册到Selector,并指定监听OP_ACCEPT事件。
相当于是告诉Selector,帮我监视这个端口,有新的客户端连接请求时就通知我。
到这儿,服务器初始化完成,准备进入事件循环。
事件循环
selector.select()方法会阻塞当前线程,直到以下情况之一发生:
至少有一个注册的事件就绪(有新连接或数据到达)。
被其他线程调用selector.wakeup()唤醒。
到达指定的超时时间。
当有事件发生的时候,select()返回就绪的通道数量,如果为0就继续等待。
通过selector.selectedKeys()获取所有就绪事件的SelectionKey集合。
每个SelectionKey包含事件类型和对应的通道信息。
用迭代器遍历集合,因为需要在遍历过程中删除已处理的Key。
处理完每个Key后必须从集合中移除。如果不移除,这个Key会一直留在集合里,下次循环还会被处理。
遍历的时候根据Key的事件类型,调用相应的处理方法。
isAcceptable表示新连接到达,isReadable表示有数据可读。
handleAccept和handleRead方法分别处理上面两种情况。
处理新连接
从Key中获取之前注册的ServerSocketChannel。调用accept()接受客户端连接,返回代表该连接的SocketChannel。
把新的客户端通道也设置成非阻塞模式
把客户端通道注册到同一个Selector,但这次监听的是OP_READ事件。
相当于告诉Selector,这个客户端连接,当有数据可读时通知我。
发现没有,之前Selector只监视服务端通道的OP_ACCEPT事件。
现在Selector同时监视服务端的OP_ACCEPT + 所有客户端的OP_READ事件。
处理数据读取
创建1024字节的缓冲区,read方法把通道数据读入Buffer,非阻塞模式下,可能只读取部分数据,需要多次读取。
buffer.flip()把Buffer从写模式切换成读模式,position重置为0,limit设置为实际数据长度。
然后从Buffer中提取字符串消息,把响应数据写回客户端。
从整个流程来看,只有一个主线程处理所有连接。只在数据真正准备好时才进行IO操作。所有IO操作都是非阻塞的,立即返回。
3.2 客户端
客户端可以用BIO写,也可以用NIO写。为了简单点,我就不写代码了,直接telnet。
我们直接启动Server,用telnet发送消息。

四、NIO的注意事项
4.1 手动移除key
这一点我们在上面的服务端中已经提到过。

同一个事件被重复处理了,可能会死循环,CPU直接飘红。所以必须在处理之前就移除掉。
4.2 设置非阻塞

Selector是基于非阻塞IO设计的,阻塞模式下,一个Channel的IO操作会阻塞整个Selector线程。
4.3 半包和粘包
半包现象:
java
客户端发送: "HelloWorld"
服务端接收: "He" + "lloWorld" // 一个包被拆成多个
粘包现象:
java
客户端发送: "HelloWorld"
服务端接收: "He" + "lloWorld" // 一个包被拆成多个
这个问题跟BIO、NIO没关系,只是NIO基于事件驱动模型让问题更明显了,不得不解决。
问题的本质其实是TCP的缓冲区机制。

我们通过应用程序写入消息 A、B、C。
TCP为了性能(比如Nagle算法),不会我们发一个字节他就走一趟,而是会把几个小包凑成一个大包发出去(粘包),或者我们的包太大,他给我们切碎了发出去(半包)。
接收端通过网卡把数据读到内核缓冲区。这时候,消息A的尾巴可能和消息B的头贴在一起了。
当我们用buffer.read()的时候,我们根本不知道读到的是A的一部分,还是A+B的混合体。
TCP协议只保证字节序的正确,发出去是lazysnail,收到也是lazysnail,但不保证消息边界。
为了解决这个问题,我们就得在应用层定义协议,固定长度、特定分隔符、或者在包头加长度字段都是办法。
其实Netty就帮我们干了这些。
结语
回顾我们的这两篇文章内容,从BIO的每个连接都必须独占一个线程,不管他是不是在真正工作。
到NIO的Selector模型进行高效的调度,动态分配计算资源,只在需要时才进行处理。
其实这种调度的思想,在现代计算中,不管是线程池还是连接池,跟事件驱动架构的配合。
都是在做一件事,就是用最少的资源做最多的事。
我们学东西不要单纯的学API,因为API可能会过时,但是设计思路,思想可能会一直受用。
像Netty框架、Tomcat的NIO连接器、RocketMQ、Kafka的网络通信层都会用到NIO。
带着NIO的设计思路再去学习这些中间件或者技术会快很多。
下一篇预告
待定
如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!