☰
Netty底层NIO三大组件深度解析:Channel/Buffer/Selector实战
2026/10/1 17:44:14 网站建设 项目流程

刚开始接触 Netty 那会儿,我就被它的 NIO 核心搞得头晕眼花。网上的教程大多是上来就讲 Pipeline、讲编解码器,绕了一圈回头才发现连最底层的三大组件都没吃透。Channel 是什么、Buffer 怎么读怎么写、Selector 到底怎么搞定上万连接,这些问题通通没搞清楚,看什么都像看天书。

所以这一篇就用大白话把 Netty 底层 NIO 的三大组件——Channel、Buffer、Selector——彻底说清楚,顺便带着你写一个用原生 NIO 实现的简易聊天室 Demo。写这个东西不仅能让你读 Netty 源码时不再犯怵,将来排查线上性能问题和内存问题也很有帮助。

1. 从BIO到NIO:先把底层逻辑理清楚

1.1 BIO的痛点和NIO的解题思路

要说清楚 NIO 的三大组件,得先知道它解决的是谁的问题。Java 早期的网络编程走的是 BIO(Blocking I/O)路线,经典的写法是每来一个客户端就 new 一个线程去处理阻塞在 read() 上的请求。这种方式在连接数量少时没问题,但连接一多线程数暴涨,线程上下文切换的开销能把 CPU 活活拖垮,而且绝大部分线程都在阻塞等待数据,资源利用率低得吓人。

NIO(Non-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,而不是直接封装BIO

Netty 选 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_KEEPALIVE,Java 只能控制开关,底层保活探测间隔等值是系统级别统一配置的,应用层心跳别完全指望它,推荐自己在应用层做心跳。

我把这些参数整理成一张表方便你对照:

参数作用实测感受
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 设为当前 position,position 归零;
  • 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 还是 capacity,get() 读的其实是 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 的 accept;
  • OP_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(); Iterator<SelectionKey> 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,二进制是 01;
  • SelectionKey.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 处理 accept,WorkerGroup 处理 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(); Iterator<SelectionKey> 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(); Iterator<SelectionKey> 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_ACCEPT,Selector 循环是否阻塞在 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。

排查时注意用 NMT(Native 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 和内存池设计基本都能看懂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询