跳到主要内容

Netty 深入学习

· 阅读需 8 分钟

在分布式系统、微服务架构中,网络通信是最基础也是最重要的一部分。
很多高性能框架(如 Dubbo、gRPC、RocketMQ、Elasticsearch 等)底层都依赖 Netty 来完成网络通信。

我最初接触 Netty 时,直接去看它的 API 和示例代码,结果越看越糊涂:为什么要有 EventLoop?为什么连接和读写要分开两组线程?后来才意识到,这些设计不是凭空出现的,而是为了解决传统网络编程的固有问题。绕过这段演进史去学 Netty,等于背答案不看题目。

所以这篇笔记不急着写代码,先把 Netty 背后的 NIO 网络模型设计思想 理清楚。理解了这条主线,Netty 的各种组件就都能对号入座了。

传统网络编程的问题

在早期 Java 网络编程中,大多数程序使用的是 BIO(Blocking IO) 模型。

BIO 的"阻塞"体现在两个地方:accept() 等待新连接时会阻塞,read() 等待数据到达时也会阻塞。一个线程一旦阻塞在某个连接的读写上,就什么别的事都干不了。

例如:

服务器每接入一个客户端连接,就创建一个线程。

一个连接 = 一个线程

这个模型的好处是编程简单直观——每个线程只管自己那一个连接,从头读到尾就行。连接数少的时候完全够用。

但如果连接很多,比如:

1万连接 = 1万个线程

这会带来几个严重问题:

线程资源消耗巨大

线程本身需要内存和调度成本。每个 Java 线程都要占用独立的栈内存,连接数上万时,光是线程栈就能吃掉大量内存,还没算上操作系统维护线程结构的开销。

线程上下文切换开销大

CPU需要频繁在不同线程之间切换。每次切换都要保存和恢复寄存器、刷新缓存,线程数远超 CPU 核心数时,相当一部分 CPU 时间花在了切换本身,而不是处理业务。

系统扩展性差

连接数量一多,系统就容易崩溃。更尴尬的是,大量连接其实是空闲的(比如长连接场景下多数客户端并不总在发数据),却各自占着一个线程干等——资源浪费在了"等待"上。

因此,传统 BIO 并不适合 高并发网络服务

NIO 的核心设计思想

为了解决 BIO 的问题,Java 提出了 NIO(Non-Blocking IO)

NIO 的核心思想非常简单:

用少量线程,管理大量连接

思路的转变在于:既然大部分连接大部分时间都是空闲的,那就不要让线程守着连接干等,而是把"哪个连接有数据了"这件事交给操作系统去监视,线程只在真正有事件发生时才出手处理。这就是 IO 多路复用 的思想,在 Linux 上对应的就是 select/poll/epoll 这类系统调用。

它依赖三个核心组件:

Channel
Buffer
Selector

这三者构成了 NIO 的核心架构。下面逐个来看。

Channel(通道)

Channel 可以理解为:

数据传输的管道

和传统 IO 不同的是:

传统 IO:

输入流 / 输出流

而 Channel:

是双向的

既可以读,也可以写。Channel 本质就是 网络连接的抽象

在 Java NIO 中,常用的实现有 ServerSocketChannel(监听端口、接收连接)和 SocketChannel(代表一条具体的 TCP 连接)。Channel 可以被设置为非阻塞模式——这是它能被 Selector 统一管理的前提:读不到数据时立即返回,而不是卡住线程。

Buffer(缓冲区)

在 NIO 中,所有数据都必须先进入 Buffer

可以理解为:

数据的临时存储区域

数据流程:

网络 -> Buffer -> 程序
程序 -> Buffer -> 网络

Buffer 本质是一块带状态的内存区域,内部靠 position、limit、capacity 几个指针来记录"写到哪了、能读到哪"。读写模式的切换要靠 flip() 这类方法完成,这也是 NIO 原生 API 出了名容易写错的地方之一。Netty 后来自己实现了 ByteBuf,很大程度上就是为了摆脱这套别扭的操作。

Selector(选择器)

Selector 是 NIO 最核心的组件

它的作用是:

用一个线程管理多个 Channel

也就是:

一个线程
监听多个连接

工作方式类似:

事件轮询

具体来说,每个 Channel 注册到 Selector 时会声明自己关心的事件类型:新连接到达(ACCEPT)、数据可读(READ)、可以写出(WRITE)等。线程调用 select() 阻塞等待,一旦有任意 Channel 就绪,就返回就绪的集合,线程挨个处理即可。

流程:

Selector
|
监听多个 Channel
|
哪个 Channel 有事件
|
处理哪个

这样一来,线程的时间全部花在"处理就绪事件"上,而不是"等待某个连接"上——这正是 NIO 相比 BIO 的根本差别。

Reactor 线程模型

Reactor 线程模型是一种 高并发网络服务器常用的设计模式。它的核心思想是:用少量线程,通过事件驱动的方式去处理大量网络连接。线程不再为每一个连接单独创建,而是通过监听网络事件(连接、读、写等),当某个连接有数据到达时再去处理它。这样就避免了大量线程带来的资源消耗和上下文切换问题,大大提升了服务器的并发处理能力。

Reactor 模型通常包含几个关键角色:

1、事件监听(Reactor)

负责监听网络事件,例如新的连接到来、数据可读、数据可写等。

2、连接接入(Acceptor)

当有新的客户端连接时,负责接收连接并注册到后续的处理线程中。

3、事件分发(Dispatcher)

将不同的网络事件分发给对应的处理逻辑。

4、业务处理(Handler)

真正执行业务逻辑,比如解析协议、处理请求、返回结果。

Reactor 模型本身也有几种演进形态:单 Reactor 单线程(一个线程包揽监听和处理)、单 Reactor 多线程(监听单线程、处理交给线程池)、主从 Reactor(连接接入和读写处理分别由不同的 Reactor 线程组负责)。连接量越大,越需要把接入和读写拆开,避免相互拖累。

在 Netty 中,Reactor 模型通常体现为 BossGroup + WorkerGroup 的线程结构,对应的正是主从 Reactor 形态:

  • Boss 线程:负责接收客户端连接
  • Worker 线程:负责处理网络读写和业务逻辑

通过这种设计,Netty 可以用 少量线程处理成千上万的连接,这也是它能够实现高性能网络通信的核心原因。

踩坑与注意

学习和使用这套模型时,有几点值得提前知道:

1)不要在 Worker 线程里做耗时操作。Reactor 模型的前提是事件处理足够快,一个 Worker 线程通常服务多个连接,如果 Handler 里做了慢查询、同步远程调用,这个线程上的其他连接会被一起拖住。耗时业务应该丢给独立的业务线程池。

2)直接用原生 NIO API 很容易写错。Buffer 的读写切换、Selector 的事件处理细节都有不少坑,这也是实际项目几乎都选择 Netty 而不是裸写 NIO 的原因——它把这些复杂性封装掉了。

3)NIO 快在"省线程",不在单次 IO。对于连接数很少的场景,BIO 未必更差;NIO/Reactor 的优势是在大量连接并存时体现出来的。技术选型要看场景。

小结

回顾这条演进主线:

  • BIO 的问题是"一连接一线程",线程被空闲连接白白占用;
  • NIO 用 Channel + Buffer + Selector 实现 IO 多路复用,让少量线程只处理就绪事件;
  • Reactor 模型在此之上定义了事件监听、分发、处理的职责划分;
  • Netty 的 BossGroup + WorkerGroup 就是主从 Reactor 的工程化实现。

理解了这条线,再去看 Netty 的 EventLoop、Pipeline、ByteBuf 这些具体组件,就不会觉得它们是凭空冒出来的设计了。后续再单独展开 Netty 的核心组件与实战用法。

评论 / COMMENTS