BIO / NIO / AIO 对比

面试的时候,经常会被问到"BIO、NIO、AIO 有什么区别"。

大多数人能说出来: BIO 是阻塞的,NIO 是非阻塞的,AIO 是异步的。但追问一下"NIO 为什么是非阻塞"、"AIO 为什么是异步"、"它们各自适合什么场景",很多人就答不上来了。

今天我们把这个知识点彻底梳理清楚。

一、三种模型的核心区别

1.1 表格对比

维度BIONIOAIO
全称Blocking IONon-blocking IOAsynchronous IO
中文阻塞 IO非阻塞 IO异步 IO
IO 等待阻塞等待非阻塞,轮询异步回调
线程模型1 连接 1 线程1 线程 N 连接1 线程 N 连接
数据就绪通知select/epoll 轮询内核回调通知
数据读取同步读取同步读取可能已读完
引入版本JDK 1.0JDK 1.4JDK 7
API 复杂度简单中等较复杂

1.2 【直观类比】

还是用餐厅点餐来类比:

  • BIO:你站在柜台前等,服务员现场做,做好才给你。期间你只能站着等,什么也干不了。
  • NIO:你拿到一个号码,自己每隔几秒去柜台问一下"好了吗?"——你被解放出来,可以坐着玩手机,但还得自己去问。
  • AIO:你拿到号码,服务员说"好了我叫您"。你去玩手机,手机响了告诉你来取餐——你完全不用管。

1.3 工作原理图

BIO:
  Thread ──→ accept() [阻塞] ──→ read() [阻塞] ──→ write() [阻塞] ──→ 结束
              连接1

  Thread ──→ accept() [阻塞] ──→ read() [阻塞] ──→ write() [阻塞] ──→ 结束
              连接2

NIO:
  Selector ──→ accept 事件 ──→ 处理

     ├───→ read 事件 ──→ 处理

     └───→ write 事件 ──→ 处理

AIO:
  发起异步读 ──→ 回调线程处理完成 ──→ 回调通知

二、代码对比

2.1 BIO:简单直接

// BIO 服务器
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket socket = server.accept(); // 阻塞
    new Thread(() -> {
        try {
            InputStream in = socket.getInputStream();
            byte[] buf = new byte[1024];
            int n = in.read(buf); // 阻塞
            // 处理数据...
            OutputStream out = socket.getOutputStream();
            out.write(response);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }).start();
}

2.2 NIO:事件驱动

// NIO 服务器
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select(); // 阻塞,直到有事件就绪
    for (SelectionKey key : selector.selectedKeys()) {
        if (key.isAcceptable()) {
            // 处理 accept
        }
        if (key.isReadable()) {
            // 处理 read
        }
        if (key.isWritable()) {
            // 处理 write
        }
    }
}

2.3 AIO:回调驱动

// AIO 服务器
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));

server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
    @Override
    public void completed(AsynchronousSocketChannel client, Void attachment) {
        server.accept(null, this); // 继续接受
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        client.read(buffer, null, new CompletionHandler<Integer, ByteBuffer>() {
            @Override
            public void completed(Integer result, ByteBuffer buffer) {
                // 处理数据
            }
        });
    }
});

// 主线程做别的事
while (true) { Thread.sleep(1000); }

三、适用场景分析

3.1 BIO 的适用场景

// 适合 BIO 的情况:
// 1. 连接数少(`<` 100)
// 2. 每个连接需要持续交互
// 3. 逻辑简单,延迟要求不高
// 4. 开发周期紧,代码要简单

优点:

  • 代码简单直观,容易理解和调试
  • 没有复杂的回调嵌套
  • 适合业务逻辑简单的场景

缺点:

  • 连接数增加时,线程数线性增长
  • 线程资源消耗大
  • CPU 利用率低(很多线程在等 IO)

3.2 NIO 的适用场景

// 适合 NIO 的情况:
// 1. 连接数多(> 1000)
// 2. 每个连接活跃度低(大部分时间在等数据)
// 3. 需要自己控制 IO 逻辑
// 4. 如:聊天服务器、长连接网关、代理服务器

优点:

  • 单线程可以管理大量连接
  • 线程资源消耗低
  • 适合 IO 密集型场景

缺点:

  • 代码复杂度较高
  • 需要处理半包、粘包问题
  • selectorKey 管理复杂

3.3 AIO 的适用场景

// 适合 AIO 的情况:
// 1. 连接数非常高(> 10000)
// 2. 需要极致性能
// 3. 能接受复杂的异步编程模型
// 4. 数据量不大的场景

优点:

  • 真正的异步,代码更简洁
  • 理论上性能最好
  • 不需要自己轮询

缺点:

  • 回调嵌套,代码难维护
  • 调试困难
  • Linux 早期实现并不完美
  • 社区生态不如 NIO/Netty

四、性能对比

4.1 吞吐量对比

并发连接数 ──→ 吞吐量

BIO:    /  (很快达到瓶颈)
NIO:   /   (线性增长)
AIO:  /    (比 NIO 稍高)

实际上,在大多数场景下,NIO 和 AIO 的性能差距没有想象中大。因为真正的瓶颈往往在业务逻辑而不是 IO 模型。

4.2 延迟对比

模型平均延迟99 分位延迟
BIO较高高(线程竞争)
NIO
AIO

4.3 内存消耗对比

假设每个线程栈 512KB,10000 连接:

BIO:      10000 线程 × 512KB = 5GB
NIO:      1 线程 + Buffer = 几十 MB
AIO:      回调线程池 + Buffer = 几十 MB

NIO 和 AIO 在内存消耗上差异不大,主要优势是远小于 BIO。

五、生产选型决策

5.1 选型决策树

需要支持多少并发连接?

├─ `<` 100 连接 ──→ BIO(简单够用)

├─ 100 ~ 10000 ──→ NIO 或 Netty(成熟稳定)

└─ > 10000 ──→ NIO/Netty 或 AIO(看团队能力)

5.2 实际案例

// 案例1:内部工具,10 个用户
// 选 BIO,简单够用
ServerSocket server = new ServerSocket(8080);

// 案例2:聊天服务器,10 万在线用户
// 选 Netty(基于 NIO),成熟生态
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

// 案例3:文件传输服务,高并发
// 可以考虑 NIO + sendfile 零拷贝
FileChannel.fromPath("largefile.txt").transferTo(...)

5.3 Netty vs 原生 NIO

很多同学问:既然 JDK 提供了 NIO/AIO,为什么还要用 Netty?

原生 NIO/AIO 问题:              Netty 解决方案:
├─ API 复杂                    ├─ 封装简单 API
├─ 半包粘包要自己处理          ├─ 内置编解码器
├─ BUG 多(History of NIO bugs)├─ 经过大量生产验证
├─ Buffer 管理麻烦              ├─ ByteBuf 自动管理
├─ 线程模型要自己写            ├─ 多种 Reactor 模型开箱即用
└─ 空轮询 Bug                   └─ 处理各种 Edge Case
💡

除非是对性能或内存有极致要求,或者在写底层库,否则直接用 Netty 是更务实的选择。它封装了 NIO 的复杂性,提供了更友好的 API 和更完善的生态。

六、面试追问链

第一层:基础区别

面试官问:"BIO、NIO、AIO 的区别是什么?"

BIO 是阻塞 IO,一个连接一个线程,线程在等待 IO 时什么也干不了;NIO 是非阻塞 IO,通过 Selector 轮询多个连接,只有就绪的才处理;AIO 是异步 IO,数据就绪后系统主动回调通知。

第二层:原理深入

面试官追问:"NIO 为什么能做到单线程管理多连接?"

依赖操作系统的 select/poll/epoll 机制。Channel 注册到 Selector 后,Selector 批量管理所有 Channel 的文件描述符。一次 select 调用可以检查所有 Channel 的状态,返回就绪的 Channel。线程不需要为每个连接都等待。

第三层:AIO 原理

面试官追问:"AIO 和 NIO 在实现上最大的区别是什么?"

NIO 的 read/write 调用仍然是同步的,Selector 只是告诉你"这个 Channel 可以读了",你还是要自己调用 read()。AIO 的 read/write 是异步的,返回时就告诉你成功了(或者失败),数据可能已经处理完了。

第四层:选型决策

面试官追问:"什么情况下你会选 BIO 而不是 NIO?"

当连接数少(<100)、业务逻辑简单、或者需要快速开发时,BIO 更合适。另外,如果团队不熟悉 NIO,强行上 NIO 可能引入更多问题。技术选型要结合实际情况,不是越"高级"越好。

【学习小结】

  • BIO:简单但低效,适合低并发
  • NIO:高效复杂,适合高并发,Netty 是主流选择
  • AIO:真正的异步,但编程复杂,生态不如 NIO
  • 选型要看连接数、团队能力、项目周期
  • 大多数场景 NIO/Netty 是最优解