Netty(一):核心架构、Reactor 线程模型与 Pipeline 责任链
Netty(一):核心架构、Reactor 线程模型与 Pipeline 责任链
导语:Netty 面试的「三座大山」是——线程模型、Pipeline 责任链、内存管理。本篇打前两座:先说清「Netty 是什么、为什么高性能、有哪些核心组件」,再用图把 Reactor 三种模型的演进 和 Netty 主从多线程模型 画明白,接着深挖
NioEventLoop的事件循环与 JDK epoll 空轮询 Bug 的修复,最后讲透 Pipeline 的双向链表结构 与 入站/出站事件传播顺序,共 9 题。
一、Netty 概览
1. 什么是 Netty?为什么不用原生 NIO 而要用 Netty?
答: Netty 是一个异步、事件驱动的网络应用框架,基于 Java NIO 封装,用于快速开发高性能、高可靠的网络服务端与客户端。几乎所有 Java 系的基础中间件都直接或间接依赖它:Dubbo、gRPC、RocketMQ、Kafka、Elasticsearch、Spark、Flink、Zookeeper、Sentinel、Seata、Redisson 等。
原生 NIO 的「四宗罪」:
| 问题 | 具体表现 |
|---|---|
| ① API 繁琐 | Selector / ServerSocketChannel / SocketChannel / ByteBuffer 需手工组合,一个可用服务要几百行样板代码 |
| ② 著名的 epoll Bug | Selector.select() 可能空轮询立即返回,导致 while 循环空转,CPU 飙到 100%,且 JDK 至今未在 NIO 层面「修复」 |
| ③ 半包/粘包要自己处理 | TCP 是字节流无边界,需自己写拆包器 |
| ④ 可靠性要自己兜 | 网络闪断、重连、失败缓存、拥塞处理、异常兜底,每一项都需要极深的功底 |
Netty 给出的答案:
| 维度 | Netty 的做法 |
|---|---|
| 易用 | 统一的 API,屏蔽 NIO/BIO 差异;Bootstrap 链式配置 |
| 高性能 | 主从 Reactor 线程模型 + 无锁串行化 + 零拷贝 + 内存池 |
| 开箱即用 | 内置 拆包器(FixedLengthFrameDecoder、LengthFieldBasedFrameDecoder 等)、编解码器、SSL、压缩、心跳检测 |
| 协议栈全 | HTTP/HTTPS、WebSocket、HTTP/2、SOCKS、MQTT 等 |
| 社区活跃 | 版本迭代快,Bug 修复及时 |
// 一个可用的 Netty 服务端,核心代码不到 20 行
EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
try {
new ServerBootstrap()
.group(boss, worker)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new StringDecoder())
.addLast(new StringEncoder())
.addLast(new BizHandler());
}
})
.bind(8080).sync().channel().closeFuture().sync();
} finally {
boss.shutdownGracefully();
worker.shutdownGracefully();
}Netty 的逻辑架构(三层):
一句话:Netty = 封装好的 Reactor 线程模型 + 插件化的 Handler 责任链 + 池化的内存管理,让你用几十行代码获得生产级的网络程序。
2. Netty 为什么能做到高性能?
答: 高性能不是单一技术,而是多个优化点的叠加。
逐条解释:
| 优化点 | 说明 |
|---|---|
| 主从 Reactor 多线程 | boss 只负责 accept,worker 负责 read/write,职责分离,不会因为海量连接而阻塞新连接接入 |
| 无锁串行化 | 一个 Channel 从创建到销毁全程只绑定一个 EventLoop(单线程),同一 Channel 的事件天然按顺序执行,不需要加锁,也避免了线程切换 |
| Selector 优化 | Netty 通过反射把 SelectorImpl 里基于 HashSet 的 selectedKeys 换成数组实现,遍历效率从 O(n) 哈希查找变为顺序访问;NioEventLoop 只有在 selectedKeys 数量较大时才触发替换 |
| epoll 空轮询修复 | 见第 7 题 |
| 零拷贝 | 见第二篇第 4 题 |
| 内存池化 | PooledByteBufAllocator,与 jemalloc 思路类似,按大小分级复用,大幅减少分配/回收与 GC 压力 |
| 堆外直接内存 | IO 读写用 DirectBuffer,避免「JVM 堆 → 直接内存」的一次拷贝 |
| MPSC 队列 | Netty 自己实现 MpscUnboundedArrayQueue,多生产者单消费者场景无锁入队,配合「一个 EventLoop 一个线程」的模型 |
| 对象池 Recycler | 大量短生命周期对象(如 ByteBuf、Promise)复用 |
| FastThreadLocal | 内部用数组下标代替哈希查找,比 JDK ThreadLocal 快 |
面试加分点:最核心的两条是「无锁串行化」和「内存池化+零拷贝」。前者消灭了锁竞争,后者消灭了内存分配与拷贝开销。
3. Netty 有哪些核心组件,各自职责是什么?
答:
| 组件 | 职责 |
|---|---|
Bootstrap / ServerBootstrap | 启动引导类。Bootstrap 用于客户端,ServerBootstrap 用于服务端;负责组装 EventLoopGroup、Channel 类型、ChannelHandler、TCP 参数 |
Channel | 网络连接的抽象(NioSocketChannel、NioServerSocketChannel),封装了 JDK 的 SocketChannel,提供 bind、connect、read、write 等操作 |
EventLoop | 事件循环,本质是一个单线程执行器。一个 EventLoop 可以绑定多个 Channel,但一个 Channel 只绑定一个 EventLoop(生命周期内不变) |
EventLoopGroup | EventLoop 的容器/线程池。服务端有两个:bossGroup 处理连接接入,workerGroup 处理 IO 读写 |
ChannelPipeline | Handler 的双向链表,是责任链模式的实现。每个 Channel 有一个独立的 Pipeline |
ChannelHandler | 业务处理单元,分 ChannelInboundHandler(入站)和 ChannelOutboundHandler(出站) |
ChannelHandlerContext | Handler 在 Pipeline 中的「上下文/节点」,持有前后指针,用于事件传播和获取 Channel、EventLoop |
ByteBuf | Netty 的字节容器,取代 JDK ByteBuffer,支持读写指针分离、动态扩容、池化、引用计数 |
ChannelFuture / Promise | Netty 所有 IO 操作都是异步的,返回 ChannelFuture。Promise 是可写的 Future,供使用者主动设置结果 |
记忆口诀:「引导(Bootstrap)启动 → 通道(Channel)连接 → 循环(EventLoop)驱动 → 管道(Pipeline)编排 → 处理器(Handler)干活 → 字节(ByteBuf)承载 → 未来(Future)回调」。
二、Reactor 线程模型
4. Reactor 有哪几种线程模型?Netty 用的是哪种?
答: Reactor 模式基于事件驱动 + IO 多路复用,核心思想是「一个线程监听多个连接」。它有三种经典演进形态。
三种模型对比:
| 模型 | 说明 | 优点 | 缺点 |
|---|---|---|---|
| ① 单 Reactor 单线程 | 一个线程完成 accept、读写、业务处理 | 简单,无锁、无线程切换 | 业务处理阻塞会拖垮所有连接;无法利用多核。典型:Redis 6.0 之前(但 Redis 是纯内存操作,无此问题) |
| ② 单 Reactor 多线程 | Reactor 线程负责 accept 和 IO 读写,业务交给线程池 | 业务不阻塞 IO 线程 | Reactor 单点:海量连接的 accept + 读写仍集中在一个线程,易成瓶颈 |
| ③ 主从 Reactor 多线程 | Main Reactor 只做 accept,拿到连接后注册给 Sub Reactor;Sub Reactor 负责该连接的全部读写与业务 | 职责彻底分离,可支撑海量连接;Netty 官方推荐与默认形态 | 复杂度最高 |
Netty 用哪种?——三种都支持,默认是「主从 Reactor 多线程」:
// ③ 主从 Reactor(默认,生产推荐)
EventLoopGroup boss = new NioEventLoopGroup(1); // Main Reactor,通常 1 个线程
EventLoopGroup worker = new NioEventLoopGroup(); // Sub Reactor,默认 CPU 核数 * 2
new ServerBootstrap().group(boss, worker);
// ② 单 Reactor 多线程(boss 和 worker 共用一组)
EventLoopGroup group = new NioEventLoopGroup();
new ServerBootstrap().group(group);
// ① 单 Reactor 单线程
EventLoopGroup group = new NioEventLoopGroup(1);
new ServerBootstrap().group(group);为什么 worker 默认是 CPU 核数 × 2?
NioEventLoopGroup 的无参构造会取:
// MultithreadEventLoopGroup
private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1,
SystemPropertyUtil.getInt("io.netty.eventLoopThreads",
NettyRuntime.availableProcessors() * 2)); // CPU 核数 * 2依据是 Netty 自身的压测经验:在 IO 密集型场景下,IO 线程常因网络等待而短暂阻塞,线程数略多于核数能更有效地压满 CPU。这不是理论公式,可用 -Dio.netty.eventLoopThreads=N 调整。
boss 为什么只需 1 个线程?
boss 只负责 accept,一次只处理一个接入连接,且 Netty 的服务端 Channel(NioServerSocketChannel)只注册到一个 EventLoop 上,多给线程也不会被使用。只有多个 ServerBootstrap 绑定多个端口时,才需要 boss 多线程。
一句话:boss 管「接入」,worker 管「读写」,一个连接终身只属于一个 worker 线程(无锁串行化)。
5. EventLoop 与 EventLoopGroup 是什么关系?NioEventLoop 的线程什么时候启动?
答:
关系:一对多 + 一对一
EventLoopGroup→EventLoop:一对多。Group 是一组 EventLoop 的容器,通过next()(默认EventExecutorChooserFactory轮询 / 2 的幂优化)分发。EventLoop→Channel:一对多。一个 EventLoop 可以驱动多个 Channel,每个 EventLoop 内部绑定唯一一个Thread(SingleThreadEventExecutor.thread)。Channel→EventLoop:一对一且终身不变。这正是「无锁串行化」的前提。
| 问题 | 结论 |
|---|---|
| 一个 EventLoop 能绑定多个 Channel 吗? | 能(常见情况,一个 worker 线程管几千条连接) |
| 一个 Channel 能绑定多个 EventLoop 吗? | 不能,注册后终身不变,这样保证线程安全 |
| EventLoop 内部有几个线程? | 严格 1 个 |
| 一个 EventLoop 是多个 Group 共享的吗? | 不共享,一个 EventLoop 只属于一个 Group |
线程什么时候启动?——「懒启动,第一个任务触发」
NioEventLoop 继承自 SingleThreadEventExecutor,其线程不是构造时创建,而是在第一次提交任务时才启动:
// SingleThreadEventExecutor.execute()
public void execute(Runnable task) {
boolean inEventLoop = inEventLoop();
addTask(task);
if (!inEventLoop) {
startThread(); // ← 第一次提交任务时启动线程
...
}
}
private void startThread() {
if (state == ST_NOT_STARTED
&& STATE_UPDATER.compareAndSet(this, ST_NOT_STARTED, ST_STARTED)) {
doStartThread(); // executor.execute(() -> { thread = ...; NioEventLoop.run(); })
}
}触发点通常是 channel.register()(把 register 任务投递给 EventLoop),随后线程启动并进入 NioEventLoop.run() 死循环。
面试加分点:Netty 用
STATE_UPDATER(CAS) 保证startThread()只执行一次,因此并发提交任务也只会起一个线程。
6. NioEventLoop 的事件循环(run 方法)具体做了什么?ioRatio 是什么?
答: NioEventLoop.run() 是整个框架的「心脏」,是一个永不退出的 for 循环,每一轮做三件事:轮询 IO 事件 → 处理 IO 事件 → 执行任务队列。
三个阶段详解:
| 阶段 | 方法 | 作用 |
|---|---|---|
| ① 轮询 | select(wakenUp) | 调用 JDK Selector.select(timeoutMillis);若无任务则阻塞等待,有任务则立即返回 |
| ② 处理 IO | processSelectedKeys() | 遍历就绪的 SelectionKey,按事件类型分发:OP_ACCEPT → unsafe.read() 接入连接OP_READ → 读数据并触发 channelReadOP_WRITE → 刷写这里会传入 ChannelHandlerContext 在 Pipeline 上传播 |
| ③ 执行任务 | runAllTasks() | 执行 taskQueue(普通任务)和 scheduledTaskQueue(定时任务),并按时间片控制执行时长 |
ioRatio 是什么?
ioRatio(默认 50)表示「处理 IO 事件的时间占比」,用于防止IO 事件太多导致任务队列「饿死」。
// NioEventLoop.run() 中的核心逻辑
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);含义:本轮处理 IO 花了 ioTime,则执行任务队列的时间预算 = ioTime × (100 - ioRatio) / ioRatio。
| ioRatio | 任务队列时间预算(ioTime = 10ms 时) | 语义 |
|---|---|---|
| 50(默认) | 10ms | IO 与任务五五开 |
| 100 | 0ms | 全部时间给 IO,任务可能饿死 |
| 30 | ≈23ms | IO 占 30%,更多时间给任务 |
面试常问:「为什么 Netty 里
ctx.executor().execute()提交的任务能和其他 Channel 共享线程?」——因为一个 EventLoop 驱动多个 Channel 时,任务都进同一个taskQueue,串行执行。这也意味着一个耗时任务会阻塞该 EventLoop 上的所有 Channel(见第二篇第 8 题)。
7. Netty 是怎么解决 JDK NIO 的 epoll 空轮询 Bug 的?
答:
Bug 现象:JDK NIO 在 Linux epoll 下有个著名缺陷——Selector.select() 本应阻塞,却可能在没有任何就绪事件时立即返回 0。如果业务代码是 while(true) { selector.select(); ... },就会陷入空转死循环,CPU 直接打满 100%。
Netty 的解法:统计 select 次数,超阈值就重建 Selector。
关键点:
| 要点 | 说明 |
|---|---|
| 阈值 | SELECTOR_AUTO_REBUILD_THRESHOLD 默认 512;可通过 -Dio.netty.selectorAutoRebuildThreshold=N 配置,设为小于 3 时禁用此机制 |
| 本质 | 不是修复 epoll,而是抛弃「坏掉」的 Selector,换个新的——旧 Selector 上的 SelectionKey 全部迁移,旧 Selector 关闭 |
| 判断技巧 | 不仅数次数,还对比时间:若 当前时间 - 上次正常返回时间 >= timeoutMillis,说明是「真超时」而非空轮询,重置计数 |
| 源码位置 | NioEventLoop.select() + NioEventLoop.rebuildSelector() |
// NioEventLoop.select() 关键片段(示意)
int selectCnt = 0;
long currentTimeNanos = System.nanoTime();
for (;;) {
int selectedKeys = selector.select(timeoutMillis);
selectCnt++;
if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
break; // 正常返回
}
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
selectCnt = 1; // 真超时,重置
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 && selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
rebuildSelector(); // 空轮询次数过多 → 重建
selector = this.selector;
selector.selectNow();
selectCnt = 1;
break;
}
currentTimeNanos = time;
}追问:
selectedKeys为什么还要优化? 因为 JDKSelectorImpl内部selectedKeys是HashSet,每次就绪都要add并去重。Netty 在NioEventLoop.openSelector()里通过反射把它替换为数组实现的SelectedSelectionKeySet,去掉哈希开销——前提是sun.nio.ch可访问,替换失败会优雅降级。
三、Pipeline 与 Handler
8. ChannelPipeline 是什么结构?入站和出站事件是怎么传播的?
答:
结构:带「哨兵」的双向链表
每个 Channel 持有一个 ChannelPipeline,内部是 AbstractChannelHandlerContext 组成的双向链表,首尾各有一个固定哨兵节点:
| 节点 | 类型 | 作用 |
|---|---|---|
HeadContext | inbound + outbound | 入站事件的起点;出站事件的终点——write 最终由它调用 unsafe.write() 写入 Socket |
TailContext | 仅 inbound | 入站事件的终点;兜底释放:若 channelRead 一路传到 tail 仍无人处理,会 ReferenceCountUtil.release(msg) 并打印 warn,防止内存泄漏;同时也是出站事件的起点 |
传播规则(面试必背):
| 事件类型 | 方向 | 典型事件 | 触发方法 |
|---|---|---|---|
| Inbound(入站) | head → tail(向后) | 连接注册/激活、读数据、读完成、异常、连接断开 | pipeline.fireChannelRead() 从头开始;ctx.fireChannelRead() 从当前节点向后 |
| Outbound(出站) | tail → head(向前) | 绑定、连接、断开、写数据、刷新 | pipeline.write() 从尾开始;ctx.write() 从当前节点向前 |
三个高频追问:
① ctx.write() 和 ctx.channel().write() 有什么区别?
ctx.write(msg):从当前 Handler 向前查找 Outbound,跳过了它后面的所有 Outbound。ctx.channel().write(msg):从 Tail 开始,经过全部 Outbound。- 同理
ctx.fireChannelRead()vsctx.channel().fireChannelRead()。
坑点:在
InboundHandler里用ctx.channel().write()回包是安全的;但在靠前的 OutboundHandler 里用ctx.channel().write()可能导致重复编码。
② Handler 执行顺序怎么确定?
- 入站:
addLast的顺序执行(先加的先执行) - 出站:
addLast的逆序执行(后加的先执行,因为从尾往前)
// 推荐写法:Inbound 在前,Outbound 在后,执行顺序才符合直觉
pipeline.addLast("decoder", new StringDecoder()); // in ①
pipeline.addLast("biz", new BizHandler()); // in ②
pipeline.addLast("encoder", new StringEncoder()); // out ①(最后一个被执行)③ 为什么 write 之后一定要 flush?
write()只是把数据写入 ChannelOutboundBuffer(用户态缓冲区),不发送。flush()才真正触发unsafe.doWrite()把数据刷到 Socket 发送缓冲区。writeAndFlush()=write()+flush()。
一句话:Inbound 是「别人发给我的」,从头传到尾;Outbound 是「我要发给别人的」,从尾传到头。最后都由
HeadContext落盘/落网。
9. ChannelHandler 的生命周期是什么?@Sharable 有什么用?
答:
生命周期回调(按触发顺序):
| 回调方法 | 触发时机 | 典型用途 |
|---|---|---|
handlerAdded() | Handler 被添加到 Pipeline | 资源初始化 |
channelRegistered() | Channel 注册到 EventLoop | — |
channelActive() | Channel 就绪(TCP 连接建立) | 记录连接、触发首次业务逻辑 |
channelRead() | 读到数据 | 核心业务处理 |
channelReadComplete() | 本轮 read 完成 | 批量 flush、统计 |
exceptionCaught() | 处理链中抛异常 | 必须重写,否则异常一路传到 tail 被吞掉 |
channelInactive() | 连接断开 | 清理资源、重连逻辑 |
channelUnregistered() | 从 EventLoop 注销 | — |
handlerRemoved() | 从 Pipeline 移除 | 资源释放 |
最常继承的三个适配器:
| 适配器 | 说明 |
|---|---|
ChannelInboundHandlerAdapter | 只需重写关心的入站方法 |
ChannelOutboundHandlerAdapter | 只需重写关心的出站方法 |
ChannelDuplexHandler | 同时支持入站和出站 |
SimpleChannelInboundHandler<I> | 入站 + 自动释放消息(泛型指定消息类型,自动 release),最常用 |
注意:
SimpleChannelInboundHandler会在channelRead0()返回后自动释放msg。如果你要把 msg 传给后续 Handler 或异步线程使用,就不能继承它,否则会踩「use after free」的坑。
@Sharable 的作用:
| 问题 | 说明 |
|---|---|
| 它标记什么? | 标记该 ChannelHandler 实例可以被多个 Channel / Pipeline 共享 |
| 不加会怎样? | Netty 在 addLast 时会检查:若一个非 @Sharable 的实例已被添加过,再添加会抛 ChannelPipelineException: ... is not a @Sharable handler |
| 加了有什么约束? | 必须保证无状态、线程安全——不能有可变的成员变量,否则多个连接并发访问会出数据错乱 |
| 常见使用场景 | 无状态的编解码器、统计 Handler、SslHandler 的上下文工厂等 |
// 正确示范:无状态,可安全共享
@ChannelHandler.Sharable
public class EchoServerHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.write(msg); // 无任何成员变量
}
}
// 错误示范:有状态却加了 @Sharable → 并发错乱
@ChannelHandler.Sharable
public class BadHandler extends ChannelInboundHandlerAdapter {
private int count; // × 共享实例下的可变状态
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) {
ctx.writeAndFlush(++count);
}
}一句话:
@Sharable= 我保证这个 Handler 是线程安全的,允许被多个连接复用;代价是你必须自己保证无状态。
小结
| 主题 | 核心结论 |
|---|---|
| Netty 定位 | 异步事件驱动的网络框架,中间件事实标准 |
| 高性能支柱 | 主从 Reactor + 无锁串行化 + 零拷贝 + 内存池 |
| 线程模型 | 默认主从 Reactor:boss 只 accept,worker 默认 CPU×2 |
| EventLoop | 单线程绑定多 Channel;一 Channel 终身一 EventLoop |
| epoll Bug | 统计空轮询次数达 512 次 → 重建 Selector |
| Pipeline | 带 head/tail 哨兵的双向链表;入站 head→tail,出站 tail→head |
| @Sharable | 允许 Handler 被多 Pipeline 复用,但必须无状态 |
