学Netty的人很多但真正打开过Netty源码的人比想象中少得多。大多数时候我们停留在“会用”的层面知道Bootstrap怎么配、ChannelHandler怎么写、EventLoopGroup开几个线程一旦跑到线上出问题比如连接积压、内存涨、延迟抖动面对日志里那一串io.netty.*的堆栈心里是虚的。这个“认真系列”想做的事情很简单不再满足于网上的结论和二手解读直接把Netty源码摊开一条一条链路读过去搞清楚它为什么快、为什么稳、为什么那样设计。第一篇我打算先把骨架搭起来——不急着追某个具体类而是先回答三个最核心的问题Netty在宏观上由哪些部分组成一个网络请求从接入到返回在源码里到底走了一条什么路我们自己读源码时应该从哪个文件切入、用什么方法读才不会一头扎进去出不来。如果你已经写过一阵子Netty业务代码又想往深走一步这篇文章应该能给你一张还算清晰的地图。如果你是刚接触Netty不久也不用担心我会尽量把每个概念都落到“它在源码里对应哪个类、哪个方法”上让抽象的东西变得可以定位、可以点击、可以断点。1. 为什么值得从源码层理解Netty1.1 从“会用”到“用明白”的距离有朋友问过我我业务代码写得挺顺channelRead里处理消息、writeAndFlush返回结果Netty底层做了什么跟我有什么关系关系其实很大。举一个我印象深刻的例子前几年在某业务团队排查线上延迟毛刺现象是高峰期偶发几十毫秒的卡顿业务代码里查了个遍没找到问题最后才定位到是某个ChannelHandler里不小心做了耗时的阻塞操作把NioEventLoop那个线程给卡住了。这一个线程卡住意味着注册在它上面的所有channel全部停摆延迟自然就上去了。这种问题如果你不知道Netty是“单线程串行处理一个Channel的所有事件”的模型不知道Handler默认跑在IO线程上排查方向就会完全跑偏。源码层面理解的价值就在这里它不解决某个具体bug但它决定了你遇到问题时的第一反应——你是去看IO线程的状态还是继续在业务代码里打转。1.2 这个系列打算怎么读源码阅读最容易犯的错是按类从上往下读。NioEventLoop、DefaultChannelPipeline、ByteToMessageDecoder……每个类都很长读完三个类前面讲什么都忘了因为类和类之间是网状关系不是线性关系。所以这个系列我采取另一种路线按请求的生命周期读源码。从连接被accept开始到数据被read进来再到经过pipeline层层传递、解码最后业务处理完写回客户端——完整走一遍。这样读完你脑子里留下的不是一堆孤立的类名而是“一个请求在Netty内部流转的地图”。接下来的文章再深入具体模块比如内存分配、线程模型、零拷贝就会轻松很多。这一篇先把这张地图画出来。2. 先不碰代码Netty的四根柱子打开Netty源码netty-transport、netty-buffer、netty-codec、netty-handler这些模块一大堆新手很容易被吓住。但抛开细节Netty的运行时架构其实只有四个核心抽象理解它们源码目录就变成了一棵有结构的树。2.1 Channel连接的最小载体Netty里的Channel接口封装了底层JDK SocketChannel的一切。为什么要包一层因为JDK原生NIO的接口用起来太零散注册Selector、处理OP_READ/OP_WRITE、管理Buffer全都散在不同地方。Netty把各种传输方式统一收敛成一个Channel接口不管是NIO、Epoll还是OIO对上层来说都是同一个门面。它的核心实现是NioSocketChannel和NioServerSocketChannel分别对应客户端连接和服务端监听。NioSocketChannel内部持有JDK的SocketChannelNetty的所有读写、关闭、注册最终都落到这个JDK对象上。你可以把Channel理解成Netty为每条TCP连接准备的档案袋所有和这条连接相关的状态——配置、pipeline、eventLoop、缓冲区——都挂在这个档案袋下面。2.2 EventLoop驱动一切的引擎EventLoop是Netty最容易被低估的概念。它的本质是一个线程 一个Selector 一个任务队列。NioEventLoop就是这几个东西拼出来的它干三件事轮询Selector获取IO事件、处理这些IO事件、执行用户提交的普通任务比如execute提交的任务、定时任务。关键设计在于一个Channel一旦注册到某个EventLoop就终身绑定在这个线程上所有关于这条连接的事件都在这个线程里串行执行。串行意味着不需要加锁不需要考虑并发竞争这是Netty高性能的一个根基。你可以想象银行里每个窗口一个柜员每个客户从进门到办完业务都只跟同一个柜员打交道不用在大厅里抢号也就不会乱。2.3 ChannelHandler与ChannelPipeline请求的流水线ChannelPipeline是一条双向链表链表上的每个节点是一个ChannelHandlerContext里面裹着真正的ChannelHandler。链表的头是HeadContext尾是TailContext这两个是Netty内置的用户自定义的Handler通过pipeline.addLast()插在中间。入站事件从头部往后传出站事件从尾部往前传。为什么要分入站出站因为一次请求处理是两段逻辑读到的数据从前往后经过解码器、业务Handler这是入站响应的数据从业务Handler写出经过编码器、最终写到socket这是出站。一个完整的Handler链路其实把这两个方向的路都铺好了每个Handler可以只关心自己感兴趣的方向。用流水线来比喻最贴切数据像工件一样在流水线上移动每个Handler是一道工序入站是正着走一遍流水线出站是反着走一遍两道工序之间靠ChannelHandlerContext传递。2.4 四根柱子串成一张网把这四个抽象拼起来就是Netty运行时的完整画面服务端启动时创建ServerBootstrap配置两个EventLoopGroup——通常叫bossGroup和workerGroup。bossGroup的EventLoop监听OP_ACCEPT事件 accept到新连接后把新连接注册到workerGroup的某个EventLoop上这个EventLoop负责这条连接后续的所有读写。连接上的所有Handler串在它的pipeline里由同一个EventLoop线程驱动。这里有一个很多初学者模糊的点bossGroup和workerGroup不是Netty里特殊的类它们就是普通的NioEventLoopGroup只是职责不同。bossGroup往往只需要一个线程因为监听端口就一个workerGroup的线程数默认是CPU核数的两倍负责处理所有连接的IO。3. 一次请求的源码旅程从accept到业务handler有了骨架我们来走一遍真实的源码路径。以服务端收到一次新连接、客户端发来一段数据、服务端处理并写回响应为例完整看Netty内部“发生了什么”。我尽量把关键类名和方法名点出来你可以照着源码一起点进去看。3.1 连接接入NioEventLoop里的select循环服务端启动后bossGroup的NioEventLoop进入一个死循环核心逻辑在NioEventLoop.run()方法里。这个循环大致分三步select检查是否有IO事件就绪、processSelectedKeys处理就绪的事件、runAllTasks执行任务队列里的非IO任务。// 简化后的NioEventLoop.run() protected void run() { for (;;) { int selectedKeys selector.select(); if (selectedKeys 0 || ...) { processSelectedKeys(); } runAllTasks(); } }当有一个新连接连上来时selector上会就绪一个OP_ACCEPT事件对应到NioServerSocketChannel的NioMessageUnsafe.read()方法。这里名字叫read实际干的是“接收连接”底层doReadMessages里循环调用SocketChannel accept()每拿到一个JDK的SocketChannel就包装成Netty的NioSocketChannel对象然后通过pipeline.fireChannelRead(childChannel)把这个新连接的Channel往pipeline后面传。新连接的Channel传到哪一站服务站是ServerBootstrapAcceptor这是Netty在启动时自动加进服务器pipeline里的一个内部Handler。它的channelRead代码非常关键public void channelRead(ChannelHandlerContext ctx, Object msg) { final Channel child (Channel) msg; child.pipeline().addLast(childHandler); childGroup.register(child).addListener(...); }看到没这里就是boss和worker交接的地方。ServerBootstrapAcceptor拿到了新连接的channel做了两件事——给它装上用户配置的childHandler然后把这个channel注册到workerGroup。一旦注册完成这条新连接就从boss线程的管辖移交给了worker线程。3.2 读事件NioByteUnsafe与ByteBuf的诞生连接注册到workerGroup的某个NioEventLoop后这个EventLoop的selector开始监听新连接的OP_READ事件。等到客户端发来数据NioEventLoop的processSelectedKeys会分发给对应的channel调用它的AbstractNioByteChannel.NioByteUnsafe.read()。: // NioByteUnsafe.read()核心骨架 public final void read() { ByteBuf byteBuf null; ... do { byteBuf allocHandle.allocate(allocator); int readBytes doReadBytes(byteBuf); if (readBytes 0) { ... } pipeline.fireChannelRead(byteBuf); } while (readMessages maxMessagesPerRead ...); pipeline.fireChannelReadComplete(); }这里有几个点值得注意。第一allocate用的是allocHandle它背后是AdaptiveRecvByteBufAllocatorNetty会动态调整每次分配缓冲区的大小——如果数据一直很大缓冲区就慢慢变大如果数据变小了缓冲区也缩回来避免浪费内存。第二doReadBytes最终调用的是SocketChannel.read(ByteBuffer)把数据从内核socket缓冲区读进Netty管理的ByteBuf。Netty的ByteBuf不是JDK的ByteBuffer它自己实现了引用计数、池化复用、零拷贝等机制这是后面专门文章要讲的内容。第三循环读取直至maxMessagesPerRead上限避免一次事件循环把线程卡太久。读完后触发fireChannelReadComplete这就意味着本次读事件的数据已经全部发出去了。3.3 pipeline里的“装水”过程从ByteBuf到POJO现在数据已经到了ByteBuf里通过pipeline.fireChannelRead(byteBuf)进入了pipeline。如果用户配置了解码器比如常见的ByteToMessageDecoder子类LengthFieldBasedFrameDecoder、HttpObjectDecoder等接下来这一站就是它们的主场。ByteToMessageDecoder.channelRead的实现逻辑很精巧它内部维护了一个cumulation累积缓冲区因为TCP是字节流一次读事件的数据可能不是一个完整消息。解码器先调用decode方法尝试从累积缓冲区拆出消息能拆出一个就fireChannelRead(msg)传一个拆不出来的留在缓冲区等下一次数据。protected void callDecode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { while (in.isReadable()) { int outSize out.size(); decodeRemovalReentryProtection(ctx, in, out); // 没有解码出新消息说明字节不够退出循环 if (outSize out.size()) { return; } ... } }拆出来的消息对象会接着往后传一直传到业务Handler的channelRead里。如果你用的是SimpleChannelInboundHandler还有个贴心但容易踩坑的设计它会在channelRead处理完消息后自动调用ReferenceCountUtil.release(msg)释放引用计数。如果你在业务里把这个消息又传给了别的Handler或存了起来就要小心引用计数归零导致对方拿到的对象已经被释放。3.4 返回写出的链路write与flush为什么分开业务Handler处理完数据调用ctx.channel().writeAndFlush(response)这条响应就踏上了出站之旅。出站方向从pipeline尾部往前走。编解码器里的MessageToByteEncoder在这一站工作——它的write方法把POJO编码成ByteBuf再传给前一个节点。最终走到HeadContextwrite方法把ByteBuf缓冲到ChannelOutboundBuffer里。注意ChannelOutboundBuffer不是真正写进socket它只是一个待写缓冲队列。真正写socket的时机在flush。HeadContext.flush会调用AbstractNioChannel.doWrite把缓冲队列里攒的数据通过Channel.write一批批发出去。为什么Netty要把write和flush拆开因为网络写入是个昂贵的系统调用如果每条消息都立即flush性能会很差。批量把多个write攒起来最后一个flush一次性提交是Netty减少系统调用次数的关键手段。一次请求的完整源码旅程到这里就闭环了accept - read - decode - 业务处理 - encode - write - flush。这条链路就是Netty源码的主干道其他所有机制都挂在这条主路旁边。4. 源码阅读的切入路径与调试工具光看文章终究是纸上谈兵。下面我把实践下来的“有效路径”和“黄金调试点”整理出来按这个走比自己乱翻效率高很多。4.1 文件级的入口建议我建议按下面顺序打开第一个源码文件不要跳第一步打开ServerBootstrap主要看bind方法和init方法。init里会把ServerBootstrapAcceptor塞进pipeline这是理解boss/worker分界的关键。第二步打开NioEventLoop直接定位run方法。看这个for循环你就会明白“事件循环”这个名字的由来。在processSelectedKeys里打断点可以亲眼看到IO事件是怎么被分发出去的。第三步打开DefaultChannelPipeline看fireChannelRead和writeAndFlush两个方法。它们内部都是从头或从尾遍历context链调用下一个节点的对应方法。看到这个遍历逻辑pipeline的工作方式就永远忘不掉了。第四步打开AbstractNioChannel里面的doWrite展示了Netty把缓冲数据写给JDK socket的过程——这一步是性能的关键汇聚点。这四个文件看完主链路基本就通了。其余类都是细节的补充。4.2 断点调试的黄金位置读源码最忌讳只看不跑。我强烈建议你写一个极简的EchoServer然后在这几个位置打断点用一个Netty客户端发几条消息断点位置观察目标NioEventLoop.run()里的selector.select()返回后看当前线程名字确认boss和worker各自在哪跑ServerBootstrapAcceptor.channelRead看新连接channel的注册过程DefaultChannelPipeline.fireChannelRead看事件的链表遍历过程SimpleChannelInboundHandler.channelRead0确认业务代码在哪个线程上执行HeadContext.flush看数据如何从出站缓冲真正写进socket断点有一个技巧把Thread名字打出来。NioEventLoop线程名字通常是nioEventLoopGroup-x-x在调试面板里看到这个线程栈你就能直观理解“所有Handler方法都在IO线程里执行”这件事。4.3 第一遍阅读的节奏源码阅读要分轮次一轮就想全部搞懂是不可能的。第一轮只梳理链路不要追细节。看到new Object[]、AtomicInteger这种小点先跳过保持主线推进第二轮专门看内存管理相关的类比如ByteBufAllocator、PoolArena搞清楚池化内存的分配与回收第三轮再回头看线程模型比如EventLoop的任务队列如何调度、executor如何submit任务。三轮下来你对Netty的认知就不再是一堆散点而是一张可以随时定位的网。5. 读Netty源码常见的几个认知陷阱最后聊几个我观察到很多人都会踩的坑。这些坑不是技术难度问题而是阅读方法问题。5.1 把源码当小说从头读到尾我第一次尝试读Netty源码的时候就是打开NioEventLoop一个类从第一行注释读到最后一行。结果呢类太长字段太多读了一半就忘了开头在讲什么挫败感极强。源码是网状结构不是线性文本。正确的方式是按运行时的真实路径去追读什么场景下代码会走到这里来的时候带着什么数据走的时候把控制权交给了谁用这些问题驱动阅读每次只看一小条路径效率会高得多。5.2 忽视“调度线程”而只看方法调用看channelRead方法时很多人只关注方法内部的业务逻辑却不问一句这个方法现在跑在哪个线程上这是一个关键问题。Netty默认情况下所有pipeline里的Handler方法都由IO线程调用。如果你在channelRead里做了耗时的数据库查询或远程调用整个EventLoop线程都会被堵住所有注册在该线程上的连接全部卡死。这也是前面说的那个线上故障的根源。所以读Netty源码时每看到一个回调方法都要问谁在调用它当前线程是谁如果业务处理耗时较长要通过EventExecutorGroup或者把任务提交到独立的业务线程池把IO线程解放出来。5.3 只背结论不复现证据网上的源码分析文章很多结论已经普及了比如“Netty默认使用池化内存”“Netty零拷贝减少拷贝次数”。但这些结论如果你没有自己从源码里看到证据用起来总归是虚的。什么是证据比如你想验证“池化内存”就去翻PooledByteBufAllocator.DEFAULT的初始化逻辑看它如何根据系统属性和JDK版本决定是否启用池化。看到那句SystemPropertyUtil.get(io.netty.allocator.type, ...)你就会明白为什么可以在启动参数里通过-Dio.netty.allocator.typeunpooled切换分配器。这比背一百句“Netty默认池化”都管用。看源码的价值从来不是记住几个类名而是学会怎么自己找到证据。从这个意义上讲认真读一遍Netty源码你的收获可能不只是Netty本身还包括一套读任何源码的方法论。最后这篇是“认真系列”的开篇链路地图先画到这里。下一篇文章我打算扎进NioEventLoop这个类把线程模型掰开揉碎讲一遍——它是Netty的CPU核心也是排查线上问题绕不开的地方。如果你在跟着源码走的过程中有卡住的地方欢迎在评论区把你的断点进程贴出来我们下一篇接着读。