在互联网公司带团队这些年,我最喜欢在技术面试里问一个问题:AIO、BIO 和 NIO 的区别是什么。这问题看起来像八股文,实际上能一层层挖出候选人对底层原理、并发模型和工程取舍的真实理解。我见过太多人第一句能答出“同步阻塞、同步非阻塞、异步非阻塞”,但追问一句“那 NIO 底层是谁在检测连接有没有数据”就卡壳了。也见过不少朋友把这三个模型背得滚瓜烂熟,实际拿到一个高并发服务还是只会用线程池套 BIO,一压测就挂。这篇文章想把这些年面试和被面试里积累的理解完整写下来:三个 IO 模型各自怎么工作、核心区别到底是什么、生产环境怎么选,以及面试官抛出这个问题时,哪些答案最能证明你真的懂。
1. 先搞清楚这三个概念到底在讲什么
1.1 把名字翻译成人话
要理解区别,我建议先把三个名词按字面拆开。
BIO,Blocking IO,同步阻塞 IO。它的特点是一个连接配一个线程,线程在等待连接、等待数据的过程中整个挂起,别的什么事都干不了。
NIO 有两个全称,一个是 Non-blocking IO,一个是 New IO。官方叫 New IO 更严谨,因为它是 Java 1.4 引入的一套全新的 IO API;但大家平时说的“NIO 是非阻塞 IO”,指的是它在模型上的核心能力。它的本质是同步非阻塞,配合多路复用器(Selector)用极少数线程管理大量连接。
AIO,Asynchronous IO,异步非阻塞 IO。JDK 1.7 开始提供,核心是当你发起一个操作之后不用管了,由操作系统把活儿干完,再通过回调或者 Future 把结果送到你手上。
先把结论摆这:BIO 是同步阻塞,NIO 是同步非阻塞,AIO 是异步非阻塞。三个模型在“阻塞/非阻塞”和“同步/异步”这两个维度上刚好占了三种组合,这正是面试题考察的核心坐标系。但光知道结论不够,大家最容易忽略的是:NIO 虽然用到了多路复用这种“看起来很异步”的机制,它整体上仍然是同步的。这个点后面我会详细解释,它也是面试官最爱用来区分“背答案”和“真理解”的试金石。
1.2 同步与异步、阻塞与非阻塞,其实是两组独立概念
很多人把“阻塞”和“同步”当成一回事,这是概念混乱的根源。严格来说,这两个词描述的不是同一个问题。
阻塞和非阻塞,说的是“调用发起后,当前线程怎么办”。你调用 read 去读一个还没有数据到达的 socket,如果线程在那边死等,等到数据来了才返回,这就是阻塞;如果调用立刻返回一个状态,告诉你“暂时没数据”,线程该干嘛干嘛,这就是非阻塞。
同步和异步,说的是“调用结果的获取方式”。如果是同步,你发起调用之后,整个操作过程的每一步都需要自己参与、自己等待结果;如果是异步,你发起调用之后操作系统或框架会接手整个过程,完成后通知你。异步的核心是“结果不是由发起调用的逻辑主动等来的,而是由别人完成后送上门的”。
把这两个维度组合起来,可以得到四种组合:同步阻塞、同步非阻塞、异步阻塞(现实中基本见不到)、异步非阻塞。BIO 是同步阻塞,NIO 是同步非阻塞,AIO 是异步非阻塞。很多人以为“NIO 既然不阻塞,那就是异步的”,其实 NIO 的读操作仍然需要你主动去循环检查、主动去读取,只不过这个过程不阻塞线程而已。真正常见的事故是:面试者脱口而出“NIO 是异步非阻塞”,面试官一听就知道概念没建立起来。
为了直观,我用一个“去餐厅吃饭”的例子类比一下。
- BIO:食堂窗口只有一个,你排着队,前面的人还没打完菜,你就只能干站着等。整个过程自己盯着、自己等。
- 叫号模式(NIO):在餐厅门口取个号,然后你可以去玩手机、聊天,但要时不时看一眼屏幕或者听到叫号才去窗口取餐。你没被强制等待,但你必须主动去查、去响应。
- 外卖(AIO):你下单付款之后完全不用管了,骑手做好送到家门口,系统自动通知你去拿。过程全程由别人代劳。
这三个例子基本就是三种模型的直观印象。接下来逐个深入看它们的代码实现和工程问题。
2. BIO:最诚实也最“耗人”的同步阻塞模型
2.1 一段最原始的 BIO 服务端代码
我带新人准备面试的时候,喜欢先让他们默写一段最简单的 BIO 服务端代码。几乎所有人都能写出来,但很多人的注释都是错的。先看代码:
ServerSocket serverSocket = new ServerSocket(8080); while (true) { // 这一步会阻塞:没有新连接时线程卡在这里 Socket socket = serverSocket.accept(); // 来一个连接就开一个线程,线程与连接一一绑定 new Thread(() -> { try { BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream())); String line; while ((line = in.readLine()) != null) { System.out.println("收到消息: " + line); } } catch (IOException e) { e.printStackTrace(); } }).start(); }这段代码有两个阻塞点:第一个是accept(),它阻塞等待新连接;第二个是readLine(),它阻塞等待对端发送数据。这两个阻塞点意味着什么呢?
对于accept()来说,如果当前一直不来新连接,main 线程就一直挂在 accept 上,整个服务端无法处理任何其他事。对于read()来说,一个连接对应一个线程,如果这个连接的客户端一直不发数据,这个线程就一直挂在 read 上。一个典型的场景是:你开一个长连接,客户端每隔 30 秒才发一个心跳包,那这 30 秒里这个线程 100% 的时间都在空等。连接数量一多,总不能无限开线程吧?所以 BIO 在高并发下天然撑不住。
2.2 线程被空等是 BIO 最致命的浪费
线程本身是有内存成本的。每个线程默认栈大小在 64 位 JVM 上一般是 1MB 左右,虽然可以通过参数调小,但默认值就是这么大。你建 1000 个连接就需要 1000 个线程,光线程栈就占了差不多 1GB 的虚拟内存;再加上线程切换的开销,CPU 有一大部分消耗在“把 CPU 从 A 线程切到 B 线程”的过程中,真正业务计算的能力反而被稀释了。
线程阻塞切换还有一个隐藏问题:JVM 里线程切换需要操作系统介入,频繁的阻塞-唤醒会让系统调用增多。在高连接数、低活跃度的场景下,比如大量设备接入但每个设备偶尔发一条消息,线程池模式的 BIO 表现极其糟糕。
我见过一个真实案例:某内部监控服务用 BIO 实现,连接数到两千多就开始频繁 FullGC 和 CPU 飙高。排查半天发现不是业务问题,而是大量的阻塞线程和随连接数增长导致的频繁上下文切换,把系统拖垮了。后来改成 NIO 模型,用八个线程就把几万条长连接管住了。
2.3 BIO 并没有完全退出历史舞台
说了这么多 BIO 的坏话,但如果你以为 BIO 一无是处,那也是被八股文带偏了。BIO 的优点是模型简单、代码直观、调试容易、逻辑顺序完全符合人类直觉。对连接数少、请求频率高、或者一条连接上逻辑连续且复杂的场景,BIO 配合线程池反而比引入 NIO 框架更划算。
注意,现在生产环境里的 BIO 一般不会像最原始的示例那样每个连接裸开一个线程,而是配合线程池使用:acceptor 线程负责 accept,然后任务丢给线程池执行。这种方式缓解了“来一个连接就建一个线程”的随意创建问题,但本质上没有解决 read 时线程空等的问题——只要数据不 ready,线程池里的线程就被连接占着。
所以 BIO 的适用面是:连接数已知且较小(比如几十上百),或者每条连接都需要大量同步处理逻辑的场景。在微服务内部调用、管理端口、简单工具类服务里,BIO 还经常出现。
3. NIO:用一个线程看住成千上万连接
3.1 三件套:Buffer、Channel、Selector
Java NIO 引入了一套和传统 IO 完全不同的抽象:Buffer(缓冲区)、Channel(通道)、Selector(选择器/多路复用器)。
传统的 BIO 里,数据流是单向的:InputStream 读,OutputStream 写,读写对象集中在“流”上。NIO 里不再是流,而是 Channel,它支持双向读写;读写的方式也变了,所有数据必须先进入 Buffer,然后从 Buffer 写出。Channel 负责读写通道,Buffer 负责数据载体,Selector 负责监控一批 Channel 的事件状态。
很多人不理解“为什么 NIO 强调 Buffer”。我的理解是:非阻塞模式下,一次 read 可能只读到一部分数据,必须有个地方把不完整的数据暂存下来,等下一次可读事件再来时把它续上;写也是一样的道理,可能一次写不完,剩下的要留在 Buffer 里等待下一次可写事件。Buffer 是整个 NIO 模型里必须被开发者亲自管理的东西,这也是 NIO 比 BIO 难用的原因之一。
Selector 是最关键的东西。它做的事情是:让一个线程注册它感兴趣的若干 Channel,然后这个线程调用select()等待,内核会告诉它这些 Channel 里哪些已经有事件就绪了。这就是多路复用——一个线程管多路 IO。你把它理解成“大厅里的叫号屏幕”,一个屏幕可以同时显示几百桌的状态。
3.2 事件驱动:NIO 高并发的底层逻辑
传统 BIO 里,线程阻塞在 read 上,是“线程找人(数据)”。NIO 里反过来了,是“数据找人(线程)”——数据到了,selector 在select()返回的集合里标记出哪个 channel 可读,线程只需要遍历这些就绪的 key 逐个处理。
因为线程不再需要每秒都去问“你有数据了没”,而是内核主动把这些信息汇总后交给它,所以一个线程处理几千、几万个连接就成了可能。这个思路就是事件驱动。
在 Java 里核心步骤是:
- 打开 Selector。
- 把 ServerSocketChannel 配置成非阻塞模式,注册到 Selector 上,关注 OP_ACCEPT 事件。
- 循环里调用
selector.select(),阻塞等待(也可以传超时时间),直到有就绪事件。 - 拿到 selectedKeys,遍历处理:可接受事件就 accept 新连接,并把新 SocketChannel 也注册为 OP_READ;可读事件就从这个 channel 读数据到 Buffer。
这里有个关键点必须强调:selector.select()阻塞等待的是就绪事件,而不是某个 socket 的数据。它仍然是同步的——因为线程在 select 返回之后,必须自己主动去读取数据。数据不会自己送到 Buffer 里,必须由应用线程调用 read 去拷贝。
3.3 一个极简 NIO 服务端骨架
下面是一段非常简化的 NIO 逻辑,足以展示模型长什么样。实际生产我建议直接用 Netty,这里放原生代码是为了让原理看得更清楚:
Selector selector = Selector.open(); ServerSocketChannel serverChannel = ServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8080)); serverChannel.configureBlocking(false); serverChannel.register(selector, SelectionKey.OP_ACCEPT); while (true) { selector.select(); // 阻塞等待就绪事件,也可以带超时 Iterator<SelectionKey> iterator = selector.selectedKeys().iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove(); // 必须手动移除,否则下次还会重复处理 if (key.isAcceptable()) { SocketChannel socketChannel = serverChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { SocketChannel socketChannel = (SocketChannel) key.channel(); ByteBuffer buffer = ByteBuffer.allocate(1024); int len = socketChannel.read(buffer); if (len > 0) { // 处理数据,注意这里读到的是不完整的数据块 buffer.flip(); // do something } } } }对比 BIO 代码,最大的差别就是没有“一个连接一个线程”——accept 之后,SocketChannel 被塞进 selector,主循环继续回去等新事件。所有连接的事件都汇到一次 select 调用上,这就是多路复用的核心。
3.4 NIO 的经典坑:半包、粘包、Buffer 翻转、空轮询
很多朋友从 BIO 跳到 NIO,会踩一堆以前没见过的坑。我挑几个高频的说说。
第一个是半包问题。网络传输是流式的,一个业务消息可能被拆成多个 TCP 段,一次 read 可能只读到消息的一半,甚至一次 read 读到两个消息(粘包)。BIO 时代因为阻塞在 read 上,只要连接不断,readLine 这种字符流 API 能自动帮你按行分隔;而 NIO 里你必须自己在 Buffer 里积累数据,自己判断一个完整消息的边界。这也是为什么工业级 NIO 实现,比如 Netty,要花大力气做拆包器:LineBasedFrameDecoder、LengthFieldBasedFrameDecoder 等等。每次读到的字节到底存不存下来、什么时候算一条完整消息,这块逻辑必须自己负责。
第二个是 Buffer 的读写模式切换。NIO 的 Buffer 有 position、limit、capacity 三个概念,写完数据之后要flip()才能切到读模式,读完清空要clear()或compact()。搞反的话,轻则读到脏数据,重则死循环。我见过不止一个新人在循环里忘了 flip,结果服务端永远只处理同一个字节。最简单的心法是:写完立刻 flip,读完全部或部分处理完后 clear 或 compact,每次操作前问一句“现在这是往哪写、从哪读”。
第三个是空轮询问题。在 Linux 下,NIO 的 select 在某些极端情况下可能在没有就绪事件时也返回 0,如果代码里不处理,就会形成死循环狂转,CPU 飙满。JDK 对这个问题有过修复,但 Netty 仍然做了自己的保护措施:统计 select 空转次数,超过阈值后重建 Selector。这提醒我们,底层依赖并不是永远按文档跑,工程实现里必须有兜底逻辑。
4. AIO:异步非阻塞,一个回调走天下
4.1 AIO 的设计思路
AIO(Asynchronous IO)把“异步”做到了极致。在 NIO 里,即使有 selector 的帮助,线程仍然要主动调用 read 让数据从内核拷贝到用户缓冲区;AIO 则把这一步也省掉了。
AIO 的编程模型是:调用一个异步操作,然后传入一个回调(CompletionHandler)或者拿到一个 Future。操作系统在数据准备好并且拷贝完成之后,直接调用回调方法通知你。也就是说,从“发起读”到“数据到达你的 Buffer”这整个过程,不需要应用线程参与等待。这是和 NIO 最本质的区别。
简单代码示意如下:
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(8080)); server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() { @Override public void completed(AsynchronousSocketChannel ch, Void attachment) { // 继续监听下一个连接,否则只能处理一个连接 server.accept(null, this); ByteBuffer buffer = ByteBuffer.allocate(1024); ch.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer result, ByteBuffer attachment) { // 数据已经读入 buffer,直接处理 attachment.flip(); // 处理业务…… } @Override public void failed(Throwable exc, ByteBuffer attachment) { // 异常处理 } }); } @Override public void failed(Throwable exc, Void attachment) { // 连接失败处理 } });注意,AIO 的 accept 本身也是异步的:你调用一次 accept,它不会立即返回一个连接;连接建立后回调触发,这时你还要再发起一次 accept,否则后续新连接就无法继续处理了。这是异步模型里很容易让人懵的点——为什么要在回调里再调一次 accept?因为每次 asyncAccept 都只接受一个连接,为了持续接收,得在回调里重新注册。
4.2 为什么 AIO 没有取代 NIO
这是面试追问里的一个高段位问题。AIO 概念上比 NIO 更先进,可现实是当前主流高性能框架,比如 Netty、各种游戏服务器中间件,大多还是基于 NIO 模型,而不是 AIO。原因有几个。
第一,操作系统的异步 IO 支持程度不一致。真正的异步 IO 需要内核级支持,Windows 的 IOCP 实现比较完整,但 Linux 下原生 AIO 在文件 IO 方面一直有争议,网络 IO 的高效异步支持在较新内核版本里才算有成熟方案。跨平台行为不一致,让框架很难统一封装。
第二,JDK 的 AIO 实现并不比 NIO 快多少。在 Linux 上,JDK 的 AIO 早期实现甚至是用线程池模拟的“伪异步”:底层还是非阻塞加事件通知,只不过把等待和回调转发给你。这样一来,AIO 在性能上的优势被打折扣,编程复杂度却实实在在增加了。
第三,编程心智负担重。NIO 虽然要处理 Buffer 和事件循环,但事件处理逻辑相对线性;AIO 的回调式写法会让业务代码散落在不同的回调里,在一个连接上同时有多个异步操作时,回调嵌套极其难维护。很多团队评估后觉得,为了异步去堆回调,不如用 NIO 的事件循环自己控制流程。
第四,Netty 本身对 NIO 的优化已经做得很成熟,包括前面说的空轮询保护、拆包解码、内存池复用。在绝大多数业务场景,NIO 加 Netty 的吞吐已经够用,切换到 AIO 没有绝对收益,还引入新风险。工程上“够用且成熟稳定”往往比“概念上前卫”更重要。
所以 AIO 的定位更像是一个概念进阶的存在:你需要理解它的异步思想,但很难在真实项目中大规模使用纯 AIO。面试官问 AIO,其实就想确认两件事:你知道它和 NIO 的本质区别是“回调式的完成通知”;你知道为什么工程界没全面主流化。
5. 面试官真正想听的答案长什么样
5.1 一版能拿到分的回答模板
如果现在有人让我用面试的口吻回答“AIO、BIO 和 NIO 的区别”,我会这样组织。
BIO 是同步阻塞 IO,每个连接由一个线程独占,线程在等待连接或等待数据时阻塞挂起。好处是模型简单,坏处是连接数上来后线程数量爆炸、上下文切换严重,适合连接数少、并发低的场景。
NIO 是同步非阻塞 IO,底层通过 Selector 多路复用,一个线程可以管理数千上万个连接的读写事件。它不要求线程为每个连接空等,而是由内核报告哪些 channel 就绪,线程再去处理这些就绪的 IO。NIO 的要点是它仍然是同步的——数据就绪后,还需要应用线程自己调用 read 去读取。这也是 Netty 等主流高性能框架的基础。
AIO 是异步非阻塞 IO。发起读写之后,由操作系统内核完成数据从内核态到用户态的拷贝,然后通过回调通知应用处理。应用线程不用参与 IO 等待,写起来是回调式或者 Future 式。它从模型上讲最先进,但因为跨平台支持、JDK 实现成熟度等原因,目前没有替代 NIO 成为主流。
这个回答大概两分钟能讲完,结构清晰,维度准确,已经把三个模型最核心的区别都覆盖了。如果你还能补上“NIO 虽然不阻塞,但同步;AIO 不仅不阻塞,还异步”,那就证明你真的分清了“同步/异步”和“阻塞/非阻塞”这两组概念,已经能反超相当一部分候选人。
5.2 用一张表把维度理清楚
为了清晰,我常用一张表来收尾面试笔记。
| 模型 | 阻塞性 | 同步/异步 | 线程模型 | 事件获取方式 | 适用场景 |
|---|---|---|---|---|---|
| BIO | 阻塞 | 同步 | 一个连接一个线程 | 线程阻塞等待数据 | 连接少、逻辑简单 |
| NIO | 非阻塞 | 同步 | 一线程管理多连接(多路复用) | 主动轮询 select 就绪事件后再读 | 高并发、长连接、Netty |
| AIO | 非阻塞 | 异步 | 操作系统完成后回调 | 回调 / Future 获取 | 概念先进、实际少用 |
这张表里真正容易被忽略的一列是“同步/异步”:NIO 是同步但非阻塞。很多人默认 NIO 就是异步,因为它的线程不用盲等。这其实是一种错觉——线程确实不用等数据,但 select 返回之后,线程必须自己把数据 read 出来。这个 read 动作就是同步的部分。
5.3 回答的思路和角度
面试官问这个问题,往往不只是要一个定义,而是在考察你的网络编程功底。我建议的回答顺序是:
- 先给结论:三个词分别对应三种 IO 模型;
- 再给坐标:以“阻塞/非阻塞”和“同步/异步”两个维度给出区分;
- 然后展开:BIO 的线程模型、NIO 的 Selector、AIO 的回调机制;
- 最后落到工程:实际项目里怎么选型、为什么不用更先进的模型、NIO 的注意事项。
注意,千万不要只说结论就停下。一场技术面里,这个问题的价值在于引导出更深层的讨论。你要是能主动把话题引到“什么是多路复用”“AIO 为什么没流行”“Netty 为什么基于 NIO”,面试官通常会顺着往下聊,这时才是真正展示工程判断力的机会。
补充一个实操建议:如果你自己是面试官,听候选人回答时,不必纠结他说得多全。重点听两个信号:他是否知道 NIO 是同步非阻塞,而不是把它说成异步非阻塞;他是否解释得清楚“同步/异步与阻塞/非阻塞是两回事”。这两个信号比背出十个名词更有含金量。
5.4 三个模型的适用场景对比
实战中选哪个,很少靠“哪个先进”,而是靠“匹配场景”。
BIO 适合:系统简单、连接数少、开发周期短、团队对 NIO 不熟。比如内部管理后台、命令行监控工具、少量设备接入的服务。BIO 加线程池在这种场景下的开发效率和可维护性都是最优的。
NIO 适合:高并发、长连接、海量连接低活跃度。典型是 IM 服务、推送服务、各类网关、游戏服务器连接层、RPC 通信底层。生产上不需要自己写原生的 NIO,直接用 Netty 这种成熟框架。
AIO 适合:目前多数还处于技术评估和探索阶段,或者项目明确运行在 AIO 支持很完善的特定环境下,并且团队有足够的异步编程经验。对绝大多数团队而言,我不推荐为了 AIO 而上 AIO。
6. 面试追问与实战避坑笔记
把高频追问和实战避坑整理在一起,算是这篇分享最后的价值沉淀。
6.1 几个高频追问和快速回应
追问一:NIO 不阻塞,为什么还叫同步?
同步与否取决于“发起 IO 操作后,你是否要主动等待数据从内核拷贝到用户空间”。在 NIO 里,即使有 Selector 告诉你某个 socket 可读,你也必须自己调用 read 完成拷贝,应用线程参与了 IO 结果获取的过程,所以是同步。AIO 则是由内核自动完成拷贝并回调,所以异步。
追问二:多路复用和普通轮询有什么本质区别?
普通轮询是应用线程不断去问“有数据吗”,每次都要切换到内核态看一遍状态,成本很高。多路复用(select、poll、epoll)是把一批 socket 的监视任务交给内核,内核汇总哪几个 socket 有事件后统一返回。select 和 poll 仍有线性扫描的问题,epoll 用事件通知机制做到接近 O(1) 的就绪事件通知,这也是 Linux 高并发高性能服务的基石。
追问三:NIO 里的零拷贝和 IO 模型有关系吗?
零拷贝是另一个层面的优化,解决的是数据从内核到用户空间的多次拷贝问题,不属于 IO 模型分类的维度。面试时如果被问到这个,可以把它单独归为“减少数据拷贝次数”的优化,不要和阻塞/非阻塞、同步/异步混为一谈。Netty 里许多传输优化就是靠这种思想,但它不会改变模型本质。
追问四:Netty 为什么不用 AIO?
原因回到第 4.2 节:Linux 生态下 AIO 成熟度不够;JDK 的 AIO 在实践中没有量级化性能优势;回调式编程复杂度高;NIO 加 epoll 在大多数场景已经足够。Netty 同时给用户预留了多种可用模型,但网络 IO 默认路线就是 NIO 事件循环。
6.2 一些切身体会的避坑建议
第一,面试讲 NIO 时,尽量避免只谈理论而没有任何代码印象。即使你实际开发用的是 Netty,也建议把原生 NIO 的极简服务端写一遍,哪怕只是几百行的 Demo。只有自己写过一次 Selector 循环,你才会对“selectedKeys 要 remove”“Buffer 必须 flip”这种细节有肌肉记忆。面试时如果能随手画出核心循环,说服力会强很多。
第二,分析性能时不要凭感觉说“AIO 一定比 NIO 快”。从 NIO 到 AIO,减少的是应用线程等待 IO 的消耗,但引入了回调分发开销和线程池的提交开销。在真实业务里,瓶颈往往在网络带宽、业务处理、GC、数据库,而不是 IO 模型本身。这个问题一旦说错,面试印象分会掉很多。
第三,把“连接数”和“并发数”分开思考。NIO 擅长的是海量连接但低活跃度的场景,密集并发读写反而可能因为 event loop 线程处理高负载逻辑过重而遇到瓶颈。所以在设计高并发服务时,通常也是 IO 线程只做收发,业务逻辑丢到单独的业务线程池,避免把事件循环堵死。这个点也是很多新人踩过的坑:把所有业务都丢到 Netty 的 IO 线程里同步阻塞处理,结果压测时吞吐反而比 BIO 还差。
第四,如果正在准备面试,建议把这个问题和其他几个网络编程关键词串起来复习:TCP 粘包拆包、epoll 与 select 的区别、Reactor 模式与 Proactor 模式、线程模型如何应对高并发。IO 模型并不孤立,一旦把这些串起来,回答这个问题时就能游刃有余地顺势展开,而不是卡在名词背不出来。
我个人带团队时有个习惯:新人对 IO 模型理解到位,我会让他去看一整个 Netty 的 EventLoop 实现,把聊天室、网关这类小项目自己从零写一遍。倒不是鼓励大家都去造框架,而是当你亲手处理过一次粘包、遭遇过一次空轮询导致的 CPU 空转、经历过一次因为忘记 remove selectedKeys 而导致的重复事件处理,你才会真正理解这些模型背后的工程取舍。AIO、BIO 和 NIO 的区别,表面上是一道面试题,实际上是一把尺子:量出的是你对网络编程底层逻辑的熟练程度,而不只是背下三个名词的瞬时记忆。希望这篇文章能帮你把这块地基重新夯实一遍,下一次再被问到这个问题时,可以不只是给出一个漂亮的名词解释,而是能聊出真正的模型细节和实践判断。