学完这篇你能分清 BIO、NIO、AIO 三者的本质差别,并在写服务端代码时知道该选哪一个、避开哪些坑。
第一步:先搞清楚两个维度,别被名字绕晕
Java 里的 IO 模型要从两个维度看:
- 阻塞 / 非阻塞:指的是「调用发起后,线程要不要在那儿干等数据」。
- 同步 / 异步:指的是「数据从内核缓冲区搬到用户缓冲区这一步,谁来干」。
按这两个维度分:
| 模型 | 阻塞性 | 同步性 | JDK 版本 |
|---|
| BIO | 阻塞 | 同步 | 1.0 |
| NIO | 非阻塞(可切换) | 同步 | 1.4 |
| AIO (NIO.2) | 非阻塞 | 异步 | 7 |
关键点:NIO 名字叫 Non-blocking,但它是同步的——数据就绪后仍要你自己调 read() 把数据读进 ByteBuffer。只有 AIO 才是真正的异步,读完由回调通知你。
注意:很多人把「非阻塞」和「异步」当成一回事,面试和选型时最容易翻车的就是这里。
第二步:BIO —— 一个连接一个线程
最直白的写法,ServerSocket 一 accept 就开线程:
ServerSocket server = new ServerSocket(8080);
while (true) {
Socket socket = server.accept(); // 阻塞点 1
new Thread(() -> handle(socket)).start(); // 每连接一线程
}
accept() 阻塞等连接,InputStream.read() 阻塞等数据。
优点是代码简单、调试直观,Tomcat 早期版本就是这套。缺点是线程数 = 并发连接数,1 万连接就要 1 万线程,每个线程栈默认 1MB(-Xss 可调小),几万连接光线程栈就能把内存吃穿,线程上下文切换也会把 CPU 打满。
适用:连接数少(几百以内)、内部服务、客户端程序、快速验证原型。
第三步:NIO —— 一个线程管成千上万连接
NIO 换了一套抽象:Channel + Buffer + Selector。核心是把「等数据」这件事交给操作系统,一个线程用 select() 一次问询所有连接,谁就绪就处理谁:
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.bind(new InetSocketAddress(8080));
ssc.configureBlocking(false); // 关键:切成非阻塞
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select(); // 阻塞在这里,直到有事件就绪
for (SelectionKey key : selector.selectedKeys()) {
if (key.isAcceptable()) { /* 注册新连接 OP_READ */ }
else if (key.isReadable()) { /* channel.read(buffer) 读数据 */ }
}
selector.selectedKeys().clear(); // 必须清,否则重复处理
}
Linux 下底层是 epoll,一个 Selector 线程撑几千到几万连接是常规操作。Netty、Redis 客户端、Dubbo 的通信层都是这套。
注意几个高频坑:
1. 忘记 selectedKeys().clear(),同一个事件会被反复处理。
2. 半包 / 粘包:TCP 是流,一次 read 拿到的未必是一条完整消息,必须自己定长度字段或分隔符来拆包。Netty 提供了 LengthFieldBasedFrameDecoder,自己手写 NIO 时务必处理。
3. 空轮询:JDK 早期版本 epoll 空转会导致 select() 返回 0 但 CPU 100%,Netty 用计数 + 重建 Selector 绕过了这个 bug,自己写建议直接上 Netty 而不是裸写 NIO。
4. 业务逻辑别放在 IO 线程里,一个慢 SQL 会卡住整个 Selector。
适用:高并发长连接网关、IM、推送、RPC、代理服务器。
第四步:AIO —— 内核读完再回调你
AIO 用 AsynchronousServerSocketChannel,调用后立刻返回,结果通过 CompletionHandler 回调:
AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open();
server.bind(new InetSocketAddress(8080));
server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
public void completed(AsynchronousSocketChannel ch, Void att) {
server.accept(null, this); // 继续接下一个
// ch.read(...) 同样是异步 + 回调
}
public void failed(Throwable exc, Void att) { exc.printStackTrace(); }
});
理论上最省心,但现实是:Linux 上 JDK 的 AIO 底层仍是用 epoll 模拟出来的,性能不比 NIO 强,反而多了线程池调度的开销。Windows 上是真正的 IOCP,表现才好。社区实践里 AIO 用得很少,Netty 甚至明确放弃了 AIO 支持。
适用:文件异步读写、连接数不算极端的场景,或者你确实想用回调风格写代码。生产级网络服务,优先 NIO(Netty)。
第五步:选型速查
- 并发连接 < 500、逻辑简单 → BIO,够用就好
- 高并发网络服务(IM / 网关 / RPC / 推送)→ NIO,且直接上 Netty
- 大文件异步读写 → AIO(
AsynchronousFileChannel)
- 不想折腾这些细节,只是要个 HTTP 服务 → 用 Spring WebFlux(底层 Reactor Netty)或 Vert.x,别自己写裸 NIO
注意:选型先看连接数和消息模型,别看「新不新」。BIO 在低并发下延迟和吞吐一点不差,代码还少。
小结
- BIO 同步阻塞,一连接一线程,简单但线程是瓶颈。
- NIO 同步非阻塞,Selector 多路复用,一个线程管万级连接,代价是拆包、空轮询等细节要自己处理。
- AIO 才是真异步,Linux 上是 epoll 模拟,实际收益有限,网络编程场景用得少。
- 裸写 NIO 容易出事,生产环境直接用 Netty;连接数不大就老老实实 BIO。
- 判断标准永远是连接数、消息模型和团队维护成本,不是「谁更新」。