这篇笔记的目标是把 I/O 中最容易混淆的几组概念放到同一张图景里:同步和异步、阻塞和非阻塞、BIO/NIO/AIO,以及多路复用。内容重点放在网络 I/O 和 Java 常见实现上,目的是先把概念边界理顺,再去理解框架和源码中的具体写法。
很多资料会把这些词混着用,或者直接把
NIO等价成“同步非阻塞”、把AIO等价成“内核一定真异步”。这种说法在入门阶段有助于建立直觉,但一旦继续深挖,就很容易在操作系统语义、Java API 语义和框架实现语义之间打架。这篇内容的重点就是把这几个层次拆开。
参考资料:
官方文档:java.nio.channels package summary 、 AsynchronousChannel 、 AsynchronousSocketChannel 、 ByteBuffer
系统资料:Linux man page - select 、 Linux man page - poll 、 Linux man page - epoll
实践参考:JavaGuide - BIO、NIO 和 AIO 、 Java 面试那些事儿 、 多路复用
[TOC]
什么是 I/O
在计算机系统中,I/O 就是输入(Input)和输出(Output)。只要一个系统涉及数据从一个地方流向另一个地方,例如键盘输入、磁盘读写、网络收发、数据库访问、设备通信,都可以放到 I/O 的语境里理解。
针对不同对象,又可以继续细分为磁盘 I/O、网络 I/O、内存映射 I/O、Direct I/O、数据库 I/O 等。Java 讨论 BIO、NIO、AIO 时,最常见的背景其实是网络 I/O,因为高并发服务端最容易在这里暴露出线程数量、系统调用次数和数据搬运成本的问题。
在今天的系统里,I/O 往往就是性能瓶颈的主要来源之一。大量文件处理、大量数据库访问、大量网络连接,都会把瓶颈推向磁盘、网络、内核缓冲区和线程调度,而不是单纯的 CPU 算力本身。也正因为如此,Java 一直在持续完善 I/O 编程模型:从早期流式 BIO,到 JDK 1.4 引入的 NIO,再到 JDK 7 的 NIO.2 异步通道。
无论具体 API 怎么变化,理解 I/O 语义时都可以先抓住一个最核心的事实:一次网络读操作,通常至少包含“等待数据就绪”和“把数据从内核空间拷贝到用户空间”这两个阶段。
进程中的 I/O 调用过程可以粗略拆成下面几步:
- 应用线程发起读写请求
- 操作系统等待数据到达,或者等待设备进入可读/可写状态
- 数据先进入内核缓冲区
- 操作系统把数据从内核缓冲区复制到用户缓冲区
- 应用线程真正拿到数据,继续执行业务逻辑
这几个步骤几乎是理解后文所有概念的基础。后面提到的阻塞、非阻塞、同步、异步,本质上都和这几个阶段里“线程是否等待”“结果如何通知”“谁来完成数据搬运的最后一步”有关。
理解 I/O 的两个阶段
很多概念混淆,根源都在于没有把 I/O 的两个阶段拆开:
- 等待就绪: 数据还没到,或者对端还没发,或者网卡/磁盘还没把数据准备好
- 复制数据: 数据已经准备好了,但还要从内核缓冲区搬到用户缓冲区
不同 I/O 模型的核心差异,主要就在于这两个阶段里线程怎么参与。
flowchart TB
A[应用发起 I/O 请求] --> B[等待数据就绪]
B --> C[数据进入内核缓冲区]
C --> D[复制到用户缓冲区]
D --> E[应用真正拿到数据]
如果从这两个阶段来看:
- BIO 往往是线程从头等到尾
- 非阻塞 read/write 会让线程在“等待就绪”阶段尽快返回
- I/O 多路复用会把“等待很多连接是否就绪”这件事集中交给
select/poll/epoll - AIO 则是把“完成后通知”这一层进一步交给系统或实现框架
同步和异步
同步:
同步强调的是结果依赖关系。发起方在拿到结果之前,后续处理不能真正完成,必须自己盯着这次调用的结果。
放到 I/O 里,可以把同步理解为:调用方自己负责等待结果、检查状态、拿走结果并继续往下执行。
如果用一个更生活化的例子来理解,同步很像“排队到窗口买火车票”。
售票窗口一次只能处理一个人,售票系统真正关心的是购票动作必须一个接一个地完成。至于排队的人是在看手机、聊天,还是做别的事情,并不影响“必须轮到前一个人结束,下一个人才能开始”这个事实。
这个例子说明,同步首先强调的是多个动作之间的先后关系,而不是等待期间每个人具体处于什么状态。
除了这种由于共享资源导致的同步,还存在一种由于逻辑先后顺序导致的同步。
例如,先更新代码,再编译,再打包。这几个动作不一定是因为抢占同一个物理资源才必须串行执行,而是因为后一步本身依赖前一步的结果。如果代码还没有更新完成,编译就无从谈起;如果编译产物还没有生成,打包也无法继续。
例如:
- 调用
read()后,线程自己等待读结果 - 调用
Future.get()时,线程自己等待异步任务结果 - 调用方不断轮询某个状态是否完成
同步并不天然等于阻塞,但同步意味着调用方要自己对结果负责。至于是傻等、轮询,还是先做别的事情再回来拿结果,那是阻塞/非阻塞层面的问题。
异步:
异步强调的是结果通知方式。发起方把请求交出去后,不需要自己一直盯着这个过程,等结果准备好之后,由系统或其他机制主动通知。
放到 I/O 里,可以把异步理解为:调用方发起请求后先返回,等完成时由回调、事件、通知机制把结果交回来。
如果继续沿用生活中的类比,异步更像“取号后等待通知”。
动作发起之后,后续不需要一直站在窗口前盯着办理过程,而是可以先去处理别的事情。等系统准备好结果之后,再通过广播、短信、叫号屏或其他通知方式把结果交回来。
这个例子里最关键的点,不是“过程中有没有等待”,而是是否必须由发起方自己一直守着结果。
例如:
CompletionHandler在异步通道完成时被回调- 事件循环收到完成事件后再处理结果
- 消息、信号、通知把处理结果推回给调用方
同步和异步的关注点,可以概括成下面这张表:
| 概念 | 关注点 | 核心问题 |
|---|---|---|
| 同步 | 调用方自己拿结果 | 结果没到之前,后续逻辑怎么继续 |
| 异步 | 完成后如何通知 | 结果准备好之后,谁来把结果交回来 |
阻塞和非阻塞
阻塞:
阻塞强调的是当前线程能不能继续往下执行。
如果线程发起一个调用后被挂起,直到条件满足才能恢复执行,这就是阻塞。
可以把阻塞理解成“道路已经堵死,车暂时动不了”。
前方条件没有满足之前,当前这辆车只能停在原地;放到程序里,就是当前线程不能继续推进这条执行路径,只能等待系统再次唤醒。
典型情形:
- 调用
read(),没有数据就睡眠等待 - 调用
accept(),没有连接就阻塞 - 调用
epoll_wait(),没有事件就阻塞等待
非阻塞:
非阻塞强调的是当前调用尽快返回,不把线程挂死在原地。
如果条件还没准备好,就立即返回一个“暂时不可做”的结果,例如返回 0、返回 EAGAIN/EWOULDBLOCK、或者返回一个未完成的句柄,让调用方稍后再来处理。
继续用交通来类比,非阻塞更像“前方暂时不能通过,但车辆可以先绕开,稍后再回来确认”。
对应到程序里,当前调用不会把线程钉在原地,而是尽快把控制权还给调用方。至于下一步是稍后重试、轮询检查,还是交给事件机制统一处理,那是更上层的策略问题。
阻塞和非阻塞的关注点,也可以概括成下面这张表:
| 概念 | 关注点 | 核心问题 |
|---|---|---|
| 阻塞 | 线程是否被挂起 | 当前线程能不能继续干别的事 |
| 非阻塞 | 调用是否立即返回 | 条件未就绪时,是否必须卡在原地 |
同步/异步 与 阻塞/非阻塞 的关系
这两组概念很容易被混在一起,但它们并不是同一个维度:
- 同步/异步关注的是结果如何交付
- 阻塞/非阻塞关注的是线程在等待期间能不能继续执行
如果直接背定义,通常会越看越绕。更容易理解的方式,是重新回到那两个基础阶段:
- 等待数据就绪
- 把数据从内核缓冲区复制到用户缓冲区
先只看阻塞和非阻塞:
- 如果线程在“等待数据就绪”这个阶段被挂起,就是阻塞
- 如果线程发起调用后立刻返回,没有数据就下次再来,就是非阻塞
再看同步和异步:
- 如果调用方需要自己反复检查状态、自己调用
read()把结果拿回来,这一层更接近同步 - 如果调用方把请求提交出去后,不需要自己盯着过程,而是等系统在完成后回调或通知,这一层更接近异步
按照这个思路,再去看几种常见情况就容易很多:
| 场景 | 等待数据阶段 | 拷贝数据阶段 | 更贴近的语义 |
|---|---|---|---|
阻塞 read() |
线程阻塞等待 | 线程继续参与拷贝 | 同步阻塞 |
非阻塞 read() + 轮询 |
没数据立即返回 | 真正读时仍要自己拿数据 | 同步 + 非阻塞 |
select/poll/epoll + read() |
阻塞在复用器上等就绪 | 就绪后自己调用 read() |
同步 + 多路复用 |
| AIO/完成回调 | 提交后立即返回 | 由系统或实现完成后通知 | 异步,通常也表现为非阻塞 |
这里最容易混淆的一点是:
- 非阻塞 I/O 不等于“线程完全不参与 I/O”
- 同步 I/O 也不等于“所有阶段都必须一直睡死在那里”
以非阻塞 read() 为例:
- 第一次调用时,如果数据还没到,立即返回
EAGAIN - 线程没有被挂起,所以这是“非阻塞”
- 但什么时候再读、什么时候真正把数据取走,仍然要调用方自己负责
- 因此它在结果交付语义上依然更接近同步
很多文章会把“同步非阻塞”直接一票否决,这种说法在入门阶段容易形成误导。更稳妥的说法是:在操作系统经典网络 I/O 模型里,人们通常直接说“非阻塞 I/O”或“I/O 多路复用”;但如果从更一般的结果交付语义去看,同步和非阻塞并不是绝对互斥。
所以,理解这几组术语时,关键不是机械背定义,而是先问清楚下面三个问题:
- 当前线程是否被挂起
- 调用方是否需要自己盯着结果
- 结果完成后是谁来通知下一步逻辑
BIO、NIO、AIO 基本定义
先给出一个最常见、也是最适合 Java 入门阶段使用的理解框架:
- BIO(Blocking I/O): 以阻塞式流读写为代表,一个线程通常绑定一个连接或一个 I/O 操作
- NIO(New I/O): Java 1.4 引入的新 I/O API,核心能力包括
Channel、Buffer、Selector;在网络编程里,最常讨论的是“非阻塞通道 + Selector 多路复用” - AIO(Asynchronous I/O): Java 7 的 NIO.2 异步通道 API,调用方通过
Future或CompletionHandler获取完成结果
需要注意两点:
NIO本身是一个API 体系,不等于“只存在一种固定 I/O 模型”AIO是 Java 提供的异步编程接口语义,但底层是否由操作系统原生异步完成,依赖平台和 JDK 实现
下表可以先建立一个总览:
| Java 语境 | 更贴近的底层理解 | 典型特征 |
|---|---|---|
| BIO | 阻塞 I/O | 一个线程在单个 I/O 上等待 |
| NIO(非阻塞通道 + Selector) | 非阻塞 I/O + I/O 多路复用 | 一个线程管理多个连接就绪事件 |
| AIO | 异步完成通知模型 | 提交后由 Future 或回调接收结果 |
BIO(Blocking I/O)同步阻塞I/O
这是最基本、也最符合直觉的一种 I/O 方式。发起一次读写后,线程通常会一直等到这次操作完成,再继续处理下一步逻辑。
它的优点是:
- 编程模型简单,顺序思维强
- 调试和排查问题相对直接
- 并发量不高时,完全够用
它的缺点也很明显:
- 一个线程通常同时只能盯住一个连接或一个阻塞点
- 大量空闲连接会占着线程不放
- 并发量上来以后,线程切换、栈内存占用和上下文调度成本都会明显放大
所以 BIO 并不是“过时不能用”,而是更适合:
- 连接数不高
- 业务逻辑远重于 I/O 等待
- 对开发简单性要求高于极致并发
- 使用线程池后仍能把线程数量控制在合理范围内
如果是典型的高并发 Web/TCP 服务端,BIO 往往很快就会遇到线程数膨胀的问题。
NIO (New I/O) 与非阻塞、多路复用
关于 NIO,经常有两个容易混淆的点:
NIO的官方含义是 New I/O,不是 Non-Blocking I/O- Java NIO 这个 API 体系既包含 Buffer/Channel,也包含 Selector;讨论高并发网络模型时,说的往往是“非阻塞通道 + Selector”
NIO 的核心目标是解决 BIO 在大并发场景下“一个连接一个线程”的资源浪费问题。
在 BIO 模型中,如果需要同时处理很多连接,通常就要开很多线程。
在 NIO 模型中,可以把大量连接注册到 Selector 上,由一个或少量线程统一等待“哪些连接已经就绪”,只有真正可读、可写、可连接时,才对对应的 Channel 做实际处理。这背后用到的核心思想,就是 I/O 多路复用。
如下图所示,NIO 的编程模型不再直接围绕 InputStream/OutputStream 展开,而是围绕 Channel 和 Buffer 展开:

通过比较 New I/O 的使用方式可以发现,新 I/O 操作不再只面向 Stream,而是引入了更明确的 Channel、Buffer 和 Selector 三层抽象。
Channel:
Channel 通常翻译为“通道”。它和传统 IO 里的 Stream 处于相近的抽象层级,但 Channel 往往是双向的,既可以读,也可以写。
NIO 中常见的 Channel 包括:
FileChannel:文件 I/ODatagramChannel:UDPSocketChannel:TCP Client 侧ServerSocketChannel:TCP Server 侧监听连接
Buffer:
Buffer 是 NIO 中非常重要的一层,用来承接“数据放在哪里、从哪里读、往哪里写”。
常见 Buffer 实现包括:
ByteBufferCharBufferIntBufferLongBufferFloatBufferDoubleBuffer
其中最常用的是 ByteBuffer,网络编程里绝大多数场景最终还是围绕字节缓冲区展开。
Selector:
Selector 是 NIO 相对于 BIO 实现多路复用的关键。多个 Channel 可以注册到同一个 Selector 上,然后由一个线程调用 select() 统一等待事件。
这个等待本身通常是阻塞的,但阻塞的是“等待哪几个连接就绪”这件事,而不是对某一个连接单独阻塞读写。这样就能把“等待很多连接”的成本集中处理,从而避免大量线程空转。
对于低流量但连接数很多的场景,例如聊天服务器、网关、代理服务,Selector 会非常有价值。
NIO 的工作方式
可以把 NIO 的经典网络编程方式概括成下面这条链路:
flowchart LR
A[多个 SocketChannel] --> B[注册到 Selector]
B --> C[select 等待就绪事件]
C --> D[拿到 ready keys]
D --> E[按事件类型处理 accept/read/write]
Selector 负责解决的是“哪些连接值得现在去处理”,Channel 和 Buffer 负责解决的是“真正怎么读、怎么写、数据放在哪里”。
补充DirectByteBuffer 与 HeapByteBuffer 的区别:
ByteBuffer 分配内存的两种方式:
-
HeapByteBuffer:内存位于 JVM 堆上,本质上可以理解为对byte[]的包装 -
DirectByteBuffer:内存位于堆外(native memory / off-heap),JVM 会尽量让底层 native I/O 直接操作这块内存,以减少额外中间拷贝
为什么不直接使用 DirectByteBuffer,还要来个 HeapByteBuffer?
原因在于 DirectBuffer 不是“只会更快,没有代价”。它的特点通常是:
- 分配和释放成本通常比堆内 Buffer 更高
- 内存不在 Java 堆里,排查内存占用时更容易被忽视
- 更适合大块、长生命周期、频繁参与 native I/O 的缓冲区
需要特别注意的是:
DirectByteBuffer的堆外内存释放依赖 JVM 对象生命周期和清理机制,不适合随手大量创建小对象后指望它“立刻自动回收”- 直接内存有上限,通常受
-XX:MaxDirectMemorySize影响 - 在常见 HotSpot 实现中,如果没有显式指定
-XX:MaxDirectMemorySize,实际可用上限通常会和堆最大值同量级,而不是简单理解成固定 64M
所以,DirectByteBuffer 适合的是“能明显降低拷贝成本”的场景,而不是“只要是 NIO 就默认全部上 DirectBuffer”。
这里再来看一个 NIO 模型下的 TCP 服务器的实现,通过代码可以看到 Selector 正是 NIO 模型下 TCP Server 实现 IO 复用的关键,请仔细理解下段代码 while 循环中的逻辑,见下图:

AIO (Asynchronous I/O) 与异步通道
Java AIO 通常指的是 JDK 7 引入的 NIO.2 异步通道能力,例如:
AsynchronousFileChannelAsynchronousServerSocketChannelAsynchronousSocketChannel
异步通道的结果获取通常有两种形式:
- 返回
Future - 提供
CompletionHandler回调
从编程模型上看,AIO 相对于 NIO 的区别在于:
- NIO 更常见的写法是“调用方通过 Selector 观察就绪事件,再主动发起读写”
- AIO 更像是“调用方先把操作提交出去,完成后再通过回调或 Future 获取结果”
但这里有一个非常重要的边界:Java AIO 的 API 语义是异步完成通知;底层是否等价为操作系统原生的真正异步 I/O,需要看平台和实现。
更稳妥的理解方式是:
- 在 Windows 上,底层更容易映射到 IOCP 这类完成通知模型
- 在 Linux/Unix 上,JDK 的具体 provider 可能结合线程池、内核通知机制等方式来实现异步接口语义
- 因此不能简单地说“Java AIO 在所有平台上都一定直接对应底层真异步 I/O”
这也是为什么在 Java 高并发网络编程里,生产实践中更常见的是基于 NIO/Selector 的框架,例如 Netty;AIO API 虽然存在,但不是所有场景都会把它作为默认首选。
从经典网络 I/O 模型再看
如果回到操作系统层面,经典网络 I/O 模型通常会分成下面几类:
| 模型 | 等待数据就绪 | 拷贝数据到用户空间 | 线程表现 | 典型说明 |
|---|---|---|---|---|
| 阻塞 I/O | 阻塞 | 阻塞 | 线程从头等到尾 | 最传统的 read() |
| 非阻塞 I/O | 不阻塞,没数据立即返回 | 真正开始拷贝时仍需要参与 | 线程反复重试 | O_NONBLOCK + 轮询 |
| I/O 多路复用 | 阻塞在复用器上 | 真正读写时仍要调用 read/write |
一个线程等很多连接 | select/poll/epoll |
| 信号驱动 I/O | 就绪后由信号通知 | 读数据时仍要主动调用 | 工程里不算最主流 | SIGIO |
| 异步 I/O | 不阻塞 | 由系统完成后通知 | 调用方提交后可继续执行 | completion-style |
如果只看“线程是不是卡住”,可以先这样理解:
- 阻塞 I/O: 线程自己等数据,数据到了以后还是线程自己把数据拿走
- 非阻塞 I/O: 线程先去问一句“数据到了没”,没到就立刻返回,过一会儿再来问
- I/O 多路复用: 不再逐个连接傻问,而是先让
select/poll/epoll统一等,等到“某些连接已经好了”再去读 - 异步 I/O: 先把请求交出去,数据准备和拷贝都完成后,再把结果通知回来
所以这几种模型的关键差异,可以概括为:
- 非阻塞 I/O 解决的是“等待数据时,线程是否必须原地卡住”
- I/O 多路复用 解决的是“很多连接同时等待时,是否必须一个连接占一个线程”
- 异步 I/O 解决的是“结果完成后,是否还需要调用方亲自去把数据取回来”
这也是为什么:
- 非阻塞 I/O 不是“数据已经拷到用户空间后才通知线程”,那样的语义更接近异步 I/O
- I/O 多路复用 本身仍然属于同步模型,因为应用线程最终还是要自己调用
read/write - 异步 I/O 才更接近“内核或实现把事情做完后,再把结果交给应用”
多路复用
I/O 多路复用(I/O multiplexing)是一种非常重要的同步 I/O 模型。它的核心不是“替应用把数据读完”,而是让一个线程可以同时监视多个文件描述符,知道哪些连接已经具备读写条件。
“多路”指的是多个连接、多个 socket、多个文件描述符;“复用”指的是把原本可能需要很多线程分别等待的工作,集中复用到一个或少量线程上完成。

它的本质可以理解为:不再让每个连接各自睡一个线程,而是让一个线程先等“事件通知”,只在连接真正就绪时再处理它。
发明它的原因,就是尽量提高高并发服务器的吞吐能力,减少无意义的线程阻塞和上下文切换。

在同一个线程里,通过“先知道谁准备好了,再处理谁”的方式,同时管理多个 I/O 流。
IO 多路复用解决的问题:
应用程序经常要同时处理来自多条事件流的事件。例如操作系统要处理键盘、鼠标、中断;Nginx、Netty 这类服务端则要同时处理大量客户端连接。
CPU 单核同一时刻只能做一件事。最直接的办法当然是多线程或多进程,让操作系统调度不同执行流轮流占用 CPU。但这会带来额外成本。
但凡事都是有成本的。线程/进程也一样,有这么几个方面:
- 线程/进程创建成本
- CPU 切换不同线程/进程的成本(Context Switch)
- 多线程的资源竞争
I/O 多路复用可以在单线程或少量线程中处理多个事件流,因此它解决的本质问题是:用更少的线程资源完成更多连接的等待与调度工作。
IO多路复用的三种实现方式:select、poll、epoll
select 是第一个实现 (1983 左右在 BSD 里面实现):
select 很早就被实现出来了,但也很快暴露出一些问题:
- 需要反复把待监视描述符集合从用户态传入内核态
- 返回后仍然要遍历描述符集合,找出到底哪些 fd 就绪
fd_set通常存在FD_SETSIZE限制,很多实现里默认是 1024- 当连接数很大时,线性扫描成本明显
14年以后(1997年)其他人实现了 poll, poll 修复了 select 的很多问题:
poll去掉了select那种固定 1024 上限的典型限制poll使用pollfd数组表达关注事件,接口设计上更灵活一些
但是 poll 依然要在每次调用时把关注列表交给内核,再由内核遍历,因此在大量连接场景下,复杂度问题仍然存在。
5年以后, 在2002, Davide Libenzi 实现了epoll:
epoll 是 Linux 上 I/O 多路复用的重要实现。它和 select/poll 的关键差异在于:
- 关注列表保存在内核中,不需要每次完整重复传入
- 内核维护 ready list,
epoll_wait返回的是就绪事件集合 - 更适合大量连接、少量活跃连接的高并发场景
- 支持 LT(水平触发)和 ET(边沿触发)两种模式
需要注意的是,epoll_wait() 本身仍然是一个阻塞等待事件的系统调用,所以它属于同步 I/O 多路复用模型,并不等价于“异步 I/O”。
可以把三者的差异总结成下面这张表:
| 机制 | 描述符数量限制 | 每次调用是否重复传入全部 fd | 就绪结果获取方式 | 适用场景 |
|---|---|---|---|---|
| select | 常见实现里限制明显 | 是 | 返回后自己遍历 | 连接数较少、兼容性优先 |
| poll | 无固定 1024 限制 | 是 | 返回后自己遍历 | 中等规模、可移植性要求较高 |
| epoll | Linux 下可扩展性更好 | 否 | 返回 ready list | 高并发网络服务器 |
epoll 的缺点也很明确:它是 Linux 特有机制,不具备跨平台通用性。
在 BSD/macOS 上更常见的是 kqueue,在 Windows 上则是 IOCP。
BIO、NIO、AIO 的关系总结
最后把全文最容易混淆的地方再收束一下:
- BIO、NIO、AIO 是 Java 语境中的 API 和编程模型术语
- 阻塞/非阻塞、同步/异步、select/poll/epoll 是更底层的 I/O 语义或操作系统机制
- Java NIO 在网络编程里最常见的落地方式,是非阻塞通道配合 Selector 实现 I/O 多路复用
- Java AIO 表达的是异步完成通知接口,不应简单等同于所有平台上的“内核原生真异步 I/O”
如果只需要先记一条最实用的经验,可以概括为:
- 连接少、实现简单优先,用 BIO 也完全可以
- 高并发网络服务端,通常优先考虑基于 NIO/Selector 的方案
- AIO 更适合理解异步完成式接口模型,但 Java 生产实践里并不一定比 NIO 更常见
到这里,再去看 Netty、Tomcat、Redis、Nginx 这类高并发框架或中间件的网络模型,就更容易把“为什么要事件循环、为什么要 Reactor、为什么要多路复用”这些问题串起来。