刚开始接触 Netty 那会儿我就被它的 NIO 核心搞得头晕眼花。网上的教程大多是上来就讲 Pipeline、讲编解码器绕了一圈回头才发现连最底层的三大组件都没吃透。Channel 是什么、Buffer 怎么读怎么写、Selector 到底怎么搞定上万连接这些问题通通没搞清楚看什么都像看天书。所以这一篇就用大白话把 Netty 底层 NIO 的三大组件——Channel、Buffer、Selector——彻底说清楚顺便带着你写一个用原生 NIO 实现的简易聊天室 Demo。写这个东西不仅能让你读 Netty 源码时不再犯怵将来排查线上性能问题和内存问题也很有帮助。1. 从BIO到NIO先把底层逻辑理清楚1.1 BIO的痛点和NIO的解题思路要说清楚 NIO 的三大组件得先知道它解决的是谁的问题。Java 早期的网络编程走的是 BIOBlocking I/O路线经典的写法是每来一个客户端就 new 一个线程去处理阻塞在 read() 上的请求。这种方式在连接数量少时没问题但连接一多线程数暴涨线程上下文切换的开销能把 CPU 活活拖垮而且绝大部分线程都在阻塞等待数据资源利用率低得吓人。NIONon-blocking I/O的思路完全反过来它核心是“我不用为每个连接分配一个专职线程”。数据到了没有到了我就处理没到我就去干别的。想做到这一点需要三个东西配合一个读写数据的通道Channel一个临时存数据的缓冲区Buffer一个不断轮询状态的调度器Selector这三个组件各司其职组成了一条完整的数据流水线。Channel 负责搬运数据Buffer 负责暂存数据Selector 负责告诉你数据什么时候能读写。用生活化的比喻来说Channel 像水管Buffer 像水桶Selector 像站在水龙头旁边的人水来了就喊你一声你再去接没来就继续刷手机。1.2 三大组件到底是谁先谁后很多初学者搞不清三个组件之间的调用顺序我从实操的角度梳理一下第一步客户端发起连接服务端用 ServerSocketChannel 调用 accept() 接收连接完成 TCP 三次握手 第二步连接建立后服务端拿到的 SocketChannel 就是和这个客户端点对点通信的通道 第三步读写数据时必须先把数据从 Channel 读入 Buffer或者先把 Buffer 里的数据写入 Channel 第四步Selector 作为总调度员注册上面那些通道只要通道变得可读或可写它就会通知我们处理。顺序理顺之后你会觉得 NIO 一点不神秘。本质上就是“水的流向 临时存储 有人通知你什么时候该接水”这三件事。1.3 为什么Netty要基于NIO而不是直接封装BIONetty 选 NIO 做底座不是偶然的。BIO 的阻塞模型撑不起高并发网关的需求每连接一线程在几十万个长连接面前就是灾难。而 NIO 的 Selector 模型里一个线程就能管理成千上万个 Channel内存占用又小。当然 NIO 原生 API 确实难用各种 ByteBuffer 细节容易踩坑JDK 还出过几回 Selector 的空轮询 Bug。Netty 正是在 NIO 之上做了一层非常优雅的封装把那些坑大部分给填了。但封装得再好底层原理仍是这三样东西。读 Netty 源码时遇到的大部分问题比如 ChannelHandler 里拿到的 ByteBuf 是怎么来的EventLoop 为什么要把 Channel 注册到 Selector追根溯源都要回到本文的三大组件。2. Channel所有数据都必须从这儿过2.1 Channel与Stream的差别传统的 BIO 用的是 InputStream 和 OutputStream这俩分得很清楚要么读要么写。Channel 是双向的既能读又能写。更重要的是流式 API 是阻塞的而 Channel 可以配合 Selector 变成非阻塞模式这是质的区别。打个比方Stream 像单行道的马路只能一个方向走Channel 像双行主干道来与去都在同一条路上配上 Selector 就相当于给这条路装了红绿灯什么时候通行完全可控。在 Java NIO 里最常用到的 Channel 有这么几种ServerSocketChannel监听 TCP 端口对应的通道负责接收新连接操作系统的 accept() 都挂在它身上SocketChannel代表了一条 TCP 连接的两端之一服务端和客户端各有一个对应的 SocketChannel通过它执行真正的数据读写DatagramChannel基于 UDP 协议的通道面向无连接、不保证可靠投递适用于日志采集、监控指标上报这一类的场景FileChannel对文件进行读写虽然和网络编程关系不大但 Netty 里做零拷贝传输时也依赖过它。2.2 ServerSocketChannel与SocketChannel的分工第一次接触 NIO 时很多人以为 ServerSocketChannel 就是用来收发数据的亲自写过一遍才发现完全不是那么一回事。它唯一的职责是监听端口、接收连接。真正干活的是 accept() 之后返回的 SocketChannel。我画一个线程执行的视角给你看服务端的结构大致长这样ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.socket().bind(new InetSocketAddress(8080)); while (true) { SocketChannel socketChannel serverChannel.accept(); if (socketChannel ! null) { // 拿到了和客户端通信的通道 // 后续读写都在 socketChannel 上做 } }关键点是 accept() 在非阻塞模式下不会卡死它要么返回一个新的 SocketChannel要么返回 null。配合 Selector 后accept() 返回的时机变成了“有新连接到来时”这才能支撑高并发接入。2.3 Channel实战中的配置细节配置通道时有几个细节你可能从来没注意过却在真实场景里挺要命。第一个是 configureBlocking(false)如果不设非阻塞Selector 根本没法用Channel 注册到 Selector 前必须确保它是非阻塞的。第二个是 TCP_NODELAY 这个 Socket 参数开发里经常需要手动开启关闭 Nagle 算法后小包不用等攒够数据再发延迟能显著降低对实时性要求高的协议尤其重要。第三个是 SO_KEEPALIVEJava 只能控制开关底层保活探测间隔等值是系统级别统一配置的应用层心跳别完全指望它推荐自己在应用层做心跳。我把这些参数整理成一张表方便你对照参数作用实测感受blocking(false)开启非阻塞模式不配的话 Selector 注册直接抛异常属于必须项TCP_NODELAY关闭 Nagle 算法小包即时发送对低频小消息业务延迟改善明显游戏、IM 必配SO_KEEPALIVE系统层 TCP 保活探测探测间隔太长应用层还是要自己做心跳SO_RCVBUF接收缓冲区大小网络差、丢包多时调大能提升吞吐SO_SNDBUF发送缓冲区大小与接收端缓冲需匹配不是越大越好这些细节 Netty 里基本都帮你封装好了但你自己写原生 NIO 时少配一个可能就掉进性能陷阱。3. Buffer一套被精心设计的内存容器3.1 先搞懂三个核心标记位Channel 只负责搬运数据落在哪里答案就是 Buffer。NIO 里的 Buffer 是一个基于数组的容器按数据类型分成 ByteBuffer、CharBuffer、IntBuffer 等等日常网络编程用得最多的就是 ByteBuffer。Buffer 设计的精妙之处是它内部维护了三个标记位capacity缓冲区总容量初始化后不可变相当于水桶的总容积position下一个读或写的位置相当于水桶里当前水位标记limit实际上能够读或写的最大边界相当于水桶上标着“最多到这里”的那条横线。这三个标记位联合起来决定了 Buffer 的一举一动。写数据之前 position 表示下一个字节落在哪limit 则等于 capacity意味着整个缓冲区都可写。一旦调用 flip() 从写模式切到读模式limit 就被挪到 position 的位置position 归零读数据时只能读到刚才写进去的部分。3.2 flip、clear、compact到底在做什么不夸张地说90% 的 NIO 初学者栽在 flip() 和 clear() 的用法上。我先直接给结论flip()写完数据后调用把模式从“写”切换成“读”同时把 limit 设为当前 positionposition 归零clear()读完后调用把所有标记复位position 归零limit 等于 capacity看起来是清空了 Buffer但数据其实还在只是位置标记都复位了compact()读完后调用把未读完的数据压缩到缓冲区头部position 设为剩余数据长度然后还可以继续写新数据适用于处理一包数据不完整、需要拼接下一包的情况。需要注意的是clear() 只是把 position 和 limit 复位底层数组里的旧数据并不会被抹掉。新写入的数据会从 position 开始覆盖旧数据如果你读漏了数据或者还是按旧 position 去读就会读到垃圾内容。范例如下ByteBuffer buffer ByteBuffer.allocate(1024); // 1. 写入数据 buffer.put(hello.getBytes()); // 2. 切换读模式荒谬地忘记调 flip 会怎样 buffer.flip(); byte[] dst new byte[buffer.limit()]; buffer.get(dst); // 3. 想继续复用时用 clear 复位 buffer.clear();不调 flip 直接 get()你会发现 get 一个字节都读不出来因为 position 还停留在 5 的位置limit 还是 capacityget() 读的其实是 position 指向的那一位之后的数据。这类问题在真实代码里出现频率极高。3.3 直接内存与堆内存怎么选ByteBuffer.allocate() 创建的是堆内缓冲区ByteBuffer.allocateDirect() 创建的是堆外直接内存缓冲区。两者的差别很现实堆内缓冲区数据存储在 JVM 堆中进行 Socket I/O 时数据须先复制到堆外的临时直接缓冲区多一次内存拷贝直接缓冲区数据直接落在堆外内存中读写 Socket 时省掉中间拷贝性能更好直接缓冲区的分配和回收成本更高频繁创建小容量直接缓冲区反而让性能变差。Netty 里讲究用缓冲池来复用 ByteBuf底层很大程度上就是为了规避直接缓冲区频繁分配释放的问题。自己写 NIO 时如果追求极限性能用直接缓冲区加对象池是很有必要的。但有个坑要注意堆外内存的释放由 JVM 的 Cleaner 机制负责如果你不断分配直接缓冲区又忘掉显式回收会出现堆内存充足但 OutOfMemoryError 直接爆掉的情况。3.4 粘包半包问题的根源凡是做过网络开发的肯定碰过粘包和半包。这跟 Buffer 的设计直接相关——TCP 是字节流协议底层没有消息边界上层一次 write 的数据可能和下一次 write 的数据黏在一起这叫粘包一次 write 的数据也有可能被拆成两半发送这叫半包。解决思路就是在应用层自行定义消息边界常见手法有三种固定长度每条消息都定长短了补位读够了长度就处理最简单也最浪费空间分隔符用特定分隔符比如换行符判断消息边界适合文本协议但消息本身不能包含分隔符需要转义处理长度前缀在消息头部用几个字节写明正文长度这也是 Netty 的 LengthFieldBasedFrameDecoder 采用的方式最推荐、最灵活。Buffer 的 compact() 在处理半包时是利器。读入一批数据解析后发现一条消息不完整剩下的残包就可以用 compact() 合并进下一批数据里继续下一次解析。理解了 Buffer 的流转机制你自然明白框架为什么是那么设计的。4. Selector:用单线程支撑海量连接的关键4.1 Selector注册与事件轮询的原理Selector 是整个 NIO 模型的大脑。它做的事情可以总结成一句话你把自己关心的 Channel 和事件注册到 Selector 上Selector 就会在事件发生时通知你。它能注册的事件有四种OP_ACCEPT服务端有新连接进来对应 ServerSocketChannel 的 acceptOP_CONNECT客户端连接完成一般用于判断 connect 是否就绪OP_READ通道中有数据可读OP_WRITE通道可以写入数据。为什么一个线程能管理那么多连接核心在于 Selector 底层最终是调用了操作系统提供的多路复用机制Linux 上的 epoll 等由操作系统帮我们监控所有注册的通道哪个通道有关心的事件发生就标记出来。用户线程调用 select() 的时候实际上是“去操作系统内核里把就绪的通道拿过来”没有就绪就阻塞在那里而不是轮询所有连接并傻傻等待数据。4.2 处理SelectionKey的正确姿势Selector 返回的是一组 SelectionKey每个 Key 绑定了对应的 Channel。完整处理循环大体长这样Selector selector Selector.open(); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { //阻塞直到至少有一个注册的事件发生 selector.select(); IteratorSelectionKey iter selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key iter.next(); // 注意:处理完必须remove,否则会重复处理 iter.remove(); if (key.isAcceptable()) { // 处理新连接 } else if (key.isReadable()) { // 处理可读数据 } } }我练手时踩过一个大坑selectedKeys() 返回的集合不会自动移除已经处理过的 Key如果你不在循环里手动 remove下一次 select() 后同一个 Key 还会出现在集合里。未处理的事件会被重复触发轻则业务重复执行重则把另一侧的服务端打到崩溃。还有个小细节interestOps 是以位掩码形式存在的。你可以这样理解SelectionKey.OP_READ 对应 1二进制是 01SelectionKey.OP_WRITE 对应 4二进制是 100如果同时关心读和写interestOps 就是 1|4 5。后续想追加或移除某些事件的监听用 key.interestOps(key.interestOps() | SelectionKey.OP_READ) 这样按位或与的方式去改直接赋值容易把旧的关注事件冲掉。4.3 空轮询Bug是怎么一回事聊 Selector 绕不开所谓的“空轮询 Bug”。想一想select() 方法明明没有任何事件发生却不阻塞直接返回 0然后循环又立刻再次调用 select()空转浪费 CPU甚至会达到 100%。这个问题在早期的 JDK 版本里出现过尤其是经典的 epoll 空轮询问题。Netty 对它有专门的应对策略——统计空轮询次数超过阈值就重建 Selector把原来的 Channel 全部重新注册一遍。你自己写原生 NIO 时也要有这层防护意识推荐写个计数器记录 select() 返回 0 的次数连续超过一定次数几百次上千次就重置 Selector。核心实现思路如下int emptySelectCount 0; while (true) { int selectCount selector.select(1000); if (selectCount 0) { emptySelectCount; if (emptySelectCount 1000) { // 触发重建Selector } } else { emptySelectCount 0; } }这个策略本身不复杂关键是先有“它会空转”的认知线上真的遇到 CPU 挂高排查时才不会一脸懵。4.4 事件模型与多线程如何配合Selector 只是帮你感知事件真正干活的还是业务线程。常见的分工方案Reactor 单线程一个 Selector 既监听连接事件又处理读写事件代码最简单但千万别在事件循环里做耗时操作否则所有连接都被卡住Reactor 多线程主 Selector 只负责 accept 连接得到的 SocketChannel 再分发给 Worker 线程池里的多个 Selector 去管理读写连接和 IO 分开开心得多主从 Reactor在 Netty 里就是 BossGroup 和 WorkerGroup 的划分BossGroup 处理 acceptWorkerGroup 处理 IO 和业务灵活度还更高一点。我自己写高并发服务时的经验是就算你只有几十个连接也别把所有事情都塞进一个事件循环IO 线程不该碰数据库调用、远程 RPC 这种慢操作。框架层面 Netty 让你在 Handler 里自由发挥但一旦阻塞了 IO 线程整个服务吞吐直接掉好几成。5. 实操环节用三大组件实现一个简易聊天室5.1 需求拆解与整体设计聊完了原理我带你把三大组件串起来。目标很简单用原生 NIO 写一个迷你聊天室不依赖任何框架。服务端能接收多个客户端任何客户端发来的消息都会广播给所有在线客户端。拆解下来大概需要这么几步服务端创建一个 ServerSocketChannel注册 OP_ACCEPT 到 Selector 上Selector 循环处理事件新连接事件就注册 SocketChannel 的 OP_READ可读事件就把数据读入 ByteBuffer再广播给所有已注册的 SocketChannel客户端创建 SocketChannel 连接服务端注册 OP_CONNECT 等待连接建立客户端连接建立后注册 OP_READ同时开一个线程从标准输入读消息通过 Channel 写到服务端。代码实现不需要花里胡哨重点是让你看到三大组件是如何配合的。5.2 服务端核心代码实现先把服务端的骨架写出来public class ChatServer { public static void main(String[] args) throws IOException { // 1. 创建Selector Selector selector Selector.open(); // 2. 创建服务端Channel并绑定端口 ServerSocketChannel serverChannel ServerSocketChannel.open(); serverChannel.configureBlocking(false); serverChannel.socket().bind(new InetSocketAddress(8080)); // 3. 注册OP_ACCEPT事件 serverChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println(ChatServer started on port 8080); while (true) { // 阻塞等待事件 selector.select(); IteratorSelectionKey iter selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key iter.next(); iter.remove(); if (key.isAcceptable()) { // 新连接到达 SocketChannel clientChannel serverChannel.accept(); clientChannel.configureBlocking(false); clientChannel.register(selector, SelectionKey.OP_READ); System.out.println(Client connected: clientChannel.getRemoteAddress()); } else if (key.isReadable()) { // 处理可读事件 SocketChannel clientChannel (SocketChannel) key.channel(); handleRead(clientChannel, selector); } } } } }这里有个细节要提醒你accept() 返回的 SocketChannel 也是要设置成非阻塞的然后才能注册到 Selector。如果你忘记 configureBlocking(false)注册时会报 IllegalBlockingModeException。5.3 消息读取与广播逻辑再看 handleRead 和广播方法private static void handleRead(SocketChannel channel, Selector selector) throws IOException { ByteBuffer buffer ByteBuffer.allocate(1024); int readBytes channel.read(buffer); if (readBytes -1) { // 对端关闭连接 System.out.println(Client closed: channel.getRemoteAddress()); channel.close(); return; } if (readBytes 0) { return; } // 从写模式切换到读模式很重要 buffer.flip(); byte[] bytes new byte[buffer.limit()]; buffer.get(bytes); String message new String(bytes, StandardCharsets.UTF_8); System.out.println(Received: message); // 广播给所有注册在selector上的SocketChannel for (SelectionKey selectionKey : selector.keys()) { if (selectionKey.channel() instanceof SocketChannel) { SocketChannel target (SocketChannel) selectionKey.channel(); if (target.isConnected() target ! channel) { target.write(ByteBuffer.wrap(([ channel.getRemoteAddress() ] message).getBytes(StandardCharsets.UTF_8))); } } } }广播时遍历 selector.keys() 拿所有注册过的通道过滤掉 ServerSocketChannel再过滤掉消息来源通道。selector.keys() 和 selector.selectedKeys() 完全不是一回事前者是当前所有注册的通道后者只是这一轮就绪的通道别搞混。5.4 客户端核心代码实现客户端相对轻量public class ChatClient { public static void main(String[] args) throws IOException { Selector selector Selector.open(); SocketChannel channel SocketChannel.open(); channel.configureBlocking(false); channel.connect(new InetSocketAddress(localhost, 8080)); channel.register(selector, SelectionKey.OP_CONNECT); // 单独开线程读控制台输入并发送 new Thread(() - { BufferedReader reader new BufferedReader(new InputStreamReader(System.in)); try { while (true) { String line reader.readLine(); if (line null || quit.equalsIgnoreCase(line)) { channel.close(); break; } channel.write(ByteBuffer.wrap(line.getBytes(StandardCharsets.UTF_8))); } } catch (IOException e) { e.printStackTrace(); } }).start(); while (true) { selector.select(); IteratorSelectionKey iter selector.selectedKeys().iterator(); while (iter.hasNext()) { SelectionKey key iter.next(); iter.remove(); if (key.isConnectable()) { SocketChannel client (SocketChannel) key.channel(); if (client.isConnectionPending()) { client.finishConnect(); } client.register(selector, SelectionKey.OP_READ); System.out.println(Connected to server); } else if (key.isReadable()) { SocketChannel client (SocketChannel) key.channel(); ByteBuffer buffer ByteBuffer.allocate(1024); client.read(buffer); buffer.flip(); byte[] bytes new byte[buffer.limit()]; buffer.get(bytes); System.out.println(new String(bytes, StandardCharsets.UTF_8)); } } } } }客户端的 connect() 在非阻塞模式下同样是异步返回哪怕 TCP 握手没完成也会立即返回。要等 OP_CONNECT 事件触发后再调用 finishConnect()连接才算真正建立。直接在 connect() 之后立刻写数据大概率会失败。5.5 实测效果与改进方向把服务端跑起来再启动两三个客户端互相发消息输出基本符合预期。每个客户端发什么服务端都能收到而且会广播到其他客户端。这个 Demo 虽然简陋但已经完整走通了“Selector 事件驱动 - Channel 读 - Buffer 存 - Channel 写”的全链路。实际生产肯定不能这么粗糙。改进方向首先是 Buffer 用直接内存并加池化现在每次 allocate(1024) 都在堆内分配并发一高内存分配频率蹭蹭涨其次是一个 Channel 只分配一个固定 1024 字节的 Buffer 不够长消息会截断合理做法是按长度前缀动态扩容再有就是广播用的写线程要防阻塞某个客户端写慢了会拖慢其他人需要引入写队列和限流。这些正是 Netty 已经替我们造好的轮子搞懂了原生 NIO 再看 Netty 会亲切得多。6. 常见问题与排查技巧实录6.1 我的连接总是建立不起来遇到连接一直建立不起来先按这个顺序排查服务端 ServerSocketChannel 是否注册了 OP_ACCEPTSelector 循环是否阻塞在 select() 上没处理事件客户端有没有注册 OP_CONNECT连接事件触发后是否调用了 finishConnect()服务端 accept() 拿到的 SocketChannel 是否设成非阻塞配置了没有就会在 register 时炸异常防火墙和端口占用这类网络层面的老问题用 netstat 看看端口到底有没有在监听。6.2 收到的消息是乱码或者是重复的旧数据百分之百是 Buffer 没处理好。最常见的场景是读取时忘了调 flip()position 还停留在写入后的位置读到一堆旧垃圾数据。再有就是 clear() 之后 position reset 了但数组里旧数据还在新数据只覆盖了前面一部分读的时候如果把 limit 设成 capacity就会把后面没覆盖到的旧数据也读出来。写一个带长度前缀的通信协议能彻底规避这些问题。简单做法是前 4 个字节存消息长度后面跟着消息正文读完后按长度切数据这样就算 Buffer 位置出问题也能快速自查。6.3 服务端CPU飙到100%先用 jstack 抓线程栈。如果堆栈显示线程卡在 select0 方法上那多半就是遇到了 epoll 空轮询。按前面说的统计空轮询次数并重建 Selector 应对。如果堆栈显示线程卡在自己的业务代码里比如数据序列化、查数据库那就要考虑把耗时的业务从事件循环线程挪出去放线程池里面跑。也可以检查一下代码里是不是不小心在事件循环里做了阻塞式 Socket 调用或者 Thread.sleep()一旦事件循环被卡整个 Reactor 模型的吞吐立刻崩掉。6.4 客户端断开后连接没有及时清除TCP 断开有很多状态如果客户端异常断电服务端感知是滞后的read() 很长时间都不会返回 -1。这时候要靠心跳机制兜底。自己写 NIO 时可以在 Channel 上挂一个 lastReadTime后台定时任务定期扫描超时未读就主动 close 连接释放资源。这个思路在 Netty 里就是 IdleStateHandler 在做的事别指望 TCP 的 KeepAlive 能即时发现死连接。6.5 高并发下出现OOM前面说过直接内存泄露的坑。使用 allocateDirect 时如果忘记回收 Buffer堆外内存会被持续占用。如果内存泄漏又发生在 Direct Buffer 上GC 根本盯不住它最后直接内存耗尽抛 OOM。排查时注意用 NMTNative Memory Tracking去观察堆外内存用 NMT 能看到 Direct Buffer 的占用情况定位是哪个业务代码不断创建 Buffer 没有释放。最实际的解决方法是复用池化 Buffer别图省事天天 new。7. 最后聊几句把这三大组件亲手实践一遍之后再回头看 Netty你会发现它的很多设计其实有迹可循。EventLoop 在本质上就是一个绑定了 Selector 的线程Channel 的注册与绑定就是 Selector 在管理网络事件ByteBuf 相比 ByteBuffer 改进了读写索引分离、池化管理、扩容策略大幅提升了开发体验。学框架有个绕不开的过程就是先把底层的轮子造一遍。原生 NIO 确实有不少烦人的地方但正因为自己踩过那些坑用框架时才知道它在背后帮我挡掉了什么排错时就不会手足无措。下一步建议自己去把 JDK 里 NIO 的源码翻一翻重点看下 Windows 和 Linux 上 Selector 的底层实现差异以及 ByteBuffer 各实现类的内部结构。这两块搞明白了Netty 的 EventLoop 和内存池设计基本都能看懂。