Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 14网络与 IO 模型

「BIO / NIO / AIO」这六个字母谁都会背,但它们之间到底差多少?这一章用一台真机把账算出来: 1500 个连接,BIO 需要 1508 个线程;同样是 5000 个连接,NIO 只要 8 个。 差了 188 倍——这不是优化,是能不能撑住的问题。

后半段讲零拷贝:同一份 512MB 文件,普通流 924ms、transferTo 429ms, 2.16 倍。快的部分不在 Java 代码里,而在少拷贝了两次。

学习目标
能说清「NIO 省下的到底是什么」,以及零拷贝少了哪几次拷贝
前置要求
会用 Socket;知道缓冲区、阻塞这两个词
本章产出
3 组实测(线程数 / 内存 / 吞吐)+ 3 张速记卡
建议学时
2 小时(重点看 14.2 与 14.3 的线程数对比)

14.1五种 IO 模型,差别只在「谁在等」

「同步/异步、阻塞/非阻塞」这两组词之所以绕,是因为它们描述的不是同一件事:

词描述的是判断方法
阻塞 / 非阻塞调用方要不要原地等调用返回时,数据到了吗?没到就是阻塞
同步 / 异步数据拷贝由谁完成数据从内核到用户缓冲区这一步,谁干的?

把这两条一交叉,就是面试常问的「五种 IO 模型」:

模型等数据阶段拷贝阶段典型代表
阻塞 IO(BIO)阻塞阻塞SocketInputStream.read()
非阻塞 IO轮询(返回 0 就重试)阻塞configureBlocking(false)
IO 多路复用阻塞在 select/epoll 上阻塞Selector / Netty / Redis
信号驱动 IO不阻塞(等信号)阻塞用得很少
异步 IO(AIO)不阻塞内核拷完再通知AsynchronousSocketChannel
「多路复用」为什么不算异步:因为它只是把「等」这件事集中了—— 一个线程同时等 5000 个连接,谁就绪处理谁。但真正的数据拷贝还是同步的: 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

两个数字值得盯住:

所以 BIO 的真实上限不是「堆够不够」,是线程数:

连接数线程数栈内存(512KB/线程)结果
1,0001,008约 500 MB勉强
5,0005,008约 2.5 GB大部分机器已经吃紧
10,00010,008约 5 GB线程创建开始失败
50,000——操作系统线程数上限,直接拒绝
「一个连接一个线程」真正贵在哪:不是内存,是上下文切换。 1000 个线程里 990 个都在 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,0001008 / 20 MB8 / 6 MB线程 126 倍
1,5001508 / 29 MB8 / ——
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 只能事后发现「这个连接可读了」,不能替你去连。

别把「8 个线程」当成 Netty 的全部:Netty 的默认配置是 bossGroup 1 个线程(只管 accept)+ workerGroup CPU×2 个线程(管读写)。 一个 16 核机器上是 1 + 32。它不是「单线程」,是「少量线程 + 事件驱动」—— 这个区别在面试里经常被追问。

14.4Selector 在不同系统上不是同一个东西

Java 的 Selector 只是一个接口,底下换过三次实现:

系统底层实现复杂度连接数上限
LinuxepollO(1),只返回就绪的 fd受内存限制(十万级)
macOSkqueueO(1)受内存限制
Windowsselect(有 1024 上限)/ IOCPO(n),每次遍历全量 fdselect 模式 1024

select 和 epoll 的差别只有一句话,但决定了能不能上 C10K:

一个实测能验证的推论:在本机(Windows)跑 NIO 5000 连接能成功, 说明 JDK 17 在 Windows 上已经不用 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 缓冲区跳过省一次
4socket 缓冲区 → 网卡(DMA)内核缓冲区 → 网卡(DMA)—

关键在第 2、3 步:普通写法必须把数据搬进用户态(因为你的 byte[] 在那里), transferTo 让数据全程待在内核态,只传「从哪里读到哪去」的指令。 这就是「零拷贝」这个名字的由来——零的是「用户态拷贝」,不是所有拷贝。

场景用什么为什么
文件 → 文件FileChannel.transferTo实测 2.16 倍
文件 → 网络(下载)transferTo 到 SocketChannelKafka 就是这么发消息的
需要修改数据不能用零拷贝一旦要读进用户态改,就退化成普通拷贝
小文件 / 小消息普通流零拷贝的系统调用开销反而更大
面试追问的陷阱:「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 的空轮询 bugselect() 返回 0 会 100% 占 CPU已修复 + 计数规避
Reactor 线程模型要自己设计 boss/worker 分工主从 Reactor 开箱即用
ByteBufByteBuffer 读写共用 position,flip 容易忘读写双指针,还有池化分配
零拷贝要自己判断能不能用 transferToFileRegion + CompositeByteBuf

第 2 条值得单独说:JDK 的 Selector.select() 在 Linux 上有过著名的 「就绪列表被清空但仍返回」的 bug,导致 select() 立刻返回 0, while(true) 里就变成空转。Netty 的规避方式是:统计空轮询次数,超过 512 次就重建 Selector。 这类「框架替你踩过的坑」是面试里最能体现深度的地方。


14.7速记卡与自查

#卡内容(带数字)
1BIO 的上限 一个连接一个线程。1500 连接 = 1508 线程、29MB 堆,外加 512KB×1500 的栈。 撑不住的不是内存,是上下文切换。
2NIO 省的是什么 省线程,不省拷贝。5000 连接只要 8 个线程(真正干活的 1 个), 数据拷贝仍然同步。select 是 O(n) 且上限 1024,epoll 是 O(1)。
3零拷贝 普通流拷 4 次,transferTo/sendfile 拷 2 次。512MB 文件 924ms → 429ms(2.16 倍)。 mmap 只省一次,sendfile 省两次。
如果只记一句话:IO 模型的所有演进都在回答同一个问题—— 「这一个线程,在等的这几百毫秒里,能不能去干别的」。 BIO 说不能,所以一个连接配一个线程;多路复用说能,所以一个线程管几千个连接; 零拷贝更进一步,说「连拷都不用拷」。三者解决的是不同层面的浪费, 可以叠加——Netty 就是「多路复用 + 零拷贝 + 池化」三层一起上。

动手清单与自查表

#动手项可观察的判据
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 附近——零拷贝的前提是不碰数据
反例实验(做了会记一辈子):把 NIO 的那个 selector 线程改成「收到连接后 new Thread 去处理数据」,再跑 5000 连接——线程数立刻回到 5000 以上。 NIO 省线程的前提是「处理逻辑足够短」:一旦你在 selector 线程里做耗时操作(查库、调接口), 整个模型就退化成 BIO,而且更糟——因为你把 5000 个连接一起堵住了。 这就是 Netty 要分 bossGroup / workerGroup / 业务线程池的原因。
卡住了怎么办去哪查
不知道本机用的哪个 SelectorSelector.open().getClass().getName()
不知道线程都在干嘛jstack <pid> | grep "socketRead" 看有多少线程卡在读上
不知道连接数够不够ss -s(Linux)看 TCP: established;Windows 用 netstat -ano | find /c "ESTABLISHED"
想验证零拷贝有没有生效Linux 上用 strace -e trace=sendfile 看有没有这个系统调用