图 1:本文封面,多条连接汇入一个方框,就是 NIO 的全部直觉。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。它表面考三个类的用法,实际考的是线程模型:为什么 BIO 一个连接要配一个线程,NIO 凭什么一个线程能看住成百上千条连接。
想象面试现场:「说说 Java NIO 的 Channel、Buffer、Selector。它和 BIO 的本质区别是什么?什么场景选哪个?」
核心结论一句话
BIO 的线程阻塞在 read 上干等,连接数等于线程数;NIO 把「等数据」这件事从每个线程手里收上来,交给一个 Selector 统一问操作系统「谁就绪了」,单个线程轮询处理。差别不在 API,在线程和连接的配比:1 比 1,还是 1 比 N。
三件套各自的角色。
Channel 是连接的抽象,双向通道——既能读也能写,这是它和 BIO 单向流的第一处不同;读写的终点都是 Buffer。Buffer 是一块带游标的内存:写入时 position 往后走,写满要读之前先调 flip 把游标拨回起点切换成读模式——新手一半的 NIO bug 都是忘了 flip。Selector 是调度中枢:所有 Channel 注册到同一个 Selector 上并声明自己感兴趣的事件(接收连接、可读、可写),线程调用 select 阻塞等待,谁的注册事件就绪了,Selector 就把谁的钥匙(SelectionKey)放进就绪集合,线程遍历集合逐个处理。JDK 官方文档对 Selector 的定义就一句话:SelectableChannel 的多路复用器。
协作流程走一遍。
服务端启动:ServerSocketChannel 注册到 Selector,声明对「接收连接」感兴趣。select 返回,有客户端接入,accept 出新的 SocketChannel,设为非阻塞,再注册进同一个 Selector,声明对「读」感兴趣。之后线程就在一个循环里打转:select 等就绪、拿到钥匙集合、逐个处理——是读事件就从 Channel 读进 Buffer,处理完写回。一个线程,所有连接。要处理的连接挂了,取消钥匙关闭 Channel,循环继续。
和 BIO 并排比一次。
BIO 模型下,每个连接独占一个线程,线程的大半生阻塞在 read 上等对端说话。连接少而重——几十个长连接、每条都在持续传数据——BIO 无可厚非,代码直白好维护。连接多而慢——网关、推送、长会话,成千上万条连接大部分时间在沉默——BIO 的线程数随连接数线性膨胀,内存栈空间和上下文切换先扛不住。NIO 用一个线程托管成百上千条慢连接,代价同样明码标价:编程模型复杂,Buffer 要自己管,TCP 是字节流所以拆包粘包要自己处理,读出来的半条消息要自己攒。这份复杂度就是 Netty 这类框架存在的原因——它把钥匙遍历、缓冲管理、半包处理全部封装掉,业务只写处理器。
图 2:三件套协作环与 BIO 对照,选型结论在底部。
可能的追问与易错点。
「NIO 是非阻塞的,为什么 select 还会阻塞?」两个阻塞不是一回事:BIO 的阻塞是线程绑死在一条连接上,没数据就干等;select 的阻塞是线程等在「所有连接的事件总管」上,任何一条连接就绪都会把它唤醒——一个线程的等待覆盖全部连接。「NIO 适合做文件读写吗?」不适合:文件通道不支持非阻塞模式,多路复用是为网络连接设计的,文件场景该看的是异步文件通道。「为什么生产上都用 Netty 而不是裸 NIO?」除了半包与缓冲,还有一堆工程细节:空轮询 bug 的历史教训、线程模型的主从分工、内存池化——裸 NIO 是理解原理的最小集,不是生产的推荐形态。易错点有三:一是把「非阻塞」理解成「更快」——单条连接的数据搬运不会变快,变的是连接承载能力;二是忘了 flip 导致读到空 Buffer;三是遍历就绪钥匙时不 remove,同一事件被重复处理。
回到面试现场,建议的答题节奏:先一句话给线程模型对比,再讲三件套角色与协作流程,然后用「连接少而重选 BIO、连接多而慢选 NIO」给选型结论,最后主动提半包处理与 Netty——能讲出裸 NIO 的工程代价,才说明你知道它真实的样子。高并发推理网关正是 NIO 的主场:大量慢生成连接挂在网关上,一个 Selector 线程托管全部,就是 Netty 与各类网关框架的地基。
你写过裸 NIO 吗,还是被 Netty 直接保护起来了?欢迎聊聊。