Netty 第三篇:NioEventLoopGroup 是如何初始化的

 

📅 2026-05-22    ✍️ 原创    🏷️ Java NIO Netty EventLoop 源码分析

在前两篇博客中,我们用 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。
⚠️ 注意:这里说的是"至少为 1"。如果你的机器是单核 CPU(比如某些嵌入式环境),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 的数组。注意,EventExecutorNioEventLoop 的父类,所以类型声明为 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 HashSetNetty 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,而是用装饰器模式在外部包了一层,把"存什么东西"和"怎么存"这件事偷偷换了。这就是设计模式的魅力——不改原代码,行为全变了。
⚠️ 注意:这种替换并不是所有场景都生效。在 Windows 上,由于 JDK 的 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。回顾一下核心要点:

  1. 默认线程数 = CPU 核心数 × 2(至少 1 个),可通过构造函数自定义。
  2. NioEventLoopGroup 只是一个容器,持有一个 NioEventLoop 数组,不自己干活。
  3. NioEventLoop = 一个线程 + 一个 Selector + 一个任务队列,是真正的执行单元。
  4. Selector 被 Netty 替换,用数组(SelectedSelectionKeySet)替代 JDK 的 HashSet,实现 O(1) 的添加和清空。
  5. NioEventLoop.run() 是一个死循环:select() → processSelectedKeys() → runAllTasks(),周而复始。
  6. 无锁的秘诀:每个 Channel 只由一个线程管理,从根本上避免了并发竞争。

— 原创文章,转载请注明出处 —

posted @ 2026-05-29 14:57  小金虫  阅读(19)  评论(0)    收藏  举报