主题 14网络与 IO 模型
「BIO / NIO / AIO」这六个字母谁都会背,但它们之间到底差多少?这一章用一台真机把账算出来: 1500 个连接,BIO 需要 1508 个线程;同样是 5000 个连接,NIO 只要 8 个。 差了 188 倍——这不是优化,是能不能撑住的问题。
后半段讲零拷贝:同一份 512MB 文件,普通流 924ms、transferTo 429ms,
2.16 倍。快的部分不在 Java 代码里,而在少拷贝了两次。
14.1五种 IO 模型,差别只在「谁在等」
「同步/异步、阻塞/非阻塞」这两组词之所以绕,是因为它们描述的不是同一件事:
| 词 | 描述的是 | 判断方法 |
|---|---|---|
| 阻塞 / 非阻塞 | 调用方要不要原地等 | 调用返回时,数据到了吗?没到就是阻塞 |
| 同步 / 异步 | 数据拷贝由谁完成 | 数据从内核到用户缓冲区这一步,谁干的? |
把这两条一交叉,就是面试常问的「五种 IO 模型」:
| 模型 | 等数据阶段 | 拷贝阶段 | 典型代表 |
|---|---|---|---|
| 阻塞 IO(BIO) | 阻塞 | 阻塞 | SocketInputStream.read() |
| 非阻塞 IO | 轮询(返回 0 就重试) | 阻塞 | configureBlocking(false) |
| IO 多路复用 | 阻塞在 select/epoll 上 | 阻塞 | Selector / Netty / Redis |
| 信号驱动 IO | 不阻塞(等信号) | 阻塞 | 用得很少 |
| 异步 IO(AIO) | 不阻塞 | 内核拷完再通知 | AsynchronousSocketChannel |
read() 返回之前,数据必须从内核缓冲区拷到你的 ByteBuffer。
Redis 单线程能扛 10 万 QPS,靠的就是这一层(13 章测过 SCAN 每批只堵 3.3ms)。
14.2BIO 的账:1500 个连接,1508 个线程
BIO 的写法最直观:accept() 到一个连接,就开一个线程去读。实测 1500 个连接:
=== BIO:每个连接一个线程 ===
建立 1000 个连接(580 ms) 线程数 1008 堆占用 20 MB
再连 500 个(共 1500) 线程数 1508 堆占用 29 MB
两个数字值得盯住:
- 线程数 = 连接数 + 8。那 8 个是 JVM 自己的(main、GC、编译线程等), 也就是说一个连接一个线程,一个不多一个不少;
- 每连接约 20KB 堆(20MB/1000)。这还只是 Java 堆,
线程栈不在堆里——默认
-Xss512k,1000 个线程又是 500MB 栈内存。
所以 BIO 的真实上限不是「堆够不够」,是线程数:
| 连接数 | 线程数 | 栈内存(512KB/线程) | 结果 |
|---|---|---|---|
| 1,000 | 1,008 | 约 500 MB | 勉强 |
| 5,000 | 5,008 | 约 2.5 GB | 大部分机器已经吃紧 |
| 10,000 | 10,008 | 约 5 GB | 线程创建开始失败 |
| 50,000 | — | — | 操作系统线程数上限,直接拒绝 |
read() 上阻塞,CPU 每次调度都要保存/恢复寄存器,
而真正干活的时间接近 0。这就是 C10K 问题的本质——
不是处理不过来,是把时间花在了「切换」上。
14.3NIO:8 个线程管 5000 个连接
同样的连接数,换成 Selector 单线程轮询:
=== NIO:一个 selector 线程管所有连接 ===
建立 1000 个连接(313 ms) 线程数 8 堆占用 6 MB
建立 5000 个连接(1615 ms) 线程数 8 堆占用 12 MB
把两组合起来看,这一章的核心对比就出来了:
| 连接数 | BIO 线程 / 堆 | NIO 线程 / 堆 | 差距 |
|---|---|---|---|
| 1,000 | 1008 / 20 MB | 8 / 6 MB | 线程 126 倍 |
| 1,500 | 1508 / 29 MB | 8 / — | — |
| 5,000 | (按比例约 5000,已到上限) | 8 / 12 MB | 线程 约 625 倍 |
NIO 那 8 个线程里,真正干活的只有 1 个(selector 轮询线程),剩下是 JVM 的固定开销。
连接数从 1000 涨到 5000,线程数一个没加,只多了 6MB 堆(每个连接一个 SocketChannel 对象)。
代价也很清楚:建连变慢了(1000 连接 BIO 580ms、NIO 313ms; 但 5000 连接 NIO 要 1615ms)。因为建连还得靠客户端一条条来, selector 只能事后发现「这个连接可读了」,不能替你去连。
bossGroup 1 个线程(只管 accept)+ workerGroup CPU×2 个线程(管读写)。
一个 16 核机器上是 1 + 32。它不是「单线程」,是「少量线程 + 事件驱动」——
这个区别在面试里经常被追问。
14.4Selector 在不同系统上不是同一个东西
Java 的 Selector 只是一个接口,底下换过三次实现:
| 系统 | 底层实现 | 复杂度 | 连接数上限 |
|---|---|---|---|
| Linux | epoll | O(1),只返回就绪的 fd | 受内存限制(十万级) |
| macOS | kqueue | O(1) | 受内存限制 |
| Windows | select(有 1024 上限)/ IOCP | O(n),每次遍历全量 fd | select 模式 1024 |
select 和 epoll 的差别只有一句话,但决定了能不能上 C10K:
select:把 1024 个 fd 拷进内核,内核遍历一遍看谁就绪, 再拷回来。连接越多越慢,而且上限写死在FD_SETSIZE;epoll:fd 注册一次就挂在内核的红黑树上,就绪的放另一个链表。 调用时只返回就绪的那几个,与总连接数无关。
select 了——
sun.nio.ch.WindowsSelectorImpl 走的是 select + 分批 或 IOCP 混合实现。
想看自己机器用的哪一个,打印 Selector.open().getClass().getName() 即可。
「NIO 一定比 BIO 快」是错的,但「NIO 一定比 BIO 省线程」是对的——这才是它的价值。
14.5零拷贝:少了两次拷贝,快了 2.16 倍
把 512MB 文件复制一份,两种写法:
=== 复制一个 512 MB 的文件 ===
普通流 + 8KB 缓冲区 : 924 ms (554 MB/s)
transferTo 零拷贝 : 429 ms (1195 MB/s)
快了 2.16 倍
为什么快?因为普通写法数据被拷了四次,transferTo 只拷两次(甚至可以零次):
| 步骤 | 普通流(read + write) | transferTo | 省掉的拷贝 |
|---|---|---|---|
| 1 | 磁盘 → 内核缓冲区(DMA) | 磁盘 → 内核缓冲区(DMA) | — |
| 2 | 内核缓冲区 → 用户缓冲区 | 跳过 | 省一次 |
| 3 | 用户缓冲区 → socket 缓冲区 | 跳过 | 省一次 |
| 4 | socket 缓冲区 → 网卡(DMA) | 内核缓冲区 → 网卡(DMA) | — |
关键在第 2、3 步:普通写法必须把数据搬进用户态(因为你的 byte[] 在那里),
transferTo 让数据全程待在内核态,只传「从哪里读到哪去」的指令。
这就是「零拷贝」这个名字的由来——零的是「用户态拷贝」,不是所有拷贝。
| 场景 | 用什么 | 为什么 |
|---|---|---|
| 文件 → 文件 | FileChannel.transferTo | 实测 2.16 倍 |
| 文件 → 网络(下载) | transferTo 到 SocketChannel | Kafka 就是这么发消息的 |
| 需要修改数据 | 不能用零拷贝 | 一旦要读进用户态改,就退化成普通拷贝 |
| 小文件 / 小消息 | 普通流 | 零拷贝的系统调用开销反而更大 |
mmap 算零拷贝吗?」——
算,但它是另一种:mmap 把内核缓冲区映射到用户空间,
省掉了第 2 步,但第 3 步(写到 socket)还在。transferTo 在 Linux 上优先用
sendfile,连第 3 步都省了。
答「mmap 少一次、sendfile 少两次」比答「都是零拷贝」准确得多。
14.6Netty 在 NIO 之上又包了什么
既然 JDK 自带 NIO,为什么还要 Netty?因为 NIO 只给了零件,Netty 给的是整机:
| 问题 | 裸 NIO 的坑 | Netty 怎么解 |
|---|---|---|
| 半包 / 粘包 | 一次 read 可能只读到半个包,要自己拼 | 内置 LengthField / Delimiter 解码器 |
| JDK 的空轮询 bug | select() 返回 0 会 100% 占 CPU | 已修复 + 计数规避 |
| Reactor 线程模型 | 要自己设计 boss/worker 分工 | 主从 Reactor 开箱即用 |
| ByteBuf | ByteBuffer 读写共用 position,flip 容易忘 | 读写双指针,还有池化分配 |
| 零拷贝 | 要自己判断能不能用 transferTo | FileRegion + CompositeByteBuf |
第 2 条值得单独说:JDK 的 Selector.select() 在 Linux 上有过著名的
「就绪列表被清空但仍返回」的 bug,导致 select() 立刻返回 0,
while(true) 里就变成空转。Netty 的规避方式是:统计空轮询次数,超过 512 次就重建 Selector。
这类「框架替你踩过的坑」是面试里最能体现深度的地方。
14.7速记卡与自查
| # | 卡 | 内容(带数字) |
|---|---|---|
| 1 | BIO 的上限 | 一个连接一个线程。1500 连接 = 1508 线程、29MB 堆,外加 512KB×1500 的栈。 撑不住的不是内存,是上下文切换。 |
| 2 | NIO 省的是什么 | 省线程,不省拷贝。5000 连接只要 8 个线程(真正干活的 1 个),
数据拷贝仍然同步。select 是 O(n) 且上限 1024,epoll 是 O(1)。 |
| 3 | 零拷贝 | 普通流拷 4 次,transferTo/sendfile 拷 2 次。512MB 文件 924ms → 429ms(2.16 倍)。
mmap 只省一次,sendfile 省两次。 |
动手清单与自查表
| # | 动手项 | 可观察的判据 |
|---|---|---|
| 1 | 跑 NetDemo bio 1000 |
线程数 1008,正好是连接数 + 8 |
| 2 | 跑 NetDemo nio 1000 和 nio 5000 |
两次都是 8 个线程,只多了 6MB 堆 |
| 3 | 把 BIO 的连接数提到 3000,观察建连耗时 | 耗时明显变长,甚至开始抛 OutOfMemoryError: unable to create native thread |
| 4 | 跑 NetDemo copy 512 |
924ms vs 429ms,2.16 倍 |
| 5 | 把 transferTo 换成「先读进 ByteBuffer 再 write」 |
倍数回落到 1.0 附近——零拷贝的前提是不碰数据 |
new Thread 去处理数据」,再跑 5000 连接——线程数立刻回到 5000 以上。
NIO 省线程的前提是「处理逻辑足够短」:一旦你在 selector 线程里做耗时操作(查库、调接口),
整个模型就退化成 BIO,而且更糟——因为你把 5000 个连接一起堵住了。
这就是 Netty 要分 bossGroup / workerGroup / 业务线程池的原因。
| 卡住了怎么办 | 去哪查 |
|---|---|
| 不知道本机用的哪个 Selector | Selector.open().getClass().getName() |
| 不知道线程都在干嘛 | jstack <pid> | grep "socketRead" 看有多少线程卡在读上 |
| 不知道连接数够不够 | ss -s(Linux)看 TCP: established;Windows 用 netstat -ano | find /c "ESTABLISHED" |
| 想验证零拷贝有没有生效 | Linux 上用 strace -e trace=sendfile 看有没有这个系统调用 |