ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java网络编程:从BIO到NIO与AIO,高并发下的I/O模型精讲

Java网络编程:从BIO到NIO与AIO,高并发下的I/O模型精讲

做过线上网关的人,多半都有过这种体验:流量一上来,服务端线程数涨到几百上千,CPU忽高忽低,大量线程阻塞在 socket 读取上。线程池调优、连接参数调了一轮又一轮,收效甚微。换一套异步非阻塞的架构之后,同样的机器扛住的连接数翻了几倍,线程数反而降下来了。

这个从“一个连接占一个线程”到“一个线程管上万连接”的转变过程,核心就是 Java 网络编程里那套绕不开的东西:BIO、NIO、AIO。

很多人学到这里,要么被 Buffer、Channel、Selector 这些概念劝退,要么看懂了 API 却说不清它为什么快。这篇文章打算从底层内核的 I/O 机制讲起,把 NIO 和 AIO 的来龙去脉、工作原理、工程落地拆开讲透。

1. 一切要从内核 I/O 模型说起

1.1 你写的 read(),在操作系统里到底发生了什么

先说清楚一个基础问题:Java 里调用socket.getInputStream().read()的时候,代码层层往下走,最终发生了什么。

Java 层的read()调用会通过 JVM 进入操作系统内核态,发起一次read系统调用。这个系统调用做的事情,可以拆成两个阶段:

第一个阶段,等待数据准备好。网络数据包从网卡到达内核缓冲区,需要时间。对 TCP 连接来说,数据包从对端发出、经过网络传输、被本机网卡接收、再通过 DMA 拷贝到内核缓冲区,整个过程毫秒级,但对 CPU 来说已经非常长了。

第二个阶段,把数据从内核缓冲区拷贝到用户空间。这个阶段纯粹是内存拷贝,快但也要时间。

理解这两个阶段,是理解阻塞、非阻塞、异步这些概念的前提。所谓阻塞,究竟阻塞在哪?答案是第一阶段。数据没到,调用就停在那里,线程被挂起,啥也不干。

第二个阶段也有讲究。NIO 的多路复用虽然解决了“等数据”的问题,但数据就绪后,从内核到用户空间的拷贝仍然是同步的。如果这个拷贝能完全交出去、不需要用户线程参与,那就成了真正的异步 I/O。

1.2 五种 I/O 模型的一次性分清楚

操作系统教材里通常把 I/O 模型分成五种:阻塞 I/O、非阻塞 I/O、I/O 多路复用、信号驱动 I/O、异步 I/O。

用买咖啡的类比来说:

阻塞 I/O 就是你走到柜台点单,然后站在窗口死等,咖啡没做好你哪儿也去不了。

非阻塞 I/O 就是你点完单之后每隔几秒去问一次“好了吗”,没做好就继续忙别的,但是你得自己反复去问。

I/O 多路复用是你点完单之后拿了个号,坐在休息区。你只盯着那块叫号屏,任何一个号的咖啡好了你都能看到。这里的“叫号屏”就是 Selector,替你盯着所有连接。

信号驱动 I/O 是商家做好咖啡后主动打电话通知你来取。你不用盯着屏幕,但通知到了之后你还得亲自去柜台端走。

异步 I/O 是你找了个跑腿小哥帮你点单、取咖啡、送到你面前,你从头到尾不需要管取咖啡这回事。

把这五种模型对应到 Linux 系统调用上,就是:阻塞 I/O 对应普通的read/write,非阻塞 I/O 对应设置了 O_NONBLOCK 的read/write,I/O 多路复用对应select/poll/epoll,信号驱动 I/O 对应SIGIO信号,异步 I/O 对应 Linux 的io_uring或 Windows 的 IOCP。

1.3 为什么阻塞模型撑不住高并发

BIO 的问题不只是一个线程在等数据这么简单。Java 里一个线程默认栈大小是 1MB,10000 个连接就得给线程预留 10GB 的虚拟内存空间。线程多了之后,CPU 时间大量消耗在线程上下文切换上。

更难受的是,大多数连接在绝大多数时间都没有数据可读。一条 TCP 长连接可能几秒、几十秒才有一次消息,但它在阻塞模型下要一直占着一个线程。用一千个线程维持一万条连接,其中九千个线程都在空等,这个浪费非常直观。

C10K 问题就是这么来的。一连接一线程的模型到了万个连接这个量级,基本就到头了,不是 CPU 不够,而是线程管理本身把系统拖垮了。

2. Java NIO:从“等”到“轮”的关键一跳

2.1 Channel 与 Buffer:面向缓冲区的 I/O

NIO 的全称是 New I/O,也可能有人喜欢解读成 Non-blocking I/O。它的核心 API 和 BIO 完全不同。

BIO 是面向流的,读取的时候从一个流里一个字节一个字节地往外读,写的时候向流里推数据。NIO 是面向缓冲区的,所有读写都围绕 Channel 和 Buffer 展开。

Channel 可以理解成一条管道或者传送带,两头分别连接着文件、Socket 等数据源和目标。Buffer 是传送带中间放的货箱。读数据就是把管道里的货物装进货箱,写数据就是把货箱里的货物倒到管道里。

ByteBuffer 有三个核心指标需要掌握:capacity(容量)、position(当前位置)、limit(读写边界)。

  • 写模式下,position 表示下一个要写入的位置,limit 等于 capacity。
  • 写完之后要读,必须先调用flip(),把 limit 设为当前 position,position 归零。
  • 读完之后要接着写,调用clear()清空整个缓冲区,或者compact()只清理已读部分。

这里最容易踩的坑就是忘了 flip。写完忘了 flip 直接读,读出来的是空数据或者残留数据;读完忘了 clear,再写就报 BufferOverflowException。

一个简单的 ByteBuffer 使用示例:

import java.nio.ByteBuffer; ByteBuffer buffer = ByteBuffer.allocate(1024); buffer.put("hello".getBytes()); // 切换为读模式 buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } System.out.println(); // 切换回写模式 buffer.clear();

这些 API 本身不难,难的是工程里什么时候该 flip、什么时候该 compact。如果做的是半包处理——读到一半数据,既要保留未读完的字节,又要继续往缓冲区追加数据,那compact()就是正确选择。它会移动未读的数据到头部,position 指向未读数据之后的位置,这样新数据能继续写入。

2.2 Selector:一个线程盯住上万连接的前台

NIO 的读写都是非阻塞的,SocketChannel.read()返回 0 表示没有数据,write()返回 0 表示写不进去。这种非阻塞本身并不能帮你提升性能,反而让你空转——如果没有事件机制,你得循环调用 read 去轮询每个连接,CPU 全部浪费在无意义的上千次系统调用上。

Selector 就是来解决这个问题的。它像一个前台接待员,你把手里的所有通道都交给它登记,然后它帮你盯着。任何一个通道有读事件、写事件、连接事件、接入事件,它都会告诉你。

使用过程分三步:

  • 通过Selector.open()创建 Selector。
  • 把 Channel 注册到 Selector 上,指定感兴趣的事件:OP_ACCEPT、OP_CONNECT、OP_READ、OP_WRITE。
  • 调用selector.select()阻塞等待,有事件就返回,然后通过selectedKeys()取出所有已就绪的通道做处理。

来看一段完整的 NIO 服务端代码,这是一个能跑的最小实现:

import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.Iterator; public class NioServer { public static void main(String[] args) throws IOException { Selector selector = Selector.open(); ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.configureBlocking(false); serverSocketChannel.socket().bind(new InetSocketAddress(8080)); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); ByteBuffer buffer = ByteBuffer.allocate(1024); while (true) { selector.select(); Iterator<SelectionKey> keys = selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey key = keys.next(); keys.remove(); if (key.isAcceptable()) { // 处理新连接 SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); socketChannel.register(selector, SelectionKey.OP_READ); } else if (key.isReadable()) { // 处理读数据 SocketChannel socketChannel = (SocketChannel) key.channel(); buffer.clear(); int bytesRead = socketChannel.read(buffer); if (bytesRead == -1) { socketChannel.close(); } else { buffer.flip(); while (buffer.hasRemaining()) { socketChannel.write(buffer); } } } } } } }

这段代码有几个细节非常重要。

第一,处理完事件之后必须从 selectedKeys 里移除,否则下次 select 还会返回同一批已经消费过的事件,造成重复处理。这是新手最容易漏掉的一步。

第二,serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT)注册的是接受连接事件,新接入的 SocketChannel 再注册为OP_READ。注意注册新通道时,不能加 SELECTOR.select() 等待太久,否则新连接的事件一直积压。

第三,上面这段代码在写回数据时直接用了socketChannel.write(buffer),假设一次 write 能把数据全部写完。实际网络环境下很可能出现缓冲区满了、write 返回 0 的情况。正确做法是注册OP_WRITE事件,在可写事件里继续写完剩余数据。

说到OP_WRITE,这里有一个经典的坑:SocketChannel 只要没有被写阻塞,它就一直处于可写状态。你要是注册了OP_WRITE,select 会立刻返回,而且持续返回,导致你的 while 循环疯狂空转,CPU 直接拉满。正确的做法是,只在检测到 write 返回 0 时注册OP_WRITE,写完立即撤销。

2.3 内核支撑:epoll 究竟怎么做到的

Java NIO 的 Selector 在 Linux 上的底层实现依赖的是epoll机制。在 Java 诞生早期,Linux 版本普遍还停留在 2.4 时代,Selector 的默认实现实际上是select或poll,性能差很多。后来 Linux 2.6 内核全面普及了 epoll,Java 在 Linux 平台上的 NIO 性能才有了根本保障。

select 和 poll 的问题在于,它们每次调用都要把全部文件描述符(fd)从用户态拷贝到内核态,内核线性扫描所有 fd 判断有无就绪事件,复杂度是 O(n)。如果管理了一万个连接,每次 select 都要扫描一万个 fd,扫描完再把结果拷回用户态,用户态还得再遍历一遍。连接越多,开销越大。

epoll 的做法完全不同。epoll_create在内核里创建一个事件表,epoll_ctl注册/修改/删除 fd 和对应的事件,epoll_wait阻塞等待事件。内核不再每次扫描全部 fd,而是只把真正有事件发生的 fd 通过回调机制挂到一个就绪队列里。用户态调用epoll_wait时,直接拿走就绪队列里的 fd 就行,复杂度是 O(1)。这在连接数上万之后有本质差别。

epoll 还分水平触发(LT)和边缘触发(ET)。水平触发是只要缓冲区还有数据没读,就会反复通知你;边缘触发是只有新数据到达的那一刻通知你一次,你得一次性把数据读完。Java NIO 默认是水平触发,所以即便你一次读不完,下一次 select 还会通知你继续读,不丢数据。Netty 可以调整成边缘触发模式,但对处理逻辑的要求更高,处理不当容易出现数据长期滞留缓冲区的问题。

3. AIO:真正的异步到底长什么样

3.1 从 NIO 到 AIO:不再由你去轮询

NIO 演进到多路复用,本质上是把“等待哪个连接有数据”交给了内核的 epoll,数据就绪后内核通知用户线程,用户线程再同步地去读。这仍然是“同步的”事件通知模型。

AIO(Asynchronous I/O)就不一样了。AIO 把整个操作都委托出去:你发起一个读请求,告诉系统“读完之后把这些数据放到缓冲区里,然后调用我指定的回调函数”。然后你的线程就彻底解放了,干别的活去了。内核在数据到达、拷贝完成之后,主动唤醒完成回调。

Java 7 引入的 AIO API 核心是AsynchronousServerSocketChannel和AsynchronousSocketChannel。配套的有两种使用方式:

一种是 Future 风格。调用accept()返回一个 Future,你需要主动 get() 才能拿到结果,这其实是半异步,因为你还是要等。

另一种是 CompletionHandler 风格。调用时传一个CompletionHandler进去,操作完成时系统回调它的completed()方法。

看一个简单的 AIO 服务端例子:

import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.AsynchronousServerSocketChannel; import java.nio.channels.AsynchronousSocketChannel; import java.nio.channels.CompletionHandler; public class AioServer { public static void main(String[] args) throws IOException { AsynchronousServerSocketChannel serverChannel = AsynchronousServerSocketChannel.open(); serverChannel.bind(new InetSocketAddress(8083)); serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() { @Override public void completed(AsynchronousSocketChannel socketChannel, Void attachment) { // 立刻继续接收下一个连接,否则只能处理一个连接 serverChannel.accept(null, this); ByteBuffer buffer = ByteBuffer.allocate(1024); socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() { @Override public void completed(Integer bytesRead, ByteBuffer attachment) { attachment.flip(); byte[] data = new byte[attachment.remaining()]; attachment.get(data); System.out.println("receive: " + new String(data)); try { socketChannel.close(); } catch (IOException e) { e.printStackTrace(); } } @Override public void failed(Throwable exc, ByteBuffer attachment) { exc.printStackTrace(); } }); } @Override public void failed(Throwable exc, Void attachment) { exc.printStackTrace(); } }); Thread.sleep(Long.MAX_VALUE); } }

这段代码里最关键的一行是serverChannel.accept(null, this)。如果漏了这一行,服务端只能接受第一个连接,后续的客户端全部被拒之门外。AIO 的一切都是异步回调,调用 accept 之后立即返回,同一个通道不能同时有多个 accept 操作挂在那里,所以只能在回调里再发起下一个 accept,把连接接收串成链。

3.2 Linux 上的“伪异步”与 Windows 上的真异步

关于 AIO,业界一直存在一个不那么好听的真相:在 Linux 平台上,Java AIO 的底层实现并不是真正的异步 I/O。

Windows 上有 IOCP(I/O Completion Port),从提交 I/O 请求到数据拷贝完成,全程不需要用户线程参与,是货真价实的异步 I/O 模型。

Linux 上早期有 libaio,后来有了 io_uring,理论上也可以做到纯异步。但 JDK 自带的 NIO and Asynchronous I/O 实现,走的不是这条线。在 JDK 1.7 到 1.8 的版本里,Linux 上的AsynchronousServerSocketChannel和AsynchronousSocketChannel的实际实现是:内部开一个线程池,用多路复用(epoll)去等待内核事件,内核说可读之后,线程池里的线程执行 read 操作,拷贝完成再触发你的完成回调。

这就有意思了:你写的是异步回调风格的代码,底层的读动作、拷贝动作,仍然发生在 JDK 管理的线程里,而不是在真正的内核驱动层面异步完成。所以业内有一个共识——Linux 上的 Java AIO 是"用线程池模拟出来的异步",本质还是同步非阻塞那套逻辑换了个皮。

这就解释了为什么高性能网络框架的代表——Netty,在 Linux 上选择的是 NIO 而不是 AIO。在 Linux 这个主流部署环境下,AIO 并没有带来真正的异步能力提升,反而因为线程池的上下文切换和回调链路加深,让性能处在一种边界而模糊的位置。Netty 官方文档里有一句非常有名的话,大意是在 Linux 上使用 NIO,在 Windows 上可以考虑原生 IO。再加上 AIO 的 API 天然容易写出多层嵌套回调,代码可读性和可维护性都差很多,所以它在 Java 生态里一直没有成为主流。

那 AIO 在 Java 里就一无是处吗?也不是。如果你部署在 Windows 环境中,AIO 背后是真正的 IOCP,性能优势能发挥出来。另外,对于某些数据的写入场景,比如大文件的异步写入,JDK 的AsynchronousFileChannel在部分平台上确实可以做到不占用用户线程完成操作,这也是一类值得关注的应用场景。

4. 工程落地:框架、选型与避坑手册

4.1 Reactor 模型与 Netty 的关系

理解了 NIO 和 AIO 之后,再看主流的网络框架就会有豁然开朗的感觉。Netty 本质上是一个基于 NIO 的 Reactor 模型实现。

Reactor 模式描述的就是一套事件分发机制:一个或多个事件循环不断查询就绪事件,然后把事件分发给对应的业务处理器。

Netty 的线程模型一般拆成两步看。

Boss EventLoopGroup 在服务端专门负责监听端口、接收新连接,所有接入的 OP_ACCEPT 事件由它处理。Worker EventLoopGroup 负责处理每条连接上的业务读写事件。一个 EventLoop 内部维持一个 Selector 和一个Executor单线程,跑一个 for 循环:select阻塞等待事件,拿到事件后执行对应的 channel handler 链。

这个模型对性能的影响在于,同一个连接的所有读写事件始终绑定在同一个 EventLoop 上,不存在线程切换,也不存在并发修改。这也是为什么 Netty 的 handler 里不需要考虑跨线程同步问题——只要你不主动把任务分发到其他线程池去执行,所有状态都只会在当前 EventLoop 线程里被顺序访问。

4.2 选型建议:到底用 BIO、NIO 还是 AIO

写业务代码,不太建议自己直接操作 NIO 的 Selector,里面值得踩的坑实在太多了。做一个面向业务的 API 服务,Bedrock 也不说完全不能用 BIO,看场景。

下面这张表整理一下维度对比:

维度BIONIOAIO
线程模型一连接一线程多路复用,少量线程管理大量连接异步回调或 Future 获取
连接数量上限低,受线程数限制高,可支撑十万级连接高,依赖底层实现和平台
编程复杂度低中等,需要处理半包、事件管理较高,回调嵌套容易失控
Linux 平台表现低并发时稳定最优选择,基于 epoll实际为线程池模拟异步,收益有限
典型使用场景低并发小工具、开发调试大部分服务端框架:Netty、Vert.xWindows 环境或文件异步 I/O 场景

具体来说:

如果你的服务只是内部工具,并发稳定在几十上百,BIO 完全够用,逻辑简单,出问题好排查。不要为了技术炫技把简单系统复杂化。一个ExecutorService+ BIO Socket 的模型,在明确低并发的部署环境中,仍然是很务实的方案。

如果你的服务会面对长连接大流量,例如网关、IM、订阅推送,那就直接选 NIO,并且推荐使用 Netty 这类成熟框架。不要裸写 NIO 还有其他原因:粘包拆包、半包处理、断线重连、心跳检测这些公共问题,Netty 都有现成的编解码器和 handler,能帮你省掉大量重复造轮子的时间。

如果你对 AIO 特别感兴趣,建议先在 Windows 本机跑一个简单的 AIO 服务端做实验,感受一下回调驱动代码到底是怎么流转的。然后再去 Linux 上对比一下同样的代码和 NIO 方案的性能差异,这个对比会让你对“异步”的理解更扎实——它可能和你命令行里简单测试 benchmark 的表现有微妙的反差。

4.3 裸写 NIO 最常见的几个坑

哪怕用了 Netty,理解底层的坑仍然有助于排查线上问题。我自己裸写过 NIO 服务器,踩过几次坑之后,把最容易出问题的地方整理出来。

第一个是空轮询的坑。在早期的 JDK 环境中,Linux 上的 Selector 在某种特定条件下 select 会立即返回 0 且没有事件,循环没能等待就直接跑飞,CPU 空转 100%。这就是著名的 epoll bug。在 JDK 里出现过这个问题,解决思路是记录上次 select 返回的时间,如果连续返回 0 就临时重建一个 Selector,把已有 channel 重新注册进去。这个修复逻辑在 Netty 里已经内置了。

第二个是写事件的坑。上面提过,OP_WRITE只要 socket buffer 未满就会一直可写,同一个连接上反复触发写事件会让线程空转。更稳妥的做法是维护一个待写入队列,只有队列非空且写不进去的时候才注册写事件;写完队列里的数据,立即撤销写事件。

第三个是read()返回 -1 和 0 的处理。返回 -1 代表对端关闭了连接,必须关闭 channel。返回 0 则是没有数据可读,不需要特殊处理。很多人直接忽略了 0 和 -1 的区别,导致连接泄漏或者清理不及时。

第四个是拆包粘包。TCP 是流式协议,一次 write 的数据可能被拆成多个包到达,多次 write 的数据也可能合在一起到达。裸 NIO 如果按“收到一次 read 事件就解析一条消息”来做,就大错特错。需要一个累积缓冲区,把读到的字节先缓存起来,根据业务规则(比如固定长度、分隔符、自定义长度头)把完整消息切出来,剩余半包留在缓冲区继续等。这段逻辑在 Netty 里可以直接用LengthFieldBasedFrameDecoder等编解码器解决,裸写的话它占了整体代码量的大头。

4.4 从学习路径搭建到实战验证的引导

如果你想系统地掌握这一块知识,我的建议是多做一些对照实验。

第一,装一个真实的 Linux 虚拟机或云主机,看 Linux 内核版本,确认是 2.6 以上再测 epoll 的指标。用strace跟踪 Java 进程的系统调用,你能清楚地看到 select、epoll_wait 的真实调用情况。strace -p <pid>这个命令在这种场景里是无可替代的观察手段。

第二,反复修改一个 NIO 服务器并压测。先用单线程 Selector 跑 1 万连接,看什么效果;再加一个业务线程池处理消息,对比吞吐变化;再换成本文的 AIO 版本在 Linux 和 Windows 各跑一遍,记录数据差异。

第三,把 Netty 官方示例里 echo 服务器的 worker 线程数从 1 改到 CPU 核数再到 2 倍 CPU 核数,分别压测,观察线程模型对吞吐的影响。这部分数据比任何文档描述都直观。

我现在回头想,对 NIO 的开窍,不是看书看懂的,是亲手把 Selector 循环写出来、用strace看到系统调用之后才真正理解的。纸上得来终觉浅,这句话放在网络编程上太合适了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进