NIO 的三大组件与零拷贝是怎么回事?

#Java#面试八股

NIO 的三大组件与零拷贝是怎么回事?

结论:NIO 的三大组件是通道(Channel)、缓冲区(Buffer)、多路复用器(Selector):数据总是先读进 Buffer、再从中取;Channel 是双向的数据通道;Selector 用一个线程监听多个 Channel 的就绪事件。零拷贝(zero-copy)不是"完全不拷贝",而是减少用户态与内核态之间的数据复制和上下文切换:FileChannel.transferTo 走 sendfile,数据根本不经过用户缓冲区;MappedByteBuffer 走 mmap,用户态直接映射页缓存。

1. Buffer 的三个指针

Buffer 状态 = [ capacity ][ position ][ limit ][ mark ]

写模式:position 从 0 增长到 limit(= capacity)
flip():limit = position;position = 0        ← 写 → 读
读模式:position 从 0 增长到 limit
clear():position = 0;limit = capacity       ← 重置标记,数据并未擦除
compact():把未读数据搬到头部,position = 剩余长度   ← 读一半要转回写

这三个方法最容易混:clear() 不是清空数据,只是重置指针;flip() 之后才能读;读了一半又想写,用 compact() 而不是 clear()。

2. 一次读取经过几次拷贝

传统 read + write(4 次拷贝、4 次上下文切换):
  磁盘 ──DMA──→ 内核页缓存 ──CPU──→ 用户缓冲区 ──CPU──→ socket 缓冲区 ──DMA──→ 网卡

sendfile / transferTo(2 次拷贝、2 次切换,数据不进用户态):
  磁盘 ──DMA──→ 内核页缓存 ──────────────→ socket 缓冲区 ──DMA──→ 网卡

mmap + write(3 次拷贝,用户态与内核共享页缓存):
  磁盘 ──DMA──→ 页缓存 ←──映射── 用户态 ──CPU──→ socket 缓冲区 ──DMA──→ 网卡
方式系统调用数据拷贝次数上下文切换典型用途
传统read + write44需要在用户态处理数据
sendfiletransferTo2(DMA)2文件下发、静态资源、Kafka
mmapmap + write32~4大文件随机读、RocketMQ
splicesplice22管道转发

3. 代码:transferTo 与 mmap

import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;

public class ZeroCopyDemo {
    /** sendfile:文件到文件/套接字,数据不进用户态 */
    static void sendFile(Path from, Path to) throws Exception {
        try (FileChannel in = FileChannel.open(from, StandardOpenOption.READ);
             FileChannel out = FileChannel.open(to,
                     StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
            long size = in.size();
            long pos = 0;
            while (pos < size) {
                // 单次调用有上限(通常 2GB 内),必须循环
                pos += in.transferTo(pos, size - pos, out);
            }
        }
    }

    /** mmap:把文件映射进虚拟内存,读大文件避免整段加载 */
    static byte readAt(Path path, long offset) throws Exception {
        try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r");
             FileChannel ch = raf.getChannel()) {
            MappedByteBuffer map = ch.map(FileChannel.MapMode.READ_ONLY, offset, 1);
            return map.get(0);
        }
    }
}

transferTo 的返回值是实际传输的字节数,可能小于请求值(内核缓冲上限、信号中断),所以必须 while 循环推进 position,否则会静默丢数据。

4. 零拷贝的边界

限制说明
不能修改数据数据绕过用户态,应用无法加解密或改包;要改就得回到普通读写
依赖操作系统sendfile 在 Linux 上成熟,JDK 会回退到普通复制
单次 2GB 上限transferTo 内部按 Integer.MAX_VALUE 分段,需要循环
mmap 释放不即时映射区由 Cleaner/Unmapper 释放,长期持有会占用虚拟内存与页缓存
TLS 场景退化传统 sendfile 无法在加密前处理数据;kTLS 等新机制才支持

5. 常见追问

追问回答要点
Heap Buffer 与 Direct Buffer 区别?堆内数组 vs 堆外内存;Direct 少一次拷贝,但分配/释放贵,受 -XX:MaxDirectMemorySize 限制
flip() 到底改了什么?limit = position,position = 0,切换读写视角
为什么 Selector 要 configureBlocking(false)?阻塞通道无法注册到 Selector(会抛 IllegalBlockingModeException)
selectedKeys 为什么必须 remove()?集合不会自动清空,不删会导致同一事件被重复处理
零拷贝对 Kafka/RocketMQ 的意义?Kafka 用 sendfile 下发日志段,RocketMQ 用 mmap 读写 CommitLog,减少 CPU 拷贝与 GC 压力
mmap 会导致 Full GC 吗?映射区不在堆内,但大量映射会占用页缓存与虚拟地址空间,可能触发系统级内存压力

小结

三大组件回答"NIO 怎么组织数据与事件",零拷贝回答"NIO 怎么少搬数据"。记住拷贝次数对照表(4 → 2 → 3)和两条铁律:transferTo 要循环、flip() 之后才能读。

笔记加载中…