Java IO(二):NIO 核心与多路复用
Java IO(二):NIO 核心与多路复用
导语:NIO 是 IO 部分的重头戏——Buffer 的状态机、零拷贝到底省了几次拷贝、Selector 背后的 select/poll/epoll 演进,几乎逢面必问。本篇覆盖 NIO 三大组件、零拷贝、Selector 与多路复用,共 20 题。
一、NIO 三大组件
1. 什么是 NIO?它与 BIO 的本质区别是什么?
答: NIO(New IO / Non-blocking IO)是 JDK 1.4 引入的全新 IO API,位于 java.nio 包,模型是同步非阻塞。
与 BIO 的本质区别:
| 维度 | BIO | NIO |
|---|---|---|
| 数据抽象 | 面向流(Stream) | 面向缓冲区(Channel + Buffer) |
| 方向 | 流是单向的(输入流或输出流) | Channel 是双向的 |
| 阻塞性 | 读写必阻塞 | 可非阻塞,未就绪立即返回 |
| 并发模型 | 一连接一线程 | 一线程管多连接(Selector 多路复用) |
| 数据起点 | 直接读到程序中 | 必须先读入 Buffer,再从 Buffer 处理 |
核心记忆点:NIO 是用"少线程管多连接"换掉"多线程管多连接",因此在高连接数、低活跃度的场景下线程利用率极高。
2. NIO 的三大核心组件分别是什么?各自作用?
答:
| 组件 | 作用 |
|---|---|
| Buffer | 数据的容器,读写都经过它,本质是一块可读写内存 + 一组状态变量 |
| Channel | 数据通道,连接文件/Socket,双向可读可写 |
| Selector | 多路复用器,一个线程轮询监听多个 Channel 的就绪事件 |
协作关系:数据从 Channel 读入 Buffer(或从 Buffer 写入 Channel),Selector 负责监听哪些 Channel 已经就绪,从而让一个线程处理成百上千个连接。
3. Channel 与 Stream 的区别是什么?
答:
- 方向:Stream 是单向的,
InputStream只能读、OutputStream只能写;Channel 是双向的,同一个SocketChannel既能读也能写。 - 阻塞性:Stream 的读写一定阻塞;Channel 可工作在非阻塞模式(配合 Selector)。
- 数据载体:Stream 直接读写数据;Channel 必须通过 Buffer 中转,不能直接读写。
- 复用:Channel 可以被多个线程安全地注册到 Selector;Stream 不具备这种能力。
例外:
FileChannel不支持非阻塞模式,因为文件 IO 在操作系统层面总是"就绪"的,不存在等待数据的语义;FileChannel的价值在于transferTo零拷贝与内存映射。
4. 常用的 Channel 实现有哪些?
答:
| 实现 | 用途 | 支持非阻塞 |
|---|---|---|
FileChannel | 文件读写、transferTo 零拷贝、map 内存映射 | 否 |
SocketChannel | TCP 客户端 / 服务端连接 | 是 |
ServerSocketChannel | TCP 服务端监听(替代 ServerSocket) | 是 |
DatagramChannel | UDP 收发 | 是 |
5. Buffer 是什么?position、limit、capacity 的含义?
答: Buffer 是一块内存 + 三个游标组成的状态机:
| 属性 | 含义 |
|---|---|
capacity | 总容量,创建后不可变 |
position | 下一个要读/写的位置 |
limit | 读/写的边界:写模式下是最大可写位置,读模式下是最大可读位置 |
mark | 临时书签,配合 reset() 回到标记位置 |
不变式恒成立:0 ≤ mark ≤ position ≤ limit ≤ capacity。
写模式(刚 allocate 或 clear 后):position = 0,limit = capacity。
读模式(flip() 后):limit = flip 前的 position,position = 0。
新手最容易踩的坑:写完数据直接
get()读不到内容——因为position还在末尾、limit还是容量,必须先flip()。
6. flip()、clear()、rewind()、compact() 的区别?
答:
| 方法 | position | limit | 适用场景 |
|---|---|---|---|
flip() | → 0 | → 原 position | 写完后切换到读模式 |
clear() | → 0 | → capacity | 读完了,准备重新写满(数据丢弃) |
rewind() | → 0 | 不变 | 重读已读过的数据(limit 保持) |
compact() | → 剩余元素个数 | → capacity | 读了一部分就写,把未读数据搬到头部,不丢数据 |
compact() 是唯一能保留未读数据的方法,常用于"边读边写"的管道式处理;但它不保证一次读完,因此 FileChannel.read() 通常要配合 while (buffer.hasRemaining()) 循环。
7. 直接缓冲区(DirectBuffer)与堆缓冲区(HeapBuffer)的区别?
答:
| 维度 | HeapByteBuffer | DirectByteBuffer |
|---|---|---|
| 内存位置 | JVM 堆内(byte[]) | JVM 堆外(本地内存,Unsafe.allocateMemory) |
| 分配/回收 | 快,由 GC 管理 | 慢且昂贵,需调用系统调用与 Cleaner 回收 |
| IO 拷贝 | 内核无法直接访问堆(堆会移动、GC 可能整理),需先拷到临时直接缓冲区 | 内核可直接 DMA,省掉这一次拷贝 |
| 是否受 GC | 受,可用 -Xmx 限制 | 不受 -Xmx 限制,需 -XX:MaxDirectMemorySize 控制,易内存泄漏 |
| 适用 | 频繁小量操作、短期数据 | 长期复用、大块 IO、需要零拷贝 |
ByteBuffer heap = ByteBuffer.allocate(1024); // 堆内
ByteBuffer direct = ByteBuffer.allocateDirect(1024); // 堆外结论:DirectBuffer 用"分配慢、回收难"换"IO 时少一次拷贝"。所以不要每个请求都
allocateDirect,而应复用(如 Netty 的PooledByteBufAllocator)。MappedByteBuffer本身就是一种特殊的直接缓冲区。
8. NIO 中如何进行编码与解码?
答: 用 Charset 把字节与字符互相转换,NIO 提供 CharBuffer(存字符)与 ByteBuffer(存字节)两个容器:
Charset charset = StandardCharsets.UTF_8;
// 解码:字节 -> 字符
CharBuffer chars = charset.decode(ByteBuffer.wrap("中文".getBytes(charset)));
// 编码:字符 -> 字节
ByteBuffer bytes = charset.encode(CharBuffer.wrap("中文"));底层由 CharsetEncoder / CharsetDecoder 实现。关键点:一次 decode 可能产生不完整的字符(多字节字符被缓冲区边界截断),必须检查 CoderResult:
CharsetDecoder decoder = charset.newDecoder();
CoderResult result = decoder.decode(inBuffer, outBuffer, true);
if (result.isUnderflow()) { /* 数据不足,需等待更多字节 */ }这正是 NIO 编解码比 BIO 字符流复杂的原因——非阻塞场景下数据到达是分片的,可能切在多字节字符中间。
二、零拷贝
9. 什么是零拷贝?传统 IO 有几次拷贝、几次上下文切换?
答: 零拷贝(Zero-Copy)不是"一次拷贝都没有",而是让 CPU 不再参与数据在内存之间的复制,并减少用户态/内核态切换。磁盘→内存、内存→网卡这两段 DMA 拷贝依然存在。
传统 read + write 发一个文件到 Socket:4 次拷贝(2 次 DMA + 2 次 CPU)、4 次模式切换。
| 步骤 | 动作 | 类型 |
|---|---|---|
| 1 | 调用 read,用户态→内核态 | 切换 1 |
| 2 | 磁盘 → 内核读缓冲区 | DMA 拷贝 1 |
| 3 | 内核读缓冲区 → 用户缓冲区 | CPU 拷贝 1,切回用户态(切换 2) |
| 4 | 调用 write,用户态→内核态 | 切换 3 |
| 5 | 用户缓冲区 → Socket 缓冲区 | CPU 拷贝 2 |
| 6 | Socket 缓冲区 → 网卡 | DMA 拷贝 2,切回用户态(切换 4) |
面试关键结论:最浪费的是那 2 次 CPU 拷贝——应用根本没有修改数据,只是让数据"路过"用户空间转了一圈。
10. mmap 与 sendfile 的原理与区别?
答:
mmap + write:利用虚拟内存特性,让内核缓冲区与用户空间映射到同一块物理内存,于是省掉"内核→用户"那次 CPU 拷贝。
- 拷贝:2 次 DMA + 1 次 CPU;模式切换:
mmap一次、write一次(首次访问还有缺页开销)。 - 误区:
mmap调用本身只建立映射,不会立刻读盘;真正读盘发生在访问未驻留页触发缺页异常时。 - 坑:映射文件被其他进程
truncate后,再访问被截掉的部分会收到SIGBUS,进程直接崩溃。
sendfile:在两个文件描述符之间直接传数据,数据完全不经过用户空间,把"文件→Socket"的转发收进一次系统调用。
- 拷贝:2 次 DMA + 1 次 CPU;模式切换:仅 2 次。
- 限制:
in_fd必须是支持 mmap 的文件(不能是 socket);数据全程在内核走,用户态无法修改内容。
| 方式 | CPU 拷贝 | DMA 拷贝 | 模式切换 | 典型系统调用 |
|---|---|---|---|---|
read + write | 2 | 2 | 4 | read + write |
mmap + write | 1 | 2 | ≥2(另有缺页) | mmap + write |
sendfile | 1 | 2 | 2 | sendfile |
sendfile + SG-DMA | 0 | 2 | 2 | sendfile |
选型原则:要修改数据内容 → 用 mmap;只做原样转发 → 用 sendfile。
11. 为什么 sendfile + SG-DMA 被称为"真正的零拷贝"?splice 又解决什么?
答: Linux 2.4 为网卡引入 SG-DMA(scatter/gather DMA) 后,DMA 可以直接从内核读缓冲区把数据搬到网卡,不必再经 Socket 缓冲区:
- 调用
sendfile进入内核态; - DMA:磁盘 → 内核读缓冲区;
- CPU 不再拷贝数据本身,只把数据在内核缓冲区里的描述信息(地址 + 长度)写进 Socket 缓冲区;
- SG-DMA 按描述信息直接把数据搬到网卡。
结果:2 次模式切换、2 次拷贝,且两次都是 DMA——CPU 搬运 payload 的次数为 0,这才是名副其实的零拷贝。
splice 解决的是 sendfile 的通用性限制(in_fd 不能是 socket、out_fd 早期只能是 socket)。它要求至少一端是管道,借管道传递的是页指针 + 引用计数而非数据本身,因此也能在两个 socket 之间零拷贝转发。代价是文件→socket 需要两次 splice 调用,约 4 次模式切换,比 sendfile 多。
前提条件:网卡支持 scatter-gather、内核版本足够、协议栈中途不需要碰数据。一旦开启 TLS 加密、压缩或格式转换,内核必须读出 payload,零拷贝路径就退化了。
12. Java 中如何实现零拷贝?transferTo 有什么坑?
答: Java 层的对应关系:
| 内核机制 | Java API |
|---|---|
mmap | MappedByteBuffer(FileChannel.map) |
sendfile / splice | FileChannel.transferTo / transferFrom |
// mmap 路线
FileChannel channel = FileChannel.open(Paths.get("./data.bin"),
StandardOpenOption.READ, StandardOpenOption.WRITE);
MappedByteBuffer buffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, channel.size());
// sendfile 路线
try (FileChannel src = FileChannel.open(Paths.get("./in.dat"));
SocketChannel socket = ...) {
src.transferTo(0, src.size(), socket);
}三个必须知道的坑:
transferTo不保证一次传完,必须根据返回值循环调用直到传完;- 它不等于 sendfile:只有目标是已连接的
SocketChannel且平台支持时,JDK 才可能走内核零拷贝;目标是普通文件或其他 Channel 时可能退回用户态复制。Windows 与 Linux 行为也不一致; - 底层若走 Linux
sendfile,单次传输上限约 2GB(0x7ffff000),超过要分段。
13. Netty 的"零拷贝"指的是什么?
答: Netty 的零拷贝是应用层(JVM 内)的零拷贝,目的是减少堆内内存的数据复制,与操作系统的零拷贝不是同一概念,面试时务必区分:
| 机制 | 说明 |
|---|---|
CompositeByteBuf | 把多个 ByteBuf 逻辑组合成一个,避免合并时的拷贝 |
slice() / duplicate() | 共享同一块底层内存,只创建新视图,不复制数据 |
wrap() / unwrap() | 直接包装 byte[] 或 ByteBuffer,不经过中间拷贝 |
ByteBuf 读写指针 | readerIndex/writerIndex 分离,读不改变写位置,避免了 flip() 式来回搬数据 |
FileRegion | 底层走 FileChannel.transferTo,这一项才真正对应 OS 零拷贝 |
| 堆外内存池 | PooledByteBufAllocator 在堆外分配并复用,避免堆内→堆外的拷贝 |
一句话:Netty 的零拷贝大部分在应用层(少拷一次内存),只有 FileRegion 涉及内核层零拷贝。
14. Kafka 与 RocketMQ 分别走哪条零拷贝路线?
答:
| 组件 | 机制 | 原因 |
|---|---|---|
| Kafka(消息发送) | FileChannel.transferTo,即 sendfile 路线 | 日志段文件只转发、不修改,数据直接从 Page Cache 进 Socket,不进 JVM 堆 |
| Kafka(索引文件) | mmap | 索引需要随机读取,映射更合适 |
| RocketMQ(CommitLog) | MappedByteBuffer,即 mmap 路线 | 需要对映射内存做灵活读写,而非原样转发 |
一句话区分:单纯转发用 sendfile,需要操作映射内存用 mmap。 另外 Kafka 的高吞吐不只靠零拷贝,还叠加了顺序写 + Page Cache;一旦开启 TLS 或做消息格式转换,零拷贝路径同样会退化。
三、Selector 与多路复用
15. Selector 是什么?它是如何实现多路复用的?
答: Selector 是 NIO 的多路复用器,让一个线程通过轮询同时监听多个 Channel 的就绪事件。
使用四步:
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(8000));
server.configureBlocking(false); // 1. 必须非阻塞
server.register(selector, SelectionKey.OP_ACCEPT); // 2. 注册关心的事件
while (true) {
selector.select(); // 3. 阻塞直到有事件就绪
Set<SelectionKey> keys = selector.selectedKeys();
for (SelectionKey key : keys.iterator()) { // 4. 遍历就绪事件处理
if (key.isAcceptable()) { /* 接受连接 */ }
if (key.isReadable()) { /* 读数据 */ }
}
keys.clear(); // 必须手动清理,否则重复处理
}底层原理:把 Channel 注册到 Selector 后,由操作系统内核监听 IO 事件,事件发生时内核通知 Selector,唤醒线程处理。这样线程无需阻塞在单个 IO 上。
常见的两个坑:①
selectedKeys()不会自动清空,遍历完必须手动clear();② 处理OP_ACCEPT后要把新的SocketChannel注册到同一个 Selector,并设为非阻塞。
16. select、poll、epoll 的区别?epoll 为什么高效?
答: 三者是 Linux 提供的 IO 多路复用系统调用:
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 红黑树 + 就绪链表 |
| fd 数量限制 | 有,通常 1024(FD_SETSIZE) | 无硬限制 | 无硬限制(受系统 fd 上限) |
| fd 集合拷贝 | 每次调用全量拷贝入内核 | 每次调用全量拷贝 | epoll_ctl 注册时只拷贝一次 |
| 就绪检测 | O(n) 轮询遍历所有 fd | O(n) 轮询 | 内核回调把就绪 fd 挂入就绪链表,epoll_wait 直接取,O(1) |
| 返回后处理 | 仍需遍历找就绪 fd | 仍需遍历 | 直接拿到就绪列表 |
epoll 高效的根本原因有两点:
- 空间换时间:用红黑树在内核维护所有被监听的 fd,避免每次调用都重新拷贝整个集合;
- 事件驱动:fd 就绪时由内核主动回调加入就绪链表,
epoll_wait无需轮询全部 fd,复杂度与实际就绪数量相关而非总数相关。
JDK 在 Linux 上从 JDK 1.4 起即提供 epoll 实现(早期默认
poll,JDK 6 起默认epoll),可通过-Djava.nio.channels.spi.SelectorProvider指定。Windows 上 Selector 基于select()(Winsock select)实现,IOCP 才是 Windows 的 AIO 底层机制,两者不要混淆。
17. epoll 的 LT(水平触发)与 ET(边缘触发)有什么区别?
答:
- LT(Level Triggered,水平触发):默认模式。只要 fd 上还有数据可读/可写,每次
epoll_wait都会重复通知。编程简单,允许"读一半就走",下次再读。 - ET(Edge Triggered,边缘触发):只在状态变化时通知一次。必须一次性把数据读干净(循环
read直到返回EAGAIN),否则剩余数据不会再有事件通知,事件就永久丢失。
| 维度 | LT | ET |
|---|---|---|
| 通知次数 | 数据未读完就反复通知 | 每次状态变化只通知一次 |
| 编程难度 | 低,不易漏事件 | 高,必须循环读到 EAGAIN |
| 效率 | 系统调用次数多一些 | 系统调用更少,高并发下更高效 |
| 典型使用 | Java NIO Selector 默认(Linux) | Netty 的 EpollEventLoopGroup 可选 |
补充:ET 模式下必须配合非阻塞 fd,否则循环读最后会永久阻塞在
read上。
18. Selector 支持哪些事件类型?
答: 对应 SelectionKey 的四个常量(位运算组合),基于兴趣集(interestOps) 注册:
| 常量 | 值 | 含义 | 适用 Channel |
|---|---|---|---|
OP_READ | 1 | 可读(有数据到达) | SocketChannel、DatagramChannel |
OP_WRITE | 4 | 可写(发送缓冲区有空间) | SocketChannel |
OP_CONNECT | 8 | 连接建立完成 | SocketChannel(客户端) |
OP_ACCEPT | 16 | 有新连接到达 | ServerSocketChannel |
实践要点:不要长期注册
OP_WRITE。Socket 缓冲区通常可写,会导致select()几乎每次都立即返回、CPU 空转,应改为"写不进去时才临时注册,写完立即注销"(Netty 正是这么做的)。
19. 什么是 Reactor 模式?有哪几种变体?
答: Reactor 模式是事件驱动 + 多路复用的服务端设计范式,核心是"用少量线程处理大量连接":Reactor 负责监听事件并分发给对应的 Handler 处理。
| 变体 | 结构 | 特点 |
|---|---|---|
| 单 Reactor 单线程 | 一个线程既监听又处理业务 | 实现简单,无并发问题;但业务阻塞会拖垮整个服务(Redis 单线程模型近似此类) |
| 单 Reactor 多线程 | 一个 Reactor 线程监听,业务交给线程池 | 利用多核;但单 Reactor 仍是瓶颈,高并发下事件处理可能积压 |
| 主从 Reactor 多线程 | mainReactor 只负责 accept,subReactor 负责读写,业务再交线程池 | 分工清晰、扩展性最好,Netty 的线程模型即此类 |
20. NIO 有哪些典型坑?(空轮询 bug、半包粘包)
答:
1)epoll 空轮询 bug:JDK 在 Linux epoll 上曾有缺陷——在特定情况下 select() 返回就绪数量为 0,但 selectedKeys() 不为空,导致 while (true) 空转,CPU 飙到 100%。Netty 的解决方式是统计空轮询次数,超过阈值(默认 512)就重建 Selector:
// Netty NioEventLoop 的简化逻辑
if (selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
rebuildSelector(); // 重建 Selector,把原 Channel 重新注册
selectCnt = 0;
}2)半包与粘包:TCP 是面向字节流的协议,没有消息边界。发送方连续两次 write 的数据可能被接收方一次读到(粘包),一个完整消息也可能被拆成多次读到(半包)。解决思路只有三条:
- 固定长度:每条消息长度一致,简单但不灵活(
FixedLengthFrameDecoder); - 分隔符:用
\n、\r\n等特殊字符标记边界(LineBasedFrameDecoder); - 长度字段:消息头里写明 body 长度,最通用(
LengthFieldBasedFrameDecoder,Netty 默认方案)。
3)其他常见坑:ByteBuffer 忘记 flip();selectedKeys() 忘记 clear();OP_WRITE 长期注册导致空转;DirectByteBuffer 泄漏(需 -XX:MaxDirectMemorySize 与池化)。
