← 返回题目列表

怎么用 Java NIO 写一个非阻塞的 TCP 服务器?ServerSocketChannel、SocketChannel、Selector 怎么配合?

高频 困难 第 14 / 22 题 更新于 2026/08/03
NIO网络编程ServerSocketChannelSelector事件循环

简化版

**用 NIO 写非阻塞 TCP 服务器,核心是三个组件配合:ServerSocketChannel(监听端口、接受连接)、SocketChannel(每个客户端连接)、Selector(多路复用器,一个线程管很多连接)。**基本套路:**① 创建 ServerSocketChannel,绑定端口,设为非阻塞,注册到 Selector 关心 OP_ACCEPT(有新连接);② 死循环里调 selector.select()——它会阻塞直到有「就绪的事件」(有连接来了、或某个连接有数据可读),返回就绪的 SelectionKey 集合;③ 遍历就绪的 key,判断事件类型:isAcceptable(新连接)→ accept() 拿到 SocketChannel、设非阻塞、注册关心 OP_READisReadable(有数据)→ 从 SocketChannel 读数据处理。关键价值:一个线程(一个 Selector)能同时管理成千上万个连接——因为是非阻塞 + 事件驱动,线程只在「真有事件」时才干活,不用「一个连接一个线程」地傻等。这就是 NIO 相比 BIO(阻塞、一连接一线程)能支撑高并发的根本。(Reactor 模式是对这套的进一步抽象,见 reactor-pattern 题。)

详细版

三个核心组件

组件作用
ServerSocketChannel服务端通道:监听端口、accept 接受连接
SocketChannel每个客户端连接的通道:read/write 数据
Selector多路复用器:一个线程监控多个 Channel 的事件
SelectionKeyChannel 注册到 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,否则不停触发可写事件(空转)

各事件处理:acceptssc.accept() 拿 SocketChannel、必须设非阻塞、注册 OP_READ);readsc.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」。