为什么 Netty 能用区区几个线程扛住几十万并发连接,而传统的"一连接一线程"模型在几千连接时就因为线程爆炸而崩溃?答案是 Java NIO 和构建其上的 Reactor 模型。这篇文章从阻塞 IO 的瓶颈讲到多路复用,再到 Reactor 的几种演进形态。

场景:BIO 的线程困境

传统 BIO(Blocking IO)服务端长这样:

1
2
3
4
5
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞,直到有连接
new Thread(() -> handle(socket)).start(); // 每连接一个线程
}

问题在 accept() 和后续的 read() 都是阻塞的。一个线程同一时间只能服务一个连接,read 时即便对端没发数据,线程也得干等着。1 万个连接就需要 1 万个线程,而每个线程栈默认约 1MB,光栈内存就是 10GB;更致命的是上下文切换开销随线程数飙升,CPU 大量时间花在调度而非干活上。这就是 C10K 问题的根源。

机制一:NIO 的三大件

NIO(Non-blocking IO / New IO)用三个核心抽象重构了 IO 模型:

  • Channel(通道):双向的数据通道,对应文件或 socket,可读可写。
  • Buffer(缓冲区):数据不再直接读写流,而是经过 Buffer。它有 positionlimitcapacity 三个指针,读写切换靠 flip()
  • Selector(选择器):一个线程通过 Selector 同时监听多个 Channel 的就绪事件,这就是IO 多路复用

关键在于 Selector 让单线程能管理成千上万个连接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Selector selector = Selector.open();
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false); // 非阻塞
ssc.register(selector, SelectionKey.OP_ACCEPT); // 注册关注的事件

while (true) {
selector.select(); // 阻塞直到有任意 Channel 就绪
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove(); // 必须手动移除,否则下次重复处理
if (key.isAcceptable()) {
SocketChannel ch = ssc.accept();
ch.configureBlocking(false);
ch.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 读数据,注意要处理半包/粘包
}
}
}

机制二:Selector 背后是 epoll

Selector 之所以高效,是因为它底层映射到操作系统的 IO 多路复用机制——Linux 上是 epoll,BSD/macOS 是 kqueue,Windows 是 IOCP/select。以 epoll 为例,对比它和老的 select/poll:

1
2
3
select/poll:每次调用都要把全部 fd 集合从用户态拷到内核态,内核线性扫描所有 fd,O(n)
epoll:用 epoll_ctl 一次性注册 fd 到内核红黑树,内核用回调维护"就绪列表",
epoll_wait 只返回就绪的 fd,复杂度接近 O(就绪数) 而非 O(总连接数)

这就是为什么 NIO 在海量连接、活跃连接占比不高的场景(典型如长连接推送)能碾压 BIO:内核只把真正就绪的 Channel 交给你,线程不会被空转浪费。

需要强调,Java NIO 的多路复用本质上仍是同步 IO——select 告诉你"可以读了",真正的 read 还是你的线程去做的。它不是异步 IO(AIO,由内核完成读写后回调通知)。所以更准确的说法是"非阻塞 + IO 多路复用"。

机制三:Reactor 模型的三种形态

Reactor 是建立在多路复用之上的设计模式,把"事件监听/分发"与"业务处理"分离。它有三种典型演进:

单 Reactor 单线程:一个线程包揽 accept、read、业务处理、write。实现简单(Redis 早期就是这思路),但业务处理一旦耗时就阻塞整个事件循环,无法利用多核。

单 Reactor 多线程:一个 Reactor 线程负责所有 IO 事件的监听和读写,但把耗时的业务处理丢给一个工作线程池。IO 不再被业务拖慢,但单个 Reactor 线程要处理所有连接的读写,在超高并发下成为瓶颈。

主从 Reactor 多线程(Netty 的默认形态)

1
2
3
4
5
6
MainReactor(boss):只负责 accept 新连接
↓ 分发
SubReactor(worker)池:每个 SubReactor 绑定一个线程和一个 Selector,
负责一批连接的 read/write

业务线程池(可选):处理真正耗时的逻辑

Netty 里 bossGroup 对应 MainReactor,workerGroup 对应 SubReactor 池。一个连接一旦被 accept,就被固定分配给某个 worker 的 Selector,该连接的所有 IO 事件后续都在同一个线程处理。这个"线程绑定"设计很关键:它让单连接的处理天然串行、无锁,避免了多线程操作同一连接的竞争。

工程权衡与踩坑

  • 直接内存(堆外)的双刃剑:NIO 鼓励用 DirectByteBuffer,避免内核态与 JVM 堆之间的一次数据拷贝(零拷贝相关优化)。但堆外内存不受 GC 直接管理,靠 Cleaner/虚引用回收,泄漏后表现为进程内存涨但堆很干净,且 -Xmx 限制不到它,排查困难。
  • 空轮询 bug:历史上 JDK 的 epoll 实现有过 Selector 在没有事件时也从 select() 返回、导致 CPU 空转 100% 的 bug。Netty 的做法是统计空轮询次数,超过阈值就重建 Selector 规避。这提醒我们:直接裸用 NIO 写生产级网络框架坑很多,能用 Netty 就别造轮子。
  • 半包与粘包:TCP 是字节流,没有消息边界。一次 read 可能读到半条消息或多条消息黏在一起,必须自己用长度字段、分隔符或固定长度做拆包。这是新手用 NIO 最容易翻车的地方。
  • 不要在 IO 线程里跑阻塞逻辑:在 Reactor/worker 线程里执行数据库查询、RPC 等阻塞调用,会卡住这个线程负责的所有连接。耗时操作必须切到独立业务线程池。
  • selectedKeys 必须手动 remove:Selector 不会自动清理已就绪集合,忘了 it.remove() 会导致同一事件被反复处理。

小结

从 BIO 的"一连接一线程"到 NIO 的"一线程多连接",本质是用操作系统的 IO 多路复用(epoll/kqueue)把"等待就绪"这件事交给内核,线程只在真正有数据时才工作。Reactor 模型在此之上把事件分发与业务处理解耦,主从多线程形态既榨干多核又保持单连接处理的无锁串行。理解这条从内核到框架的链路,才能真正读懂 Netty,也才能在高并发网络编程里不踩坑。