Skip to content

NIO核心组件与零拷贝

NIO概述

在传统的BIO模型中,IO操作以阻塞方式进行。当线程执行read或write操作时,会被阻塞直到操作完成。这种模型在处理大量并发连接时会遇到严重的性能瓶颈,因为需要为每个连接创建独立线程,而线程的创建和上下文切换都有很大开销。

为了解决这个问题,Java 1.4引入了NIO(New IO,也称Non-blocking IO)。NIO提供了非阻塞、面向缓冲、基于通道的I/O操作方式,可以使用少量线程处理大量连接,大幅提升IO效率和并发能力。

mermaid
graph TB
    A[BIO模型] --> B[1000个连接]
    B --> C[需要1000个线程]
    C --> D[大量内存占用<br/>频繁上下文切换]
    
    E[NIO模型] --> F[1000个连接]
    F --> G[仅需1个线程<br/>+Selector]
    G --> H[低内存占用<br/>高效事件驱动]
    
    style D fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style H fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10

注意: NIO并不是在所有场景下都优于BIO。它的性能优势主要体现在高并发、高延迟的网络环境。当连接数较少、并发程度低或网络传输速度快时,NIO的性能不一定优于BIO。

NIO三大核心组件

NIO主要包括三个核心组件:

  1. Buffer(缓冲区): 数据的容器,所有数据读写都通过Buffer进行
  2. Channel(通道): 双向数据传输通道,连接Buffer和IO设备
  3. Selector(选择器): 多路复用器,实现单线程管理多个Channel

三者的协作关系:

mermaid
graph LR
    A[应用程序] --> B[Buffer<br/>缓冲区]
    B <--> C[Channel<br/>通道]
    C <--> D[IO设备<br/>文件/网络]
    
    C -.注册.-> E[Selector<br/>选择器]
    E -.监听事件.-> C
    
    style B fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style C fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10
    style E fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10

Buffer缓冲区

核心概念

在BIO中,数据读写是 面向流 的,分为字节流和字符流,数据直接在流中传输。

NIO中,所有数据都通过 Buffer(缓冲区) 进行处理。读取数据时,数据从Channel读到Buffer;写入数据时,数据从Buffer写到Channel。

这种设计类似于BIO中的缓冲流(BufferedInputStream/BufferedOutputStream),通过批量读写减少系统调用次数,提升性能。

Buffer类型

Buffer是一个抽象类,常用的子类包括:

mermaid
graph TB
    A[Buffer] --> B[ByteBuffer<br/>字节缓冲区]
    A --> C[CharBuffer<br/>字符缓冲区]
    A --> D[IntBuffer<br/>整型缓冲区]
    A --> E[LongBuffer<br/>长整型缓冲区]
    A --> F[FloatBuffer<br/>浮点缓冲区]
    A --> G[DoubleBuffer<br/>双精度缓冲区]
    A --> H[ShortBuffer<br/>短整型缓冲区]
    
    style A fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style B fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10

其中,ByteBuffer是最常用的Buffer类型,可以用于存储和操作字节数据。

可以将Buffer理解为一个数组:IntBuffer对应int[],CharBuffer对应char[],ByteBuffer对应byte[]

Buffer核心属性

Buffer类定义了四个核心属性:

java
public abstract class Buffer {
    // 四个核心属性,满足: 0 <= mark <= position <= limit <= capacity
    private int mark = -1;      // 标记位置
    private int position = 0;   // 当前读写位置
    private int limit;          // 读写边界
    private int capacity;       // 容量
}

这四个属性的含义:

  1. capacity(容量): Buffer能存储的最大数据量,创建时设置且不可改变
  2. limit(界限): Buffer中可读/写数据的边界
    • 写模式: limit = capacity,表示最多能写入的位置
    • 读模式: limit = 实际数据大小,表示最多能读取的位置
  3. position(位置): 下一个将要读/写的数据位置(索引)
    • 写模式: 指向下一个要写入的位置
    • 读模式: 指向下一个要读取的位置
  4. mark(标记): 记录特定position,可通过reset()方法回到该位置
mermaid
graph LR
    A[0] --> B[mark]
    B --> C[position<br/>当前位置]
    C --> D[limit<br/>读写边界]
    D --> E[capacity<br/>总容量]
    
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style D fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10
    style E fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10

Buffer读写模式

Buffer有两种工作模式:写模式读模式

  • 写模式: Buffer创建后默认处于写模式,可以向Buffer写入数据
  • 读模式: 调用flip()切换到读模式,可以从Buffer读取数据
java
ByteBuffer buffer = ByteBuffer.allocate(10);  // 创建容量为10的Buffer,默认写模式
buffer.put((byte) 'A');                       // 写入数据
buffer.flip();                                // 切换到读模式
byte data = buffer.get();                     // 读取数据

切换回写模式的方法:

  • clear(): 清空缓冲区,position=0, limit=capacity
  • compact(): 压缩缓冲区,将未读数据移到开头,继续写入
mermaid
graph TB
    A[创建Buffer<br/>写模式] -->|put写入数据| B[写入完成]
    B -->|flip| C[读模式]
    C -->|get读取数据| D[读取完成]
    D -->|clear/compact| A
    
    style A fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10

Buffer核心方法

创建Buffer:

java
// 分配堆内存
public static ByteBuffer allocate(int capacity);
// 分配直接内存(堆外内存)
public static ByteBuffer allocateDirect(int capacity);

核心读写方法:

  • put(): 向Buffer写入数据
  • get(): 从Buffer读取数据

其他重要方法:

  • flip(): 写模式切换到读模式
  • clear(): 清空缓冲区,切换到写模式
  • rewind(): 重置position为0,可以重新读取数据
  • mark(): 标记当前position
  • reset(): 回到mark标记的位置

Buffer实战演示

java
public class BufferDemo {
    public static void main(String[] args) {
        // 1. 创建容量为10的CharBuffer
        CharBuffer buffer = CharBuffer.allocate(10);
        System.out.println("【初始状态】");
        printBufferState(buffer);
        
        // 2. 写入5个字符
        buffer.put('H').put('e').put('l').put('l').put('o');
        System.out.println("\n【写入5个字符后】");
        printBufferState(buffer);
        
        // 3. 切换到读模式
        buffer.flip();
        System.out.println("\n【调用flip()切换到读模式】");
        printBufferState(buffer);
        
        // 4. 读取数据
        System.out.println("\n【读取数据】");
        while (buffer.hasRemaining()) {
            System.out.print(buffer.get());
        }
        System.out.println();
        printBufferState(buffer);
        
        // 5. 清空缓冲区
        buffer.clear();
        System.out.println("\n【调用clear()清空缓冲区】");
        printBufferState(buffer);
    }
    
    private static void printBufferState(CharBuffer buffer) {
        System.out.println("position=" + buffer.position() + 
                          ", limit=" + buffer.limit() + 
                          ", capacity=" + buffer.capacity());
    }
}

输出:

plain
【初始状态】
position=0, limit=10, capacity=10

【写入5个字符后】
position=5, limit=10, capacity=10

【调用flip()切换到读模式】
position=0, limit=5, capacity=10

【读取数据】
Hello
position=5, limit=5, capacity=10

【调用clear()清空缓冲区】
position=0, limit=10, capacity=10

position、limit和capacity的变化过程:

mermaid
graph TB
    A[初始状态<br/>p=0 l=10 c=10] -->|put 5个字符| B[写入后<br/>p=5 l=10 c=10]
    B -->|flip| C[读模式<br/>p=0 l=5 c=10]
    C -->|get 5个字符| D[读取后<br/>p=5 l=5 c=10]
    D -->|clear| E[清空后<br/>p=0 l=10 c=10]
    
    style A fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style E fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10

Channel通道

核心概念

Channel(通道)是NIO中数据传输的通道,建立了与数据源(文件、网络套接字等)之间的连接。

BIO中的流是单向的:

  • InputStream: 只能读取数据
  • OutputStream: 只能写入数据

Channel是双向的,可以同时用于读和写操作,更好地映射底层操作系统的API(UNIX系统中的通道本身就是全双工的)。

mermaid
graph LR
    A[BIO单向流] --> B[InputStream<br/>只读]
    A --> C[OutputStream<br/>只写]
    
    D[NIO双向通道] --> E[Channel<br/>可读可写]
    
    E <--> F[数据源]
    
    style B fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style E fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10

Channel与Buffer的关系:

  • 读操作: 将Channel中的数据读取到Buffer
  • 写操作: 将Buffer中的数据写入到Channel

Channel类型

常用的Channel类型:

mermaid
graph TB
    A[Channel] --> B[FileChannel<br/>文件访问通道]
    A --> C[SocketChannel<br/>TCP客户端通道]
    A --> D[ServerSocketChannel<br/>TCP服务端通道]
    A --> E[DatagramChannel<br/>UDP通信通道]
    
    style A fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style B fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style D fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style E fill:#9B59B6,stroke:#6C3483,stroke-width:2px,rx:10,ry:10

Channel核心方法

最核心的两个方法:

  1. read(): 从Channel读取数据到Buffer
  2. write(): 将Buffer中的数据写入Channel

FileChannel示例

java
public class FileChannelDemo {
    public void copyFile(String sourcePath, String targetPath) throws IOException {
        try (RandomAccessFile source = new RandomAccessFile(sourcePath, "r");
             RandomAccessFile target = new RandomAccessFile(targetPath, "rw");
             FileChannel sourceChannel = source.getChannel();
             FileChannel targetChannel = target.getChannel()) {
            
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            
            while (sourceChannel.read(buffer) != -1) {
                buffer.flip();              // 切换到读模式
                targetChannel.write(buffer); // 写入目标文件
                buffer.clear();             // 清空缓冲区,继续读取
            }
            
            System.out.println("文件复制完成");
        }
    }
}

Selector选择器

核心概念

Selector(选择器)是NIO最核心的组件,它允许单个线程处理多个Channel

Selector基于事件驱动的IO多路复用模型,工作原理:

  1. 将Channel注册到Selector,并指定感兴趣的事件(如连接、读、写)
  2. Selector不断轮询注册的Channel
  3. 当某个Channel有事件就绪时,Selector将其加入就绪集合
  4. 应用程序通过SelectionKey获取就绪的Channel,进行相应的IO操作
mermaid
graph TB
    A[Channel1] -.注册.-> E[Selector<br/>选择器]
    B[Channel2] -.注册.-> E
    C[Channel3] -.注册.-> E
    D[ChannelN] -.注册.-> E
    
    E -->|轮询| F{事件就绪?}
    F -->|是| G[就绪集合]
    F -->|否| E
    
    G --> H[单个线程<br/>处理所有就绪事件]
    
    style E fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style H fill:#50C878,stroke:#2E7D4E,stroke-width:2px,rx:10,ry:10

可监听的事件类型

Selector可以监听四种事件:

  1. SelectionKey.OP_ACCEPT: 接受连接事件(ServerSocketChannel)
  2. SelectionKey.OP_CONNECT: 连接就绪事件(SocketChannel)
  3. SelectionKey.OP_READ: 读就绪事件(有数据可读)
  4. SelectionKey.OP_WRITE: 写就绪事件(可以写入数据)

Selector核心方法

创建Selector:

java
Selector selector = Selector.open();

注册Channel到Selector:

java
channel.configureBlocking(false);  // 必须设置为非阻塞模式
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

Selector提供的select方法:

  • int select(): 阻塞直到有事件就绪
  • int select(long timeout): 阻塞最多timeout毫秒
  • int selectNow(): 非阻塞立即返回
  • Selector wakeup(): 唤醒阻塞在select()上的线程

SelectionKey集合

Selector维护三个SelectionKey集合:

  1. 所有注册的Key集合: 通过keys()方法获取
  2. 就绪的Key集合: 通过selectedKeys()方法获取,需要处理的Channel
  3. 被取消的Key集合: 已取消注册的Channel,程序无法直接访问

网络编程示例

java
public class NIOChatServer {
    private Selector selector;
    private ServerSocketChannel serverChannel;
    
    public void start(int port) throws IOException {
        // 创建Selector
        selector = Selector.open();
        
        // 创建ServerSocketChannel
        serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(port));
        serverChannel.configureBlocking(false);
        
        // 注册到Selector,监听连接事件
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("聊天服务器启动在端口: " + port);
        
        // 事件循环
        while (true) {
            int readyChannels = selector.select();
            if (readyChannels == 0) continue;
            
            // 获取就绪的SelectionKey集合
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
            
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                keyIterator.remove();  // 必须手动移除
                
                if (key.isAcceptable()) {
                    handleAccept(key);
                } else if (key.isReadable()) {
                    handleRead(key);
                } else if (key.isWritable()) {
                    handleWrite(key);
                }
            }
        }
    }
    
    private void handleAccept(SelectionKey key) throws IOException {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel client = server.accept();
        client.configureBlocking(false);
        
        // 注册客户端Channel,监听读事件
        client.register(selector, SelectionKey.OP_READ);
        System.out.println("新用户加入: " + client.getRemoteAddress());
    }
    
    private void handleRead(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        
        int bytesRead = client.read(buffer);
        if (bytesRead > 0) {
            buffer.flip();
            String message = new String(buffer.array(), 0, bytesRead);
            System.out.println("收到消息: " + message);
            
            // 切换到写事件,准备回复
            key.attach(message);  // 将消息附加到Key
            client.register(selector, SelectionKey.OP_WRITE);
        } else if (bytesRead < 0) {
            // 客户端断开连接
            System.out.println("用户离开: " + client.getRemoteAddress());
            client.close();
        }
    }
    
    private void handleWrite(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        String message = (String) key.attachment();
        
        ByteBuffer buffer = ByteBuffer.wrap(("服务器回复: " + message).getBytes());
        client.write(buffer);
        
        // 切换回读事件
        client.register(selector, SelectionKey.OP_READ);
    }
}

NIO零拷贝

零拷贝是提升IO性能的重要手段,在Kafka、RocketMQ、Netty等顶级开源项目中都有应用。

什么是零拷贝

零拷贝是指CPU不需要将数据从一个存储区域复制到另一个存储区域,从而减少上下文切换和CPU拷贝时间。

传统IO的四次拷贝:

mermaid
graph TB
    A[磁盘] -->|DMA拷贝| B[内核缓冲区]
    B -->|CPU拷贝| C[用户缓冲区]
    C -->|CPU拷贝| D[Socket缓冲区]
    D -->|DMA拷贝| E[网卡]
    
    style B fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10
    style C fill:#E94B3C,stroke:#B8341F,stroke-width:2px,rx:10,ry:10
    style D fill:#4A90E2,stroke:#2E5C8A,stroke-width:2px,rx:10,ry:10

零拷贝技术对比

技术CPU拷贝DMA拷贝系统调用上下文切换
传统方法22read+write4
mmap+write12mmap+write4
sendfile12sendfile2
sendfile+DMA gather02sendfile2

无论哪种方式,2次DMA拷贝都少不了(由硬件完成)。零拷贝主要减少CPU拷贝和上下文切换

Java对零拷贝的支持

1. MappedByteBuffer (mmap)

MappedByteBuffer基于内存映射(mmap),将文件映射到内存,可以像操作内存一样操作文件。

java
public class MMapDemo {
    public void loadFileToMemory(String filePath) throws IOException {
        try (FileInputStream fis = new FileInputStream(filePath);
             FileChannel fc = fis.getChannel()) {
            
            // 将文件映射到内存
            MappedByteBuffer buffer = fc.map(
                FileChannel.MapMode.READ_ONLY, 
                0, 
                fc.size()
            );
            
            // 直接操作内存中的文件数据
            byte[] data = new byte[(int) fc.size()];
            buffer.get(data);
            
            System.out.println("文件大小: " + data.length + " 字节");
        }
    }
}

2. FileChannel.transferTo/transferFrom (sendfile)

FileChanneltransferTo()transferFrom()基于sendfile系统调用,可以直接在两个Channel之间传输数据。

java
public class ZeroCopyDemo {
    public void copyFileBySendfile(String source, String target) throws IOException {
        try (FileInputStream fis = new FileInputStream(source);
             FileOutputStream fos = new FileOutputStream(target);
             FileChannel sourceChannel = fis.getChannel();
             FileChannel targetChannel = fos.getChannel()) {
            
            // 零拷贝传输
            long transferred = sourceChannel.transferTo(
                0, 
                sourceChannel.size(), 
                targetChannel
            );
            
            System.out.println("传输字节数: " + transferred);
        }
    }
}

总结

本文详细介绍了NIO的核心知识:

  1. Buffer: 数据容器,通过position、limit、capacity管理读写状态
  2. Channel: 双向通道,连接Buffer和IO设备
  3. Selector: 多路复用器,实现单线程管理多个Channel
  4. 零拷贝: 通过mmap和sendfile减少数据拷贝,提升性能

虽然NIO编程相对复杂,但在高并发场景下性能显著优于BIO。实际开发中,建议使用Netty等成熟的NIO框架,它们在NIO基础上进行了优化和扩展,提供了更友好的API和更强大的功能。

更新: 2025-12-04 17:35:11
原文: https://www.yuque.com/u22210564/zoxfmt/doc-04-io-05

Java 后端面试知识库