怎么用 Java NIO 写一个非阻塞的 TCP 服务器?ServerSocketChannel、SocketChannel、Selector 怎么配合?
简化版
**用 NIO 写非阻塞 TCP 服务器,核心是三个组件配合:ServerSocketChannel(监听端口、接受连接)、SocketChannel(每个客户端连接)、Selector(多路复用器,一个线程管很多连接)。**基本套路:**① 创建 ServerSocketChannel,绑定端口,设为非阻塞,注册到 Selector 关心 OP_ACCEPT(有新连接);② 死循环里调 selector.select()——它会阻塞直到有「就绪的事件」(有连接来了、或某个连接有数据可读),返回就绪的 SelectionKey 集合;③ 遍历就绪的 key,判断事件类型:isAcceptable(新连接)→ accept() 拿到 SocketChannel、设非阻塞、注册关心 OP_READ;isReadable(有数据)→ 从 SocketChannel 读数据处理。关键价值:一个线程(一个 Selector)能同时管理成千上万个连接——因为是非阻塞 + 事件驱动,线程只在「真有事件」时才干活,不用「一个连接一个线程」地傻等。这就是 NIO 相比 BIO(阻塞、一连接一线程)能支撑高并发的根本。(Reactor 模式是对这套的进一步抽象,见 reactor-pattern 题。)
详细版
三个核心组件:
| 组件 | 作用 |
|---|---|
| ServerSocketChannel | 服务端通道:监听端口、accept 接受连接 |
| SocketChannel | 每个客户端连接的通道:read/write 数据 |
| Selector | 多路复用器:一个线程监控多个 Channel 的事件 |
| SelectionKey | Channel 注册到 Selector 后的「令牌」,表示关心/就绪的事件 |
四种事件(SelectionKey 的 interest set):
| 事件 | 常量 | 含义 |
|---|---|---|
| 接受连接 | OP_ACCEPT | 有新连接进来(ServerSocketChannel 关心) |
| 连接建立 | OP_CONNECT | 连接完成(客户端关心) |
| 可读 | OP_READ | 有数据可读 |
| 可写 | OP_WRITE | 可以写数据(缓冲区有空间) |
// NIO 非阻塞 TCP 服务器骨架
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false); // 设为非阻塞
ssc.register(selector, SelectionKey.OP_ACCEPT); // 注册关心「新连接」
while (true) {
selector.select(); // 阻塞,直到有就绪事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // ★ 处理完必须移除
if (key.isAcceptable()) { // 新连接
SocketChannel sc = ssc.accept(); // 接受连接
sc.configureBlocking(false);
sc.register(selector, SelectionKey.OP_READ); // 注册关心「可读」
} else if (key.isReadable()) { // 有数据可读
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = sc.read(buf); // 非阻塞读
if (n == -1) { sc.close(); } // 客户端关闭
else { /* 处理 buf 里的数据 */ }
}
}
}
⚠️ NIO 服务器有几个「一步不能少」的细节:① 处理完的 key 必须
it.remove()——selectedKeys()里的 key 处理后要手动移除,否则下次select()返回时它还在集合里,会被重复处理(这是最常见的 bug)。② Channel 必须设configureBlocking(false)——注册到 Selector 的 Channel 必须是非阻塞的,否则抛异常。③read()返回-1表示对端关闭,要close()这个 channel(并取消它的 key),否则会不停触发可读事件(死循环空转)。④ 写数据的处理更复杂——非阻塞write可能没写完(内核缓冲区满了返回),要注册OP_WRITE等可写了再写剩下的(写完记得取消OP_WRITE,否则会不停触发可写事件空转)。这些细节就是为什么「手写 NIO 很容易出 bug」,也是 Netty 存在的意义——它帮你处理好了这些坑。
完整版教学
一、为什么 NIO 能一个线程管很多连接
先理解 NIO 相比 BIO 的根本优势:
BIO(阻塞式,一连接一线程):
每个客户端连接,分配一个线程处理
线程调 read() 会"阻塞"——没数据就一直等
→ 1 万个连接 = 1 万个线程(大部分在阻塞等待,浪费)
→ 线程多,内存和调度开销大,撑不住高并发(C10K 问题)
NIO(非阻塞 + 多路复用):
非阻塞:read() 没数据立即返回(不傻等)
多路复用(Selector):一个线程用 Selector 同时"盯着"很多连接
Selector 告诉你"哪些连接有事件(可读/新连接)"
线程只处理"真有事件"的连接,其余不管
→ 1 个线程(1 个 Selector)能管成千上万个连接
→ 线程少,只在有事件时干活,高并发下高效
核心:把"一个线程盯一个连接(阻塞等)"
变成"一个线程盯一批连接(有事件才处理)"
→ 这是 NIO 支撑高并发的根本
NIO 相比 BIO 的根本优势是「一个线程管很多连接」——BIO 阻塞式一连接一线程(read 阻塞等,1 万连接 1 万线程、大部分在傻等、撑不住高并发 C10K);NIO 非阻塞(read 没数据立即返回)+ 多路复用(Selector 一个线程盯很多连接、只处理有事件的)。核心是「把’一个线程盯一个连接傻等’变成’一个线程盯一批连接有事件才处理’」——这是 NIO 支撑高并发的根本。理解「BIO 一连接一线程阻塞等撑不住高并发、NIO 非阻塞+Selector 多路复用一个线程管很多连接只处理有事件的、这是 NIO 支撑高并发的根本」,就理解了 NIO 服务器的意义。
二、三个核心组件
理解 NIO 网络编程的三个核心组件的分工:
ServerSocketChannel(服务端通道):
- 监听端口(bind)
- accept() 接受新连接,返回一个 SocketChannel
- 关心 OP_ACCEPT 事件(有新连接来)
- 一个服务器一个(或几个)
SocketChannel(连接通道):
- 代表一个客户端连接
- read(buffer)/write(buffer) 读写数据(配合 ByteBuffer)
- 关心 OP_READ(有数据可读)/OP_WRITE(可以写)
- 每个客户端连接一个
Selector(多路复用器):
- 一个线程用它监控多个 Channel
- select():阻塞,直到有 Channel 就绪(有事件)
- selectedKeys():拿到就绪的 SelectionKey 集合
- 是"一个线程管很多连接"的关键
关系:
ServerSocketChannel 和 SocketChannel 都注册到 Selector
Selector 统一监控它们的事件
线程围着 Selector 转(事件循环)
三个核心组件:ServerSocketChannel(服务端通道,bind 监听端口、accept 接受连接返回 SocketChannel、关心 OP_ACCEPT)、SocketChannel(每个客户端连接,read/write 数据、关心 OP_READ/OP_WRITE)、Selector(多路复用器,一个线程监控多个 Channel,select() 阻塞到有就绪、selectedKeys() 拿就绪的 key)。关系:两种 Channel 都注册到 Selector,Selector 统一监控,线程围着 Selector 转(事件循环)。理解「三组件:ServerSocketChannel(监听/accept/OP_ACCEPT)、SocketChannel(每连接一个/read-write/OP_READ)、Selector(一线程监控多 Channel/select 阻塞到就绪)、都注册到 Selector 统一监控」,就理解了三个组件的分工。
三、SelectionKey 与四种事件
SelectionKey 是 Channel 注册到 Selector 后的「令牌」,理解它和四种事件:
SelectionKey:
Channel 注册到 Selector 时返回一个 SelectionKey
它代表"这个 Channel 在这个 Selector 上的注册关系"
记录:
- interest set(关心哪些事件):channel.register(sel, OP_READ)
- ready set(当前就绪了哪些事件):select() 后可查
- attachment(附加对象,可存这个连接的上下文/状态)
四种事件(interest ops):
OP_ACCEPT(16):有新连接 → ServerSocketChannel 关心
OP_CONNECT(8):连接建立完成 → 客户端 SocketChannel 关心
OP_READ(1):有数据可读 → 读数据时关心
OP_WRITE(4):可以写(内核缓冲区有空间)→ 写不完时关心
判断就绪的事件:
key.isAcceptable() 是不是 OP_ACCEPT 就绪
key.isReadable() 是不是 OP_READ 就绪
key.isWritable() 是不是 OP_WRITE 就绪
key.isConnectable() 是不是 OP_CONNECT 就绪
一个 key 可能同时就绪多个事件,要分别判断处理
SelectionKey 是 Channel 注册到 Selector 后的「令牌」——记录 interest set(关心哪些事件)、ready set(当前就绪哪些)、attachment(附加对象,存连接上下文)。四种事件:OP_ACCEPT(新连接,ServerSocketChannel 关心)、OP_CONNECT(连接建立,客户端关心)、OP_READ(可读)、OP_WRITE(可写)。用 key.isAcceptable()/isReadable()/isWritable() 判断就绪的事件(一个 key 可能同时就绪多个)。attachment 很有用(存这个连接的读写缓冲/状态)。理解「SelectionKey 是注册令牌(interest set 关心的事件/ready set 就绪的/attachment 存上下文)、四事件 OP_ACCEPT/CONNECT/READ/WRITE、用 isXxx 判断就绪」,就掌握了 SelectionKey 和事件。
四、事件循环:select 的套路
NIO 服务器的核心是「事件循环」——围着 select() 转:
事件循环骨架:
while (true) {
selector.select(); // ① 阻塞,直到有事件就绪
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // ② ★ 处理完必须移除
if (key.isAcceptable()) { // ③ 分派处理
handleAccept(key);
} else if (key.isReadable()) {
handleRead(key);
} else if (key.isWritable()) {
handleWrite(key);
}
}
}
关键点:
① select() 阻塞等事件——没事件时线程"睡着",不占 CPU
select(timeout) 可设超时;selectNow() 非阻塞立即返回
② selectedKeys() 是"就绪的" key 集合
③ ★ it.remove() 必须调——Selector 不会自动清空 selectedKeys
不 remove,下次 select 后这个 key 还在,被重复处理(经典 bug)
④ 根据事件类型分派到不同处理逻辑
这个"select → 遍历就绪 key → 分派处理"的循环
就是 Reactor 模式的核心(见 reactor-pattern 题)
NIO 服务器的核心是「事件循环」——while(true) { select(); 遍历 selectedKeys 分派处理 }。关键点:① select() 阻塞等事件(没事件线程睡着不占 CPU,select(timeout)/selectNow() 可变体);② selectedKeys() 是就绪的 key 集合;③ 必须 it.remove()(Selector 不自动清空 selectedKeys,不 remove 会重复处理——经典 bug);④ 按事件类型分派。这个「select → 遍历就绪 → 分派」的循环就是 Reactor 模式的核心(见 reactor-pattern 题)。理解「事件循环:select 阻塞等事件(没事件不占 CPU)→遍历 selectedKeys→必须 it.remove(不 remove 重复处理经典 bug)→按事件分派、这就是 Reactor 核心」,就掌握了事件循环。
五、处理各事件:accept、read、write
具体看每种事件怎么处理,尤其是 write 的复杂性:
处理 accept(新连接):
SocketChannel sc = ssc.accept(); // 接受连接
sc.configureBlocking(false); // ★ 必须设非阻塞
sc.register(selector, OP_READ); // 注册关心「可读」
→ 新连接注册进来,之后它有数据就会触发 OP_READ
处理 read(可读):
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = ByteBuffer.allocate(1024);
int n = sc.read(buf);
if (n == -1) { // ★ -1 = 对端关闭
key.cancel(); sc.close(); // 关闭连接、取消 key
} else if (n > 0) {
buf.flip();
// 处理数据(解析、业务逻辑、可能要回写)
}
→ 不处理 -1 会不停触发可读事件(死循环空转)
处理 write(可写,复杂):
非阻塞 write 可能没写完(内核缓冲区满了):
int written = sc.write(buf);
if (buf.hasRemaining()) { // 没写完
key.interestOps(key.interestOps() | OP_WRITE); // 注册关心可写
// 把剩余 buf 存到 attachment,等 OP_WRITE 就绪再写
} else {
key.interestOps(key.interestOps() & ~OP_WRITE); // 写完了取消 OP_WRITE
}
→ ★ 写完必须取消 OP_WRITE,否则不停触发可写事件(空转)
各事件处理:accept(ssc.accept() 拿 SocketChannel、必须设非阻塞、注册 OP_READ);read(sc.read(buf),n==-1 是对端关闭要 close+cancel,否则不停触发可读事件空转;n>0 处理数据);write(复杂)——非阻塞 write 可能没写完(内核缓冲区满),要注册 OP_WRITE、把剩余数据存 attachment、等可写再写,写完必须取消 OP_WRITE(否则不停触发可写事件空转)。理解「accept(设非阻塞+注册 OP_READ)、read(n=-1 对端关闭要 close 否则空转)、write 复杂(可能没写完要注册 OP_WRITE 等可写再写、写完取消 OP_WRITE 否则空转)」,就掌握了各事件的处理和坑。
六、手写 NIO 的坑与 Netty
总结手写 NIO 的复杂性,以及为什么用 Netty:
手写 NIO 容易出的坑(前面提到的):
① 忘了 it.remove() → key 重复处理
② Channel 没设非阻塞 → 注册抛异常
③ read 返回 -1 不处理 → 死循环空转
④ write 没写完的处理复杂(OP_WRITE 的注册/取消)
⑤ 空轮询 bug(Selector 的 epoll 空轮询,见 netty 相关题)
⑥ 半包/粘包(TCP 是字节流,一次 read 可能读到半个消息或多个消息)
⑦ 异常处理、连接管理、线程模型...
→ 手写 NIO 正确且高效非常难,坑多
Netty 的价值:
Netty 封装了 NIO,帮你处理好了所有这些坑:
- 事件循环(EventLoop)
- 半包粘包(解码器)
- 写缓冲、背压
- 空轮询 bug 的规避
- 线程模型(主从 Reactor)
→ 所以生产网络编程用 Netty,不手写 NIO
结论:
理解 NIO 三组件 + 事件循环 = 理解网络编程和 Netty 的基础
但生产中用 Netty(它把 NIO 的坑都填了)
手写 NIO 容易出的坑:忘 it.remove()、Channel 没设非阻塞、read 返回 -1 不处理空转、write 没写完的处理复杂、Selector 空轮询 bug、半包粘包(TCP 字节流一次 read 可能读半个或多个消息)、异常/连接/线程管理。手写 NIO 正确且高效非常难。Netty 的价值是封装 NIO、帮你处理好所有坑(事件循环、半包粘包解码器、写缓冲背压、空轮询规避、线程模型)——所以生产网络编程用 Netty 不手写 NIO。理解 NIO 三组件+事件循环是理解网络编程和 Netty 的基础,但生产用 Netty。理解「手写 NIO 坑多(忘 remove/没设非阻塞/read -1 不处理/write 没写完/空轮询/半包粘包)、正确高效很难、Netty 封装 NIO 填了所有坑、生产用 Netty」,就理解了手写 NIO 的复杂性和 Netty 的意义。
记忆钩子:「NIO 非阻塞 TCP 服务器三组件:ServerSocketChannel(监听端口/accept 接受连接/OP_ACCEPT)、SocketChannel(每个客户端连接/read-write/OP_READ)、Selector(一个线程监控多 Channel/select 阻塞到有就绪);SelectionKey 是注册令牌(interest set 关心的事件/ready set 就绪的/attachment 存上下文),四事件 OP_ACCEPT/CONNECT/READ/WRITE;事件循环:while(true){select()阻塞→遍历 selectedKeys→★必须 it.remove(不 remove 重复处理经典 bug)→按事件分派};坑:Channel 必须 configureBlocking(false)、read=-1 是对端关闭要 close(否则空转)、write 没写完要注册 OP_WRITE 写完取消;NIO 一个线程管很多连接是支撑高并发的根本(vs BIO 一连接一线程);手写坑多(还有半包粘包/空轮询)生产用 Netty」。
七、常见误区与追问
- 误区:NIO 服务器要给每个连接分配一个线程。 恰恰相反——NIO 的核心价值是一个线程(一个 Selector)管很多连接:非阻塞 + 多路复用让线程只处理「真有事件」的连接,不用一连接一线程;BIO 才是一连接一线程(阻塞等,撑不住高并发)。
- 误区:处理完 SelectionKey 不用管。 必须 it.remove()——Selector 不会自动清空 selectedKeys 集合,处理完的 key 不移除,下次 select() 返回时它还在集合里、会被重复处理;这是手写 NIO 最常见的 bug。
- 误区:注册到 Selector 的 Channel 可以是阻塞的。 不行——必须先 configureBlocking(false) 设为非阻塞,才能注册到 Selector(否则抛 IllegalBlockingModeException);非阻塞是多路复用的前提(阻塞 Channel 没法配合 Selector 的事件驱动)。
- 误区:read 返回 -1 不用特殊处理。 -1 表示对端关闭了连接——必须 close 这个 SocketChannel 并 cancel 它的 SelectionKey;否则这个已关闭的连接会不停触发 OP_READ 可读事件,导致事件循环死循环空转、CPU 飙高。
- 追问:为什么处理完 SelectionKey 必须调 it.remove()? 因为 Selector 的 selectedKeys() 集合不会自动清空——select() 每次把就绪的 key 加进这个集合,但处理后不会移除;如果你不手动 it.remove(),下次 select() 返回时上次的 key 还在集合里,会被当成新的就绪事件重复处理(可能处理已经处理过的、甚至已关闭的连接),是经典的 NIO bug。
- 追问:NIO 的 write 为什么比 read 复杂? 非阻塞 write 可能「没写完」——当内核发送缓冲区满时,write 只写入部分数据就返回(返回实际写入的字节数);这时要注册 OP_WRITE 事件、把剩余数据暂存(attachment),等 Selector 通知「可写」(缓冲区有空间了)再继续写剩下的;而且写完后必须取消 OP_WRITE 关注,否则会不停触发可写事件(缓冲区一直有空间)导致空转。
- 追问:既然 NIO 这么复杂,为什么还要理解它、而不直接用 Netty? 理解 NIO 的三组件(ServerSocketChannel/SocketChannel/Selector)和事件循环(select → 遍历就绪 → 分派)是理解网络编程本质和 Netty 工作原理的基础——Netty 就是在这套 NIO 之上封装的(EventLoop 就是事件循环、解决了半包粘包/空轮询/写缓冲等坑);理解了 NIO 才能理解 Netty 的线程模型、ChannelPipeline 等;生产中用 Netty,但面试和排查问题要懂底层的 NIO。
八、加强记忆
用 NIO 写非阻塞 TCP 服务器,核心是三个组件配合:ServerSocketChannel(服务端通道:bind 监听端口、accept 接受连接返回 SocketChannel、关心 OP_ACCEPT)、SocketChannel(每个客户端连接:read/write 数据、关心 OP_READ/OP_WRITE)、Selector(多路复用器:一个线程监控多个 Channel,select() 阻塞到有就绪事件)。SelectionKey 是 Channel 注册到 Selector 的「令牌」(interest set 关心的事件、ready set 就绪的、attachment 存连接上下文),四种事件 OP_ACCEPT/OP_CONNECT/OP_READ/OP_WRITE(用 isAcceptable/isReadable 等判断)。事件循环:while(true) { select() 阻塞等事件 → 遍历 selectedKeys → 按事件分派处理 }。关键坑:① 处理完 key 必须 it.remove()(不移除会重复处理——经典 bug);② Channel 必须 configureBlocking(false)(否则注册抛异常);③ read() 返回 -1 是对端关闭,要 close+cancel(否则空转);④ 非阻塞 write 可能没写完,要注册 OP_WRITE 等可写再写、写完取消(否则空转)。NIO 一个线程管很多连接(非阻塞+多路复用,只处理有事件的)是支撑高并发的根本(vs BIO 一连接一线程阻塞等)。手写 NIO 坑多(还有半包粘包、空轮询),生产用 Netty(封装 NIO 填了所有坑)。这套「select → 遍历就绪 → 分派」就是 Reactor 模式的核心。一句话「NIO TCP 服务器三组件:ServerSocketChannel(监听/accept/OP_ACCEPT)+SocketChannel(每连接/read-write/OP_READ)+Selector(一线程监控多 Channel/select 阻塞);事件循环 select→遍历 selectedKeys→必须 it.remove(不 remove 重复处理)→按事件分派;Channel 设非阻塞、read=-1 close、write 没写完注册 OP_WRITE;一线程管很多连接是高并发根本,手写坑多生产用 Netty」。