在前两篇博客中,我们用 NIO 原生 API 和 Netty 各写了一个最简单的 C/S Demo。不知道你有没有注意到,不管是服务端还是客户端,第一行代码几乎都是一样的:
new NioEventLoopGroup();
这一行看起来平平无奇,但它是整个 Netty 的"发动机"。今天我们就来拆开它,看看 Netty 到底在背后做了什么。
一、从 new NioEventLoopGroup() 说起
先问自己几个问题:
- 不传参数的时候,到底创建了几个线程?
- NioEventLoopGroup 和 NioEventLoop 是什么关系?
- Selector 是在哪里创建的?为什么 Netty 要替换 JDK 的 Selector 实现?
- NioEventLoop 到底是不是一个线程?
带着这些问题,我们一步步往下看。
二、默认线程数:CPU 核心数 × 2
当你写下:
NioEventLoopGroup group = new NioEventLoopGroup();
Netty 会调用无参构造函数,最终走到这里(简化后):
public NioEventLoopGroup() {this(0);
}
这里的 0 并不是"创建 0 个线程",而是一个特殊值,表示"使用默认值"。继续往下追:
public NioEventLoopGroup(int nThreads) {this(nThreads, null);
}public NioEventLoopGroup(int nThreads, Executor executor) {super(nThreads, executor);
}
最终在父类 MultithreadEventExecutorGroup 中,你会看到这样一段逻辑:
static {DEFAULT_EVENT_LOOP_THREADS =Math.max(1, Runtime.getRuntime().availableProcessors() * 2);
}protected MultithreadEventExecutorGroup(int nThreads, Executor executor) {if (nThreads <= 0) {nThreads = DEFAULT_EVENT_LOOP_THREADS;}// ...后续初始化逻辑
}
所以真相大白:
默认线程数 = CPU 核心数 × 2,且至少为 1。
availableProcessors() 返回 1,那么 1 × 2 = 2,最终线程数就是 2。但如果是 0 核(极端情况),Math.max(1, 0) 保证至少是 1。所以严格来说,不是"一定是 2 倍",而是"最多 2 倍,最少 1"。为什么是 ×2 而不是 ×4 或者干脆等于核心数?这其实是 Netty 作者基于大量实践得出的经验值——每个核心分配 2 个 EventLoop 线程,可以在 I/O 等待和 CPU 计算之间取得较好的平衡。当然,你也可以通过构造函数手动指定:
// 只创建 4 个线程
new NioEventLoopGroup(4);
三、NioEventLoopGroup 的构造过程
确定了线程数之后,NioEventLoopGroup 做了什么?核心逻辑可以简化为以下三步:
1️⃣ 创建 NioEventLoop 数组
children = new EventExecutor[nThreads];
这里的 children 就是 NioEventLoop 的数组。注意,EventExecutor 是 NioEventLoop 的父类,所以类型声明为 EventExecutor[]。
2️⃣ 逐个初始化 NioEventLoop
for (int i = 0; i < nThreads; i++) {children[i] = new NioEventLoop(this, executor, selectorProvider);
}
每一个 NioEventLoop 在构造时都会:
- 保存对 parent(即 NioEventLoopGroup)的引用
- 创建一个 Selector(通过
selectorProvider.openSelector()) - 初始化一个任务队列(
taskQueue) - 绑定一个 Executor(用于启动线程)
3️⃣ 注册关闭钩子
NioEventLoopGroup 还会注册一个 JVM 关闭钩子(shutdown hook),确保在程序退出时优雅关闭所有 NioEventLoop。这一块我们先不展开,后面专门讲 Netty 的优雅关闭机制时再细说。
NioEventLoopGroup 本质上就是一个 NioEventLoop 的容器。它不自己干活,而是把活分给里面的每一个 NioEventLoop。
四、NioEventLoop 的本质
既然 NioEventLoop 是真正干活的,那它到底是个什么东西?先看继承链:
Object→ ScheduledExecutorService→ ExecutorService→ Executor→ EventLoop (接口)→ SingleThreadEventExecutor (抽象类)→ NioEventLoop
关键点在于 SingleThreadEventExecutor——从名字就能看出来,它是一个单线程执行器。也就是说:
NioEventLoop = 一个线程 + 一个 Selector + 一个任务队列
这三样东西是 NioEventLoop 的全部家当:
| 组件 | 作用 |
|---|---|
| 线程(Thread) | 真正执行代码的人,NioEventLoop 启动后线程就开始跑 |
| Selector | 监听注册在其上的所有 Channel 的 I/O 事件 |
| 任务队列(taskQueue) | 存放待执行的异步任务,比如 channel.writeAndFlush() 底层就是往队列里扔任务 |
用一个生活中的类比:
NioEventLoop 就像一家小店的老板——一个人,一张桌子(Selector),一个待办清单(taskQueue)。客人(Channel)有需求就在桌子上登记,老板不停地看桌子上有没有新的需求,有就处理,同时也会看一眼待办清单上有没有要自己做的事。
五、Selector 的创建与替换
这是 Netty 最"骚"的一块操作。我们直接看 NioEventLoop 的构造方法(简化后):
NioEventLoop(NioEventLoopGroup parent, Executor executor, SelectorProvider selectorProvider) {super(parent, executor, false);if (selectorProvider == null) {throw new NullPointerException("selectorProvider");}this.provider = selectorProvider;this.selector = openSelector();
}
1️⃣ Selector 的创建
openSelector() 内部调用的是:
selector = provider.openSelector();
这里的 provider 默认是 SelectorProvider.provider(),在不同操作系统上会返回不同的实现:
| 操作系统 | Selector 实现 |
|---|---|
| Linux | EPollSelectorImpl(基于 epoll) |
| macOS | KQueueSelectorImpl(基于 kqueue) |
| Windows | WindowsSelectorImpl(基于 select) |
2️⃣ 为什么要替换 Selector 的实现?
关键在于,JDK 原生的 Selector 内部用 HashSet 来存储就绪的 SelectionKey:
// JDK Selector 内部的 selectedKeys
private Set<SelectionKey> selectedKeys = new HashSet<>();
这会带来几个问题:
- 扩容成本高:HashSet 扩容时是 O(n) 的,而且会创建新数组、重新哈希。
- 遍历删除慢:每次
selectedKeys()返回后,需要迭代器遍历并逐个删除(就是我们上一篇说的iter.remove()),HashSet 的删除操作并不快。 - GC 压力大:频繁创建和销毁 SelectionKey 对象,给 GC 带来额外负担。
Netty 的解决方案非常直接——用数组替换 HashSet:
SelectedSelectionKeySet selectedKeys = new SelectedSelectionKeySet();
Selector originalSelector = provider.openSelector();
Selector selector = new SelectedSelectionKeySetSelector(originalSelector, selectedKeys);
SelectedSelectionKeySet 的核心是一个 SelectionKey[] 数组 + 一个 size 计数器:
final class SelectedSelectionKeySet {private SelectionKey[] keys;private int size;void add(SelectionKey key) {keys[size++] = key;}void clear() {size = 0;}
}
对比一下:
| 维度 | JDK HashSet | Netty SelectedSelectionKeySet |
|---|---|---|
| 数据结构 | HashSet(哈希桶 + 链表) | 数组(连续内存) |
| 添加元素 | O(1) 平均,可能触发扩容 O(n) | O(1),数组下标直接赋值 |
| 清空 | 逐个删除,O(n) | 仅重置 size = 0,O(1) |
| GC 影响 | 频繁创建/删除节点 | 数组复用,几乎无 GC 压力 |
| 遍历 | Iterator 遍历 | for 循环,缓存友好 |
然后 Netty 用装饰器模式,把原生的 Selector 包了一层:
public class SelectedSelectionKeySetSelector extends Selector {private final Selector delegate;private final SelectedSelectionKeySet selectedKeys;// 所有方法都委托给 delegate,但 selectedKeys() 返回 Netty 的数组版本
}
Netty 没有重写 JDK 的 Selector,而是用装饰器模式在外部包了一层,把"存什么东西"和"怎么存"这件事偷偷换了。这就是设计模式的魅力——不改原代码,行为全变了。
WindowsSelectorImpl 内部实现比较特殊,Netty 的替换可能会失败,这时候会 fallback 到 JDK 原生的实现。这也是为什么 Netty 在 Linux 上性能表现最好——epoll + 数组替换,双重加成。六、NioEventLoop 在做什么?
现在我们知道 NioEventLoop 有一个线程、一个 Selector、一个任务队列。那这个线程启动之后,到底在干什么?
核心逻辑在 NioEventLoop.run() 方法中,可以简化为以下三步循环:
protected void run() {for (;;) {try {// ① 等待事件就绪select();// ② 处理所有就绪的 I/O 事件processSelectedKeys();// ③ 执行任务队列中的所有异步任务runAllTasks();} catch (Throwable t) {// 异常处理}}
}
1️⃣ select() —— 等事件
这一步就是调用 selector.select()(或者带超时的 select(timeout)),阻塞等待,直到至少有一个 Channel 上有 I/O 事件发生。底层依赖操作系统的 epoll_wait / kqueue / select 系统调用。
如果一直没有事件,线程就在这里"睡"着了,不会空转消耗 CPU。
2️⃣ processSelectedKeys() —— 处理 I/O 事件
有事件了,就开始处理。Netty 会遍历 selectedKeys(就是我们前面说的那个被替换成数组的 Set),逐个取出 SelectionKey,根据事件类型分发:
OP_ACCEPT→ 接受新的客户端连接OP_READ→ 读取客户端发来的数据OP_WRITE→ 向客户端写数据OP_CONNECT→ 客户端连接成功(客户端侧)
这对应我们第一篇 NIO Demo 中的那段 if/else 判断:
if (key.isAcceptable()) {// 处理 ACCEPT
} else if (key.isReadable()) {// 处理 READ
}
Netty 只不过把这段逻辑封装成了 ChannelPipeline 的责任链模式,让你的 Handler 只需要关心自己的逻辑。
3️⃣ runAllTasks() —— 执行异步任务
除了 I/O 事件,NioEventLoop 还会执行任务队列里的异步任务。比如你在业务代码中调用:
channel.writeAndFlush(message);
这个调用并不会立刻写数据,而是把"写数据"这个任务丢到 NioEventLoop 的 taskQueue 里。等到 runAllTasks() 执行时,才会真正把数据写到 Socket 中。
这也是 Netty 实现无锁的关键——所有任务都由同一个线程执行,根本不需要加锁。
NioEventLoop 的 run() 方法就是一个永不停歇的循环:等事件 → 处理事件 → 执行任务 → 继续等事件。
七、为什么 Netty 能做到无锁?
到这里,我们可以回答一个经典的面试题了:
Netty 为什么不需要加锁?
答案就在 NioEventLoop 的设计中:
- 每个 NioEventLoop 只有一个线程。
- 所有注册到这个 NioEventLoop 的 Channel,它们的 I/O 事件都只由这个线程处理。
- 所有提交到这个 NioEventLoop 的异步任务,也都只由这个线程执行。
- 同一个 Channel 永远不会被两个线程同时操作。
用一句话概括:
不是 Netty 没有锁,而是 Netty 通过"一个线程管一批 Channel"的模型,从根源上消除了对锁的需求。
这也解释了为什么 Netty 在高并发场景下性能如此强悍——没有锁竞争,没有上下文切换的开销,每个线程都能全速运转。
八、与传统线程池的区别
最后,我们用一张表来对比 NioEventLoopGroup 和传统的 Java 线程池(比如 ThreadPoolExecutor):
| 维度 | 传统线程池(ThreadPoolExecutor) | NioEventLoopGroup |
|---|---|---|
| 线程模型 | 固定/可缓存/定时线程池 | 固定数量 EventLoop 线程 |
| 任务类型 | 任意 Runnable / Callable | I/O 事件 + 异步任务 |
| Selector | 无 | 每个 EventLoop 持有一个 Selector |
| 锁 | 需要(任务队列、线程同步) | 不需要(单线程模型) |
| Channel 绑定 | 无 | Channel 注册到 EventLoop,终身绑定 |
| 适用场景 | 通用并发任务 | 网络 I/O 多路复用 |
九、总结
这一篇我们深入了 Netty 的"发动机"——NioEventLoopGroup 和 NioEventLoop。回顾一下核心要点:
- 默认线程数 = CPU 核心数 × 2(至少 1 个),可通过构造函数自定义。
- NioEventLoopGroup 只是一个容器,持有一个 NioEventLoop 数组,不自己干活。
- NioEventLoop = 一个线程 + 一个 Selector + 一个任务队列,是真正的执行单元。
- Selector 被 Netty 替换,用数组(SelectedSelectionKeySet)替代 JDK 的 HashSet,实现 O(1) 的添加和清空。
- NioEventLoop.run() 是一个死循环:select() → processSelectedKeys() → runAllTasks(),周而复始。
- 无锁的秘诀:每个 Channel 只由一个线程管理,从根本上避免了并发竞争。
— 原创文章,转载请注明出处 —
