在前面的章节中,我们聚焦于 Java 并发的核心——线程之间的协作与控制
锁、CAS、AQS、CountDownLatch、Semaphore……
这些工具让我们能优雅地协调多个线程,避免数据错乱与资源争抢。
真正的性能瓶颈,往往不在 CPU 内部的微秒级竞争,而在线程与外部世界交互时的毫秒级等待—— I/O。
当线程发起一次数据库查询、读取一个网络数据包、或写入一个文件时,它可能要“空等”几十甚至几百毫秒。
在这段时间里,线程被阻塞,无法执行其他任务,而系统却不得不为它保留一份宝贵的栈内存和调度开销。
这就是“并发真正的战场”:不是线程之间抢资源,而是如何让线程在 I/O 等待时不再“睡大觉”。
接下来,我们将正式进入 I/O 编程的世界,从最传统的BIO(阻塞 I/O)开始,逐步揭开NIO(非阻塞 I/O + 多路复用)的神秘面纱,并最终抵达AIO(异步 I/O)的前沿。我们还会深入探讨Reactor/Proactor这两种经典的事件驱动架构,理解它们如何支撑起高性能服务器(如 Netty、Redis、Nginx)。
同步与异步IO
同步IO与异步IO,这是理解所有IO模型的基石。
两者的区别关键在于发起IO请求后,线程是否需要等待IO操作真正完成。
同步IO是你发起了IO请求后,阻塞等待,直到数据完成。
异步IO是你发起了IO请求后,立刻返回,IO完成后再主动通知你。
1、同步IO
同步IO又可以分为阻塞IO和非阻塞IO,区别在于等待的方式不同。
1.1、阻塞IO(BIO)
这是最传统的IO模型。
InputStream in = socket.getInputStream(); byte[] buf = new byte[1024]; in.read(buf); // 线程在这里阻塞,直到数据完全到达 // 数据到手,继续执行特点:
- 线程发起
read()调用后,一直等待,直到数据从内核拷贝到用户空间。 - 在此期间线程什么都不做,被操作系统挂起。
类比:你去奶茶店排队,站在柜台前一动不动,直到奶茶做好递到你手上。
1.2、非阻塞IO(NIO)
线程发起 IO 请求后,不等待,立即返回一个状态(“数据还没准备好”或“数据准备好了”)。
SocketChannel channel = SocketChannel.open(); channel.configureBlocking(false); // 设置为非阻塞模式 ByteBuffer buf = ByteBuffer.allocate(1024); int bytesRead = channel.read(buf); // 立即返回 // bytesRead = 0 表示还没数据 // bytesRead > 0 表示读到了数据特点:
- 每次
read()调用都立即返回,不会阻塞。 - 但如果数据没准备好,返回 0,线程需要反复轮询(自旋)检查数据是否就绪。
类比:你去奶茶店,问“好了吗?”,店员说“还没”,你就走了,过一会儿再来问一次,直到拿到奶茶。
问题:轮询会浪费 CPU,所以通常需要配合IO 多路复用(Selector)来使用。
那这么看来,非阻塞IO和异步IO不是一样了吗?
这里讲一下IO重要的两个过程:
- 数据就绪:将信息读取到内核/进程缓冲区,准备发送
- 数据拷贝:发生数据
以Java和MySql为例
输出IO:
当你在 Java 里执行一条 SQL 语句时,Java 程序需要把这条指令发送给 MySQL。
Java应用层 → 内核Socket发送缓冲区 → 网卡 → 网络线路 → MySQL 服务器的网卡 → MySQL内核接收缓冲区输入IO:
MySQL 处理完你的订单后,把查询结果返回给 Java。
MySQL内核发送缓冲区 → 网卡 → 网络线路 → Java程序的网卡 → 内核Socket接收缓冲区 → Java应用层所以他们的区别是:
IO 模型 | 第一阶段:数据就绪 | 第二阶段:数据拷贝 |
非阻塞 IO | 不阻塞(立即返回,但需轮询) | 阻塞(线程自己把数据从内核拷贝到用户空间) |
异步 IO | 不阻塞(立即返回) | 不阻塞(系统帮你拷贝完,然后通知你) |
关键点:非阻塞 IO 在数据拷贝阶段仍然是阻塞的,而异步 IO 全程不阻塞。
做一个生活例子:
非阻塞 IO
你点了一杯奶茶,每隔一分钟就去问店员:“好了吗?”
- 店员说“还没”(数据未就绪)→ 你走了,过一会儿再来问
- 店员说“好了”(数据就绪)→你站在柜台前等店员把奶茶做好递到你手上(数据拷贝阶段,你在等)
你在“轮询”时不阻塞,但在“拿到奶茶”的那一刻,你还是得等着。
异步 IO
你在 App 上点奶茶,然后去打游戏。
- 奶茶做好了,骑手直接送到你家门口,按门铃通知你(系统主动回调)
- 你开门拿奶茶就行
你全程没有等过任何人,从制作到送达,都是系统在帮你搞定。
2、异步IO(AIO)
JDK7中引入了AIO(Asynchronous IO)
特点:
- 线程发起
read()调用后,立即返回,完全不用等待。 - 当 IO 操作真正完成时,系统会主动回调你注册的处理函数。
- 线程在整个过程中从未阻塞,可以做其他任务。
类比:
你在 App 上点奶茶,然后去打游戏、看电影。奶茶做好了,骑手打电话通知你来取。你全程没浪费时间等待。
3、架构设计模式
Reactor和Proactor
3.1、Reactor(反应器模式)
核心思想:“来了事件我通知你,你来处理”。
工作机制:
- 服务员(Reactor)站在前台,负责监听所有的客人(Socket 连接)。
- 当有客人按门铃(比如数据可读了),服务员立刻通知后厨的厨师(Handler)。
- 厨师必须亲自走过去,把菜端回来(应用程序主动调用
read()读取数据),然后再开始炒菜(执行业务逻辑)。
特点:感知的是“就绪事件”(告诉你“可以读了”),但实际的 I/O 读写操作还是由应用程序自己完成。
在 Java 中:NIO 就是典型的 Reactor 模式。它通过Selector(多路复用器)来监听事件,然后在事件到来时,由工作线程去执行channel.read()。
组件 | 角色 | 职责 |
Event(事件) | 信号 | 表示“有数据可读”或“可以写数据” |
Reactor(反应器) | 调度员 | 循环等待事件,事件来了分发给对应的 Handler |
Demultiplexer(多路分发器) | 监听器 | 底层调用 |
Dispatcher(分发器) | 派单员 | 将就绪的事件分配给对应的 Handler |
Handler(处理器) | 工人 | 实际执行读写操作和业务逻辑 |
Step 1: Reactor 启动,让 Demultiplexer 开始监听所有 Socket Step 2: Demultiplexer 调用 select(),阻塞等待 Step 3: 有数据到达,select() 返回 Step 4: Dispatcher 拿到就绪的事件列表 Step 5: Dispatcher 把事件分配给对应的 Handler Step 6: Handler 自己调用 channel.read() 读取数据 Step 7: Handler 处理业务逻辑 Step 8: 回到 Step 2,继续循环代码解释:
// Reactor = 调度员 Selector selector = Selector.open(); // Demultiplexer = select() 阻塞等待 while (true) { selector.select(); // 阻塞,直到有事件就绪 // Dispatcher = 遍历就绪的事件 Set<SelectionKey> keys = selector.selectedKeys(); for (SelectionKey key : keys) { // Handler = 处理事件 if (key.isReadable()) { SocketChannel channel = (SocketChannel) key.channel(); ByteBuffer buf = ByteBuffer.allocate(1024); channel.read(buf); // Handler 自己读数据 // 业务处理... } } }3.2、Proactor(前摄器模式)
核心思想:“你告诉我想吃什么,我做完了再叫你”。
工作机制:
- 客人(应用程序)把菜单和空盘子(缓冲区)一起交给服务员(Proactor)。
- 服务员把菜单交给后厨(操作系统内核),自己就不用管了。
- 后厨做完菜(内核完成数据拷贝),直接端到客人桌上,然后喊一声“饭好了”(回调通知)。
特点:感知的是“完成事件”(告诉你“已经读完了”),I/O 操作(数据拷贝)完全由操作系统内核完成,应用程序只负责处理结果。
在 Java 中:AIO 就是 Proactor 模式。你调用AsynchronousSocketChannel.read(buffer, attachment, handler),传入缓冲区和处理回调,线程立即返回,内核读完后自动回调给你。
组件 | 角色 | 职责 |
Operation(操作) | 任务单 | 告诉系统“我要读/写,结果放这里” |
Proactor(前摄器) | 调度员 | 发起异步操作,等待完成通知 |
Completion Handler(完成处理器) | 回调 | 系统完成 IO 后,自动调用的函数 |
Asynchronous Operation Processor(异步操作处理器) | 执行者 | 操作系统内核,真正执行 IO 操作 |
Completion Dispatcher(完成分发器) | 派单员 | 将完成的事件分发给对应的 Handler |
Step 1: 你告诉 Proactor:“我要读这个 Socket 的数据,读完了放到 buffer 里,然后调用我的回调函数” Step 2: Proactor 把任务交给操作系统内核 Step 3: 内核开始读数据,你把这件事忘了,去做别的事 Step 4: 内核读完了,数据已经放到了你给的 buffer 里 Step 5: 内核通知 Proactor:“读完了” Step 6: Completion Dispatcher 找到你注册的回调函数 Step 7: 回调函数被调用,你直接在 buffer 里拿到数据 Step 8: 你处理业务逻辑,不需要再做 read()代码解释:
// 你告诉 Proactor:帮我读数据,读完了回调我 AsynchronousSocketChannel channel = AsynchronousSocketChannel.open(); ByteBuffer buffer = ByteBuffer.allocate(1024); // 发起异步读操作,传入 buffer 和回调 channel.read(buffer, null, new CompletionHandler<Integer, Void>() { @Override public void completed(Integer bytesRead, Void attachment) { // 内核已经读完了,数据在 buffer 里,你直接处理 buffer.flip(); byte[] data = new byte[buffer.limit()]; buffer.get(data); System.out.println("读到了: " + new String(data)); } @Override public void failed(Throwable exc, Void attachment) { System.out.println("读取失败"); } }); // 这里你可以去做别的事情,完全不阻塞 System.out.println("读操作已发起,我去干别的了");那为什么我们实际的业务场景中很少看到过他们呢?
实际上他们都被框架封装了:
- BIO(阻塞IO):太原始,几乎没人直接用
- NIO(非阻塞IO):虽然强大,但是API很难用,需要手动管理Channel、Buffer、Selector,还要处理各种异常状态。
- AIO(异步IO):回调地狱,代码逻辑碎片化,也是很难调试
比如主流的Netty,底层使用的是NIO,Reactor模式(反应器模式),它把复杂的NIO细节全都封装起来了,只需要简单的几行Handler就可以处理高并发。
Tomcat,底层也是使用的NIO。
在高并发场景下,我们的目标是“处理业务逻辑”,而不是“手写网络协议栈”。
学习 NIO/AIO 的意义在于理解底层原理(比如为什么 NIO 能支撑百万连接,它是怎么通过 Selector 轮询的),而不是为了每天在业务代码里写selector.select()。
为什么 Reactor 更常见?
因为 Proactor在 Linux 上不好使。
- Reactor:Linux 上有
epoll,非常高效,Java NIO 就是用这个。 - Proactor:Windows 上有
IOCP,天生支持。但 Linux 上没有真正的 AIO,Java 的 AIO 在 Linux 上是用epoll模拟的,性能还不如 NIO。
所以现实中:
- Netty(最流行的网络框架)用的是Reactor 模式
- Redis用的是Reactor 模式
- Nginx用的是Reactor 模式
- Proactor只在 Windows 上比较常见
本章我们从 BIO 的阻塞困境出发,一路走到 NIO 的非阻塞革命,再到 AIO 的异步未来,并深入剖析了 Reactor 与 Proactor 这两种高性能服务器的核心骨架。至此,你已经掌握了 I/O 模型的理论根基。
下一章,我们将把这些理论落地到工业级实战——探究 Netty 为什么能支撑百万级并发,它又是如何将 Reactor 模式发挥到极致的。