这一节笔记对应第一章的后半部分,我会把 NIO 里的 Buffer、Channel、Selector 这三个核心组件一次性串起来讲。很多人学 NIO 觉得 API 又多又碎,今天 position 明天 flip,学完就忘,本质原因是没有先建立一个完整认知模型。这篇适合刚看完基础 IO、准备进入 Netty 源码的小伙伴,也适合写过一些网络程序但一直没系统梳理过 NIO 的开发者。
说实话,我第一次啃这一章时也吃了亏。当时我自信满满地认为 NIO 不过就是“非阻塞读写”,结果一动手写代码,连 ByteBuffer 的 flip 和 clear 都分不清,写的服务端总是读出一堆空数据。后来回炉重看,才真正意识到:NIO 不是 IO 的“新技术”,而是一套完全不同的软件模型。你不把它当事件驱动来理解,就永远只会抄网上的模板代码。
1. Buffer、Channel、Selector:这才是 NIO 的完整拼图
1.1 一句话拆穿 NIO 的本质
NIO 的全称其实有两个解释:New IO 和 Non-blocking IO。很多人说它是“非阻塞 IO”,这个说法不完全准确。更合适的理解是:NIO 提供了一种基于通道(Channel)与缓冲区(Buffer)的 IO 方式,同时支持非阻塞模式。
这里的核心思维转变是什么?传统 Java IO 是面向流的(Stream),你拿着一个 InputStream 在那里等数据,等不到就阻塞线程。NIO 则是面向缓冲区和通道的,线程不再“傻等”,而是先把数据读进 ByteBuffer,再统一处理。等于从“我要数据”变成了“数据来了告诉我,我自己取”。
配套出现的 Selector 则解决并发连接问题。要知道,BIO 时代你写一个聊天室,一个新客户端就开一条线程,连接数上到两三千就很容易内存吃紧、上下文切换暴涨。Selector 让一条线程注册多个通道,轮询哪些通道就绪了,再用事件驱动方式处理,这正是 Netty 底层能够支撑高连接数的根基。
1.2 NIO 与 BIO 在模型上的对决
为了说清楚 NIO 解决了什么问题,我用餐厅做类比:BIO 相当于每一桌都配一个专职服务员,客人不点菜,服务员就得在旁边站着等;客人多了,餐厅只能无限加服务员,工资开销高、管理混乱,高峰期还可能服务不过来。
NIO 是另一位领位员坐在前台。所有客人来了先登记,没排到桌就回去等叫号;哪位客人一抬手示意要倒水,领位员才过去处理。一个领位员能同时服务上百张桌子,不用盯着一桌死等。这就是 Selector 加非阻塞通道的模型。
NIO 和 BIO 的对比如下:
| 维度 | BIO | NIO |
|---|---|---|
| 数据载体 | 流(Stream) | 缓冲区(Buffer) |
| 读写方向 | 单向流 | 通道双向读写 |
| 阻塞方式 | 线程阻塞 | 非阻塞 + 多路复用 |
| 并发模型 | 一线程一连接 | 一线程多连接 |
| 适配场景 | 连接量小、逻辑简单 | 高并发网络服务 |
这张表不用死记,关键结论就一个:当连接不再等于线程时,系统才能支撑更大的并发。理解了这件事,后面 Netty 的 EventLoop 线程模型就很好懂了。
2. Buffer:先搞懂 position、limit、capacity 再下手
2.1 三大属性到底在维护什么
Buffer 是所有 NIO 读写的中转站。你别看它内部不过是个数组,麻烦的是它额外维护了三套状态:capacity、limit、position。
我用机场值机窗口来类比。假设一条安检通道最多同时接待 10 个人,这个 10 就是 capacity(容量)。当前正在被服务的旅客位置是 position(位置)。而 limit 是一个“本次业务范围指示牌”:如果是进港高峰,这次值机窗口只允许服务前 5 位旅客,limit 就是 5;如果是全天候服务,limit 可能等于 capacity。
拿 ByteBuffer 来说,capacity 在整个生命周期里不会变,除非重新分配。position 表示下一个要读或写的位置,每次读写一个元素它就自动向前走。limit 则是读写操作不可越过的边界。这三个名词不搞熟,后面每一步都会踩坑。
举一个最简单例子:
ByteBuffer buffer = ByteBuffer.allocate(64); buffer.put((byte) 1); buffer.put((byte) 2); buffer.put((byte) 3); // put 之后 position = 3,limit 仍然等于 64 System.out.println(buffer.position()); // 3这时如果你直接去 get,会从 position=3 开始读,读到的全是你还没写入的零值。所以写完必须“翻面”,也就是调用 flip()。
2.2 flip、clear、compact,三兄弟的分工
flip 是 NIO 里使用频次最高的方法,它做两件事:把 limit 设成当前 position,再把 position 归零。意思是“我已经写完数据了,现在开始读这些数据吧”。
ByteBuffer buffer = ByteBuffer.allocate(16); String msg = "hello"; buffer.put(msg.getBytes(StandardCharsets.UTF_8)); buffer.flip(); // 此时 limit = 5,position = 0 while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); }如果你写完不 flip 就直接读,会造成两种后果:要么读到 position 之后的旧数据,要么在 limit 大于实际数据长度时,把后面没写过的地方也当成了有效数据读出来。我早期调接口时返回内容末尾多了一堆“空字符”,就是这个问题。
clear 和 compact 则是读完数据之后的选择。clear 会把 position 归零、limit 设回 capacity,相当于把整块 Buffer 状态重置,这就是你准备重新写数据前要做的事。但注意,clear 只重置状态,不会清空底层数组里的残留数据,但因为 position 会覆盖老数据,所以一般不影响正确性。
compact 则用于“只读了一部分、还想保留剩余数据”的场景。它会把未读的数据往数组头部搬,然后把 position 指向未读数据的末尾,limit 设为 capacity。这时候你继续 put,不会覆盖掉未读数据。这在处理半包粘包时非常有用,后续讲拆包时你会经常碰到 compact。
三者可以总结为:
| 方法 | 核心动作 | 使用场景 |
|---|---|---|
| flip() | limit = position,position = 0 | 从写模式切换到读模式 |
| clear() | position = 0,limit = capacity | 准备重新写入前重置 |
| compact() | 压缩未读数据到头部 | 读完部分数据,保留剩余继续写 |
2.3 堆内缓冲区与直接缓冲区怎么选
ByteBuffer.allocate(64) 在堆内分配,ByteBuffer.allocateDirect(64) 在堆外分配,这两个的差别也是高频考点。
堆内缓冲区是 JVM 管理的普通字节数组,创建和释放都比较轻,但进行网络 IO 或文件 IO 时,JVM 需要先把数据从堆内复制到操作系统直接访问的堆外内存,多一次复制。直接缓冲区是 DirectByteBuffer,直接分配在堆外。IO 操作能把数据直接读进这块内存,少一次复制,吞吐量更高,但创建和销毁成本比堆内贵得多,而且不太受常规 GC 控制,一旦泄漏很难排查。
实际项目里没有绝对答案。长期复用的大缓冲区、频繁网络 IO 场景,比如 Netty 的传输缓冲,建议优先使用直接缓冲区。短生命周期的小数据块,或者你只是测试时拼接一下协议内容,用堆内缓冲区就够。
注意:直接内存不要无脑申请。生产环境一般通过
-XX:MaxDirectMemorySize限制大小,否则很多服务看起来堆内存正常,却仍被 OutOfMemoryError 击垮,问题可能就出在堆外。
我见过有人心血来潮把每个请求的响应都 allocateDirect 一次,结果压测时整体性能反而没有提升,还把系统的本地内存吃光了。这是典型的“只盯着减少复制次数,不考虑分配成本”的误区。
3. Channel:所有 IO 操作真正的入口
3.1 通道和流不是一个东西
NIO 里如果你只操作 Buffer 其实做不了任何 IO,真正连接外部数据源的是 Channel(通道)。流是单向的,InputStream 只能读,OutputStream 只能写。Channel 正好相反,是双向的,既能写也能读,甚至你可以在一个通道上同时进行读写操作。
通道还有一个特点是“可异步”。BIO 下你想建立一个网络连接,accept() 会一直阻塞到有客户端来。NIO 里 ServerSocketChannel 配置成非阻塞后,accept() 没有连接到达时直接返回 null,调用方可以先干别的事,这个就是 Netty 线程模型差异化调度的底层依赖。
常见的通道大致分三派:文件类 FileChannel,TCP 网络类 SocketChannel、ServerSocketChannel,以及 UDP 类 DatagramChannel。FileChannel 因为文件数据好定位,还支持 transferTo 和 transferFrom 这类零拷贝操作。SocketChannel 则是网络编程的主战场,也是放入 Selector 进行多路复用最典型的通道。
3.2 FileChannel 与网络通道的用法差异
FileChannel 对本地文件做读写时一般走阻塞方式,因为文件 IO 的“就绪”状态没有网络 IO 那么复杂,而且文件通道不像 SocketChannel 那样支持注册到 Selector 上。道理也简单:你要读一个文件,文件内容就在磁盘上,内核很清楚什么时候数据可读,不需要一个多路复用器来帮你监听“异步读事件”。
网络通道则默认也是阻塞模式。这一点很多人会忽略:你 new 一个 SocketChannel 直接注册进 Selector,程序会抛 IllegalBlockingModeException。必须先调用 configureBlocking(false) 才能注册。这句代码是网络 NIO 程序的开关,没写它,后面所有非阻塞设计都等于白搭。
ServerSocketChannel 和 SocketChannel 的区别也很直观:前者监听端口等着别人来连,后者代表一个已经建立的 TCP 连接。一旦 accept() 拿到了 SocketChannel,这个连接就能被注册到 Selector 上的 OP_READ 等事件。Netty 其实也保留了这个思路,Boss 线程组负责 accept,Worker 线程组负责读写。
在使用通道读写时,我习惯遵循一个原则:先想清楚这段代码最终是给业务处理层用,还是给 IO 传输层用。如果是 IO 传输层,Buffer 的读写模式必须严格管理。如果是业务层,最好尽早把 ByteBuffer 转成业务对象,别让业务代码跟 Buffer 状态搅在一起。Netty 的 ByteBuf 在这里做了大量改进,后文会对比说明。
4. Selector:非阻塞网络 IO 的灵魂
4.1 一个线程怎么管理成千上万个连接
Selector 做的事情是“事件登记”:你把感兴趣的通道和事件(比如可读、可写、可连接)注册给 Selector,然后调用 select() 阻塞等待,一旦有事件发生,它返回就绪通道集合。这个机制在内核层面通常由 epoll 或 kqueue 支撑,用户态只用一个线程就能轮询海量连接。
这就是“多路复用”的含义:多个网络连接复用同一条线程。并非每个连接都派一个专职线程去伺候,而是由一个调度员盯着所有连接,哪个连接有数据读,调度员才会唤醒那个连接对应的处理逻辑。
我随手整理一个最简服务端流程,大家可以直接参考:
Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.bind(new InetSocketAddress(8080)); ssc.register(selector, SelectionKey.OP_ACCEPT); while (true) { // 阻塞等待事件 if (selector.select() == 0) { continue; } Set<SelectionKey> keys = selector.selectedKeys(); Iterator<SelectionKey> it = keys.iterator(); while (it.hasNext()) { SelectionKey key = it.next(); if (key.isAcceptable()) { SocketChannel sc = ssc.accept(); sc.configureBlocking(false); sc.register(selector, SelectionKey.OP_READ); System.out.println("新连接接入"); } else if (key.isReadable()) { SocketChannel sc = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int readBytes = sc.read(buffer); if (readBytes == -1) { sc.close(); continue; } buffer.flip(); String msg = StandardCharsets.UTF_8.decode(buffer).toString(); System.out.println("收到:" + msg); buffer.clear(); } // 重点:必须手动移除 it.remove(); } }这个例子麻雀虽小,但把 selector-select-处理-移除的闭环走完了。你还会发现,所有业务逻辑都记在 SelectionKey 里,可以通过 key.attachment() 挂上自定义对象,比如会话上下文、协议解析状态。
4.2 手写最简 Selector 服务端时应该关注的细节
我写这套代码时踩过几个坎,这里直接说结论。第一个是清空 selectedKeys。select() 返回后,SelectedKeys 集合不会自动清理,如果你忘了 it.remove(),处理过的 key 下回还会继续留在集合里。当连接多了,可能出现明明没有新事件,旧事件却反复被触发的情况,CPU 直接空转。这个坑我在初学 NIO 时被折腾了很久,后来才发现是集合没有手动清空。
第二个是 register 的时机。ServerSocketChannel 注册的是 OP_ACCEPT,accept 之后拿到的 SocketChannel 还要再注册 OP_READ 或 OP_WRITE。不能在 ServerSocketChannel 上注册 OP_READ,也不能在未配置非阻塞的 SocketChannel 上注册任何事件,否则会抛异常。
第三个是 select() 与 selectNow() 的选择。select() 是阻塞轮询,适合写服务端主循环。selectNow() 不阻塞,立即返回当前就绪数量,适合做定时检测或者非阻塞轮询。写高性能服务时,我不建议在空循环里疯狂调用 selectNow(),会让 CPU 白白空转,最好配合 sleep 或使用带超时参数的 select(timeout) 来避免。
OP_WRITE 也要小心。很多人以为注册 OP_WRITE 就能在可写时立刻发送,但实际上 TCP 发送缓冲区大多数时候都是可写状态,这意味着 select() 会立刻返回,CPU 会疯狂触发写事件。真正经验是:不要默认注册 OP_WRITE,先尝试直接写,写不下了、返回 0 或缓冲区满时,再注册 OP_WRITE,等可写事件触发后再继续写。这属于我踩过一次就再也忘不了的坑。
4.3 半包、粘包:NIO 绕不开的话题
使用 NIO 时,你每次 read 能读到的字节是不固定的。一次消息可能分几次到达,也可能多个消息写在一个 TCP 段里。这就是半包和粘包问题。比如客户端发了一条“你好”,服务端一次读出来的可能只有“你”,也可能一次读出了“你好再见”,你很难直接按一次 read 的数据解析完整业务消息。
解决这种问题通常有两种风格:一种是定长帧,每个消息固定 100 字节,不够就补;另一种是分隔符,比如行分隔符或自定义结束符。更通用的是在消息头部加长度字段,先读长度再读内容。Netty 里对应的解码器有 FixedLengthFrameDecoder、LineBasedFrameDecoder、LengthFieldBasedFrameDecoder,底层思想都来自这里。
如果自己用原生 NIO 写,我建议直接用 ByteBuffer compact 机制:每次 read 完,先把数据翻转,再判断是否满足一条完整消息。不满足就 compact 保留剩余,下次继续读。这就是前面讲 compact 的经典实战场景。
5. 从原生 NIO 到 Netty:框架为什么存在
5.1 原生 NIO 的短板在哪里
如果你真的用原生 NIO 开发过生产项目,就会知道 API 只是一部分,真正的痛苦在工程化。你要自己管理线程池,自己拆包组包,自己写断线重连、心跳检测,还要考虑如何优雅关闭连接,避免堆外内存泄漏。这些工作非常琐碎,而且极易出错。
比如网络程序常见的拆包,我得为每个连接定义一个累计缓冲,每次读完数据要判断是否凑齐一个完整包。数据不完整时要用 compact 保留,否则下一条数据来了老数据就被覆盖。这些逻辑在 Netty 中被抽成了一个个 decode、frame 类,开箱即用。
另一个痛点是线程模型需要自己拼装。我在某服务器项目中,想把连接的读、写、业务处理分离开,自己设计了两组线程池。结果因为读写时序没有协调好,经常出现一个连接同时被两个线程处理,数据竞态问题排查了一整天。Netty 的 EventLoop 线程模型强调一个连接在一个线程内串行执行,从根源上消除了这种交错竞争。
5.2 Netty 究竟帮我们做了哪些抽象
Netty 在 NIO 之上做了一系列模型升级。缓冲区方面,它用 ByteBuf 替换了 ByteBuffer,不再需要手动 flip,读写指针分离,读取和写入互不干扰。这个设计相当聪明,读写模式切换的坑直接从根源上消灭了。
处理事件方面,Netty 的 ChannelHandler 像管道一样串联起来,入站和出站事件各走各的数组。后面的节流、协议解析、日志、编解码,都只是往管道里添加自定义 Handler。原生 NIO 里这种逻辑你得自己写在 selectionKey 的处理分支里,很容易变成一大坨 if-else。
网络协议方面,Netty 内部自带空闲检测 IdleStateHandler 和拆包粘包处理类,这些都是高并发场景里的高频需求,不需要每个团队都重复造轮子。可以说,你读完原生 NIO 再来看 Netty,会发现它的每个抽象都能在 NIO 的某个痛点上找到对应出处。
所以再回头看这篇笔记的标题:第一章“了解 Java NIO”并不是介绍一个过时 API,而是给你一张地图,后面看 Netty 源码时,你才知道它为什么要创建 EventLoop、为什么要设计 Pipeline、为什么要用 ByteBuf。学完这些再进入 Netty 的世界,就不会觉得云里雾里了。
6. 常见问题速查与避坑经验
我把实操中容易踩的高频问题整理成了一张表,读完可以直接对应排查。
| 典型症状 | 根本原因 | 解决建议 |
|---|---|---|
| read 出来的数据带一堆空内容 | put 后没有 flip 就开始读 | 写完立刻 flip,读完再 clear |
| selectedKeys 里的旧事件反复触发 | 没有手动移除已处理 key | 每次循环结束调用 it.remove() |
| register 时报 IllegalBlockingModeException | 通道没有 configureBlocking(false) | 注册 Selector 前必须切换到非阻塞模式 |
| 服务端 CPU 居高不下 | 无条件注册 OP_WRITE | 只在缓冲区写满时才注册 OP_WRITE,写完移除 |
| 一次消息被拆成多段,解析错乱 | 网络半包问题 | 使用 compact 配合长度字段,或引入 FrameDecoder |
| 多条消息合在一个包,处理异常 | 网络粘包问题 | 按分隔符或消息长度切包 |
| 直接内存不断增长导致 OOM | 堆外缓冲区泄漏 | 限制 MaxDirectMemorySize,定位未释放的 DirectBuffer |
| select() 一直返回 0 空转 | 空轮询 bug 或超时设置不合理 | 用带超时的 select(1000),统计空轮询次数便于监控 |
6.1 与 Buffer 操作相关的典型问题
我先说一个所有人都遇到过的经典场景。写完 ByteBuffer 忘记调用 flip,然后立即调用 get() 读数据。由于 position 还在写入结束位置,读出来的内容跟预期完全对不上,你甚至会读到一堆 0。我建议每次调 get 之前,先用 hasRemaining() 打印一下 position 和 limit,能快速定位问题。
还有两种极端情况。一种是 clear 之后继续写 Buffer,写的数据量超过 capacity,就会出现 BufferOverflowException。这是好事,它帮你避免写出越界数据。另一种是读模式下调用了 put,会抛出 BufferUnderflowException 或读到错误数据,根本原因还是读写状态没明确。
实操建议:写代码时把“写数据 → flip → 读数据 → clear/compact”这四个动作当成固定流程,我每次写完都会检查这四个动作是不是齐了。少了任何一个,日志里的脏数据随时会找上门。
我还会在调试阶段用System.out.println(buffer.toString())打印 Buffer 的当前状态,观察 position 和 limit 是否符合预期。这个习惯帮我发现了很多隐藏问题。
6.2 与 Selector 使用相关的典型问题
Selector 最常见的坑是 select() 返回后遗漏了 selectedKeys 清理。因为 select() 并不会从集合里移除可能已过期的 key 对象,只会在新事件到来时把旧 key 再次加入,如果你不手动删除,相当于同一个事件一直被重复消费。这个问题的表现很隐蔽,连接少时看不出问题,连接一多就变得极其明显。
另一个是大量连接同时关闭时,Selector 的 key 需要 cancel 并关闭通道,否则 Selector 内部还持有大量无效 Channel 引用,造成资源堆积。关闭 SocketChannel 时最好同时把 key.cancel() 执行一次,确保注册关系彻底解除。
6.3 半包粘包是 NIO 的原罪吗
半包粘包不是 NIO 自身的问题,TCP 是面向流的协议,它本身不按业务消息切分。UDP 才有消息边界,TCP 没有。所以你用 BIO 同样会遇到半包粘包,只是很多 BIO 初学者写的是短连接,连上就发一条消息然后断开,问题不容易暴露。长连接一旦复用到极致,网络层天然就会把多段业务数据混在一起或者拆开传输。
对协议解析,我的意见是设计时就定好长度字段。最稳妥的格式是“消息头 + 业务数据”,消息头里写清楚后面的长度。接收端先读消息头,再按长度读数据,读不够就继续等,读够了就处理,然后回到下一轮循环。
这套思路在 Netty 中已经有 LengthFieldBasedFrameDecoder 封装好了,但即使你用原生 NIO,也可以自己实现一版。关键点就是每轮 read 完成后,检查 accumulate Buffer 里是否已经有一条完整消息。没有就 compact,然后继续读;有就切片处理,然后清掉已消费数据。这就是网络框架里最通用的拆包模型。
我在实际开发中还有一个习惯:给协议定义类时,直接让解码器返回一个包含“消息长度+消息内容”的对象,而不是直接返回裸字符串。这样业务层在处理时能清楚知道消息边界,不会为了判断“这条消息有没有读完”再引入错误的状态管理。
7. 从笔记出发再说几句
这一章学完之后,我最大的体会是:NIO 不是在“读写”数据,而是在“调度”数据。你得把网络连接当作源源不断的事件来源,把 Selector 当作事件分发器,把 Buffer 当作临时存储,把 Channel 当作传输管道。用这个模型去套 Netty,整个框架的结构都清晰起来了。
我自己平时很少直接基于原生 NIO 写服务器业务代码,遇到真实场景多半直接用 Netty。但每次排查性能问题时,脑子里浮现的都是这一章讲过的 Selector 与 Buffer 的画面。没有这些底层认知,调优时根本不知道该从哪里切入。比如连接数上来后突然 CPU 高,我第一反应就会想到是不是误注册了 OP_WRITE,或者 selectedKeys 没有及时清理。这些经验,最终都来自你先啃底层的那些基础功夫。
最后给一条私人建议:别急着往下学下一章,先把文中的服务端代码抄下来,亲手跑一遍。可以给服务端加一个整数字段来记录累计 accept 的连接数,再开三个客户端同时连上来观察事件触发顺序。等你亲眼看到 Selector 在不同连接之间轮流分发事件,你对 NIO 的理解就算真正入门了。