资讯动态

一文吃透Java NIO三大核心:Buffer、Channel与Selector

发布时间:2026/10/14 11:01:29 来源:尧图企业网站定制
这一节笔记对应第一章的后半部分我会把 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 的对比如下维度BIONIO数据载体流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 3limit 仍然等于 64 System.out.println(buffer.position()); // 3这时如果你直接去 get会从 position3 开始读读到的全是你还没写入的零值。所以写完必须“翻面”也就是调用 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 5position 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 positionposition 0从写模式切换到读模式clear()position 0limit 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 线程模型差异化调度的底层依赖。常见的通道大致分三派文件类 FileChannelTCP 网络类 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 线程组负责 acceptWorker 线程组负责读写。在使用通道读写时我习惯遵循一个原则先想清楚这段代码最终是给业务处理层用还是给 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; } SetSelectionKey keys selector.selectedKeys(); IteratorSelectionKey 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_ACCEPTaccept 之后拿到的 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读完再 clearselectedKeys 里的旧事件反复触发没有手动移除已处理 key每次循环结束调用 it.remove()register 时报 IllegalBlockingModeException通道没有 configureBlocking(false)注册 Selector 前必须切换到非阻塞模式服务端 CPU 居高不下无条件注册 OP_WRITE只在缓冲区写满时才注册 OP_WRITE写完移除一次消息被拆成多段解析错乱网络半包问题使用 compact 配合长度字段或引入 FrameDecoder多条消息合在一个包处理异常网络粘包问题按分隔符或消息长度切包直接内存不断增长导致 OOM堆外缓冲区泄漏限制 MaxDirectMemorySize定位未释放的 DirectBufferselect() 一直返回 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 的理解就算真正入门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑