当前位置: 首页 > news >正文

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

 

📅 2025-01-29    ✍️ 原创    🏷️ 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 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,而是用装饰器模式在外部包了一层,把"存什么东西"和"怎么存"这件事偷偷换了。这就是设计模式的魅力——不改原代码,行为全变了。
⚠️ 注意:这种替换并不是所有场景都生效。在 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 只由一个线程管理,从根本上避免了并发竞争。

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

http://www.jsqmd.com/news/864894/

相关文章:

  • 如何轻松实现U校园智能刷课?这个Python工具让你5分钟搞定
  • 探索Taotoken模型广场如何帮助开发者找到性价比更高的模型选项
  • Python AUTOSAR XML生成:从概念到实战的完整指南
  • 从600万到5000万,人员几乎没增加——一家CRO企业的项目成本管理进化史
  • ANI-RSS界面自定义终极指南:从零打造个性化追番体验
  • 深度解析OBS Mac虚拟摄像头插件的架构设计与性能优化
  • 润富黄金回收|2026年宁波黄金回收全攻略,正规渠道与避坑指南 - 润富黄金珠宝行
  • 航拍小目标检测|无人机巡检交通目标识别数据集10054期
  • 3DS Pokémon ROM 编辑器 pk3DS:新手入门完全指南
  • Motrix浏览器扩展终极指南:3分钟实现下载加速300%的免费方案
  • SQL注入实战:5次请求完成数据库结构侦察
  • 为什么你的浏览器需要一款真正的Markdown阅读伴侣
  • MySQL报错注入实战:5次请求精准获取数据库信息
  • 5分钟搞定专业照片水印:Semi-Utils让你的摄影作品瞬间升级
  • Mac高效抢票新方案:12306ForMac客户端深度解析
  • Frida Hook绕过安卓APP SSL Pinning实现HTTPS抓包
  • 知网AI率稳降10%内?2026年5月4款降AI工具实测推荐 - 仙仙学姐测评
  • BotW Save Manager:打破平台壁垒的《塞尔达传说:旷野之息》存档转换神器
  • Unity3D舞蹈动画穿模根因与实时修正方案
  • 2026 东莞黄金变现攻略|正规回收机构测评与避坑技巧 - 奢侈品回收测评
  • HoRain云--Claude Code 基础用法
  • 如何通过New API快速搭建统一AI模型网关:完整部署指南
  • 中医AI革命:如何用1.8B参数模型实现专业中医诊疗助手
  • 小程序加密流量破解:CE内存定钥+Burp Galaxy自动化加解密
  • 省钱妙招:大润发购物卡回收与使用指南 - 团团收购物卡回收
  • 如何通过DeepEval解决LangChain应用的可观测性与评估难题
  • 小程序加密流量逆向:CE内存定钥+Burp Galaxy自动化加解密
  • 金融合规严、情绪识别难?适用金融行业的好用的AI客服推荐 - 品牌2025
  • ElevenLabs方言适配避坑清单,深度解析TTS模型对平仄调值的误判机制,附广西话声调映射表
  • 2026年智能语音机器人厂商推荐,不只拨号,更支持动态话术生成 - 品牌2025