BIO、NIO、AIO 有什么区别?多路复用是什么?

2026年 阅读约 9 分钟 面试指南 · Java面试

深入解析Java三种IO模型:BIO一连接一线程的瓶颈、NIO三大件Buffer/Channel/Selector、select/poll/epoll区别、零拷贝原理与Kafka应用、Netty线程模型,分三层讲解。

一句话总结

BIO 同步阻塞:一个连接占一个线程,连接多线程多就崩;NIO 同步非阻塞 + IO 多路复用:一个线程用 Selector 轮询管理成百上千个 Channel,就绪了才读写;AIO 异步:发起读写后直接返回,操作系统完成后回调通知(Linux 实现不成熟,用得少)。生产级网络框架 Netty 基于 NIO 封装。

初级理解

对比项BIONIOAIO
全称同步阻塞同步非阻塞异步非阻塞
线程模型一连接一线程多路复用,一线程管多连接内核完成后回调
API 核心ServerSocket/Socket 流Channel + Buffer + SelectorAsynchronousSocketChannel
适用场景连接少且固定高并发长连接连接多、操作耗时的场景

BIO 的瓶颈一目了然:accept 后所有读写都阻塞线程。1 万个连接就要 1 万个线程,内存(默认 1MB 栈/线程)和上下文切换直接拖垮系统。

// BIO 模型:每来一个连接就创建一个线程 while (true) { Socket socket = serverSocket.accept(); // 阻塞等连接 new Thread(() -> handle(socket)).start(); // 读写也阻塞 }
记忆口诀:BIO"傻等",NIO"轮询谁好了就处理谁",AIO"干完了叫我"。

中级深入

NIO 三大件:

  • Buffer:数据容器(position/limit/capacity 三指针),读写切换调 flip();有 HeapBuffer(堆内)和 DirectBuffer(堆外,省一次拷贝)
  • Channel:双向通道(流是单向的),FileChannel/SocketChannel/ServerSocketChannel/DatagramChannel
  • Selector:多路复用器,注册多个 Channel,select() 返回就绪集合,一个线程处理所有就绪事件
// NIO 服务端骨架 Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); // 必须非阻塞 ssc.register(selector, SelectionKey.OP_ACCEPT); while (selector.select() > 0) { // 阻塞到有就绪事件 Iterator<SelectionKey> it = selector.selectedKeys().iterator(); while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); // 记得手动移除! if (key.isAcceptable()) { /* accept 后注册 OP_READ */ } if (key.isReadable()) { /* 读写该 Channel */ } } }

select / poll / epoll 的区别(追问率极高):

  • select:fd 集合有上限(1024),每次调用把整个集合从用户态拷到内核态,返回后还要O(n) 遍历找就绪的
  • poll:去掉数量上限,但仍是全量拷贝 + O(n) 遍历
  • epoll:内核维护红黑树管理 fd,事件就绪时回调加入就绪链表;调用只返回就绪的 fd,O(1) 获取,无需重复全量拷贝。这是 Linux 下 NIO 高性能的根基

零拷贝(zero-copy):传统读写 = 磁盘 → 内核缓冲 → 用户缓冲 → Socket 缓冲 → 网卡,4 次拷贝 4 次上下文切换。零拷贝把用户态这一环砍掉:mmap(内存映射,RocketMQ 用)或 sendfile(Kafka 用),数据直接从内核缓冲到网卡缓冲。

高级拓展

Reactor 线程模型:单 Reactor 单线程(Redis 6 前)、单 Reactor 多线程、主从 Reactor 多线程(Netty 默认):boss 组只管 accept,worker 组管读写与业务,彻底避免"业务慢拖垮事件循环"。

Netty 为什么快(总结四点):① 主从 Reactor + epoll 封装(自动选择最优多路复用);② 池化 ByteBuf(PooledByteBufAllocator,jemalloc 思路)减少 GC;③ 零拷贝:CompositeByteBuf 逻辑组合多缓冲、FileRegion 包装 transferTo;④ 无锁串行化设计:一个 Channel 全生命周期绑定同一个 EventLoop 线程,Handler 内不用加锁。

水平触发(LT)与边缘触发(ET):LT 只要还有数据就绪就反复通知(Java NIO 的 Selector 是 LT,好写但可能重复唤醒);ET 只在状态变化时通知一次,必须一次把数据读完(Netty 可选 epoll ET 模式,吞吐更高但编程更难)。

JDK 的 epoll 空轮询 bug:极端情况下 select() 本应阻塞却立即返回 0,导致循环空转 CPU 100%。Netty 的对策:计数检测 + 重建 Selector。面试里能说出这个细节非常加分。

注意:FileChannel 不支持非阻塞模式——"NIO"的网络部分才谈多路复用;文件高性能读写走 FileChannel + MappedByteBuffer/transferTo。

实战场景

场景一:长连接网关的线程数暴涨

// 现象:10 万 WebSocket 长连接,BIO 线程数爆炸 // 方案:迁移 Netty(主从 Reactor) // bossGroup(1) 管接入,workerGroup(CPU核数) 管读写 // 心跳用 IdleStateHandler 空闲检测,及时清理僵尸连接

场景二:文件下载服务的内存抖动

// 反例:HeapBuffer 复制链长:磁盘→内核→堆内 byte[] // 正例:FileChannel.transferTo(0, size, socketChannel) // sendfile 零拷贝 // 或 MappedByteBuffer(mmap)读大文件,避免堆内全量缓冲

场景三:Selector 空轮询 CPU 100%

// 自研 NIO 网关 CPU 突然打满,jstack 看 select() 高频返回 0 // 对策一:升级 Netty(内置 invalid key 计数 + Selector 重建) // 对策二:退化为 select/poll 提供方,或定期重建 Selector // 根因:JDK + 内核版本组合触发的 epoll 状态异常,属已知 bug

面试模拟

Q:NIO 的"非阻塞"到底指什么?select() 不是还会阻塞吗?

A:非阻塞指读写数据阶段——Channel 设为非阻塞后,没有就绪数据时 read 立即返回而不是挂死线程。select() 的"阻塞"是多路复用的等待:没有事件时挂起(省 CPU),有任一事件就返回。准确说法是"同步非阻塞 + 事件驱动的多路复用",同步在于数据读写仍需线程自己完成(对比 AIO 的回调)。

Q:epoll 为什么比 select 快?

A:三点——① fd 用红黑树托管在内核,注册一次反复使用,避免每次全量拷贝;② 就绪事件通过回调挂入就绪链表,返回的只有就绪 fd,不用 O(n) 全量扫描;③ 没有硬编码数量上限。代价是只适用于 Linux(macOS 用 kqueue、Windows 用 IOCP),跨平台由 Netty/JDK 抹平。

Q:Kafka 和 RocketMQ 各用了哪种零拷贝?为什么?

A:Kafka 用 sendfile:消费路径是"文件 → 网络"的纯搬运,不需要在用户态改数据,sendfile 两级跳最优。RocketMQ 用 mmap:CommitLog 需要在用户态按 offset 读取/构建索引,mmap 映射进用户地址空间后可直接读,读写两用。选择取决于"数据要不要在用户态处理"——要处理选 mmap,纯转发选 sendfile。