NIO 核心组件(Channel/Buffer/Selector)

很多同学学 NIO 的时候,会被一堆新概念砸懵:Channel 是什么?Buffer 怎么 flip?Selector 为什么能一个线程管多个连接?

我当初学的时候也是。先记住 BIO 的"流"概念,然后发现 NIO 搞了个"通道",还得自己管理缓冲区,心想这玩意也太复杂了。

但当你真正理解了这三个组件的设计初衷,就会发现 NIO 比 BIO 高明得多:BIO 是一个线程服务一个连接,NIO 是一个线程管理所有连接

今天这篇,我们把 Channel、Buffer、Selector 三个核心组件彻底讲透。

一、Channel:双向通道

1.1 Channel 是什么

Channel 是 NIO 的核心抽象,可以理解为"连接"或"通道"。和 BIO 的 Stream 相比,最大的区别是:Channel 是双向的,可以同时读和写

BIO 的 Stream 分输入流和输出流:

// BIO:输入流和输出流是分开的
InputStream in = socket.getInputStream();  // 只能读
OutputStream out = socket.getOutputStream(); // 只能写

NIO 的 Channel 是双向的:

// NIO:Channel 既是输入又是输出
SocketChannel channel = SocketChannel.open();
channel.read(buffer);   // 从 Channel 读数据到 Buffer
channel.write(buffer);  // 从 Buffer 写数据到 Channel

1.2 常见的 Channel 类型

Channel 类型说明
FileChannel文件 IO,只能阻塞模式
SocketChannelTCP 客户端连接,可配置为非阻塞
ServerSocketChannelTCP 服务器监听,可配置为非阻塞
DatagramChannelUDP 数据报
Pipe.SinkChannel / Pipe.SourceChannel管道,用于线程间通信

1.3 基本用法

// 客户端:连接服务器
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8080));

// 设置为非阻塞模式
socketChannel.configureBlocking(false);

// 读数据
ByteBuffer readBuffer = ByteBuffer.allocate(128);
int bytesRead = socketChannel.read(readBuffer);

// 写数据
ByteBuffer writeBuffer = ByteBuffer.wrap("Hello".getBytes());
socketChannel.write(writeBuffer);

// 关闭
socketChannel.close();
// 服务器:监听连接
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 非阻塞

while (true) {
    SocketChannel clientChannel = serverChannel.accept(); // 非阻塞,没有连接返回 null
    if (clientChannel != null) {
        // 处理新连接
        handleClient(clientChannel);
    }
}

1.4 FileChannel 的特殊能力

FileChannel 有一个其他 Channel 没有的能力:内存映射文件(Memory-Mapped File)

RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
FileChannel fileChannel = file.getChannel();

// 把文件映射到内存,读写像操作数组一样快
MappedByteBuffer buffer = fileChannel.map(
    FileChannel.MapMode.READ_WRITE,  // 模式
    0,                                  // 起始位置
    fileChannel.size()                  // 映射大小
);

// 直接像数组一样读写
buffer.put(0, (byte) 'H');
byte b = buffer.get(0);

FileChannel.map() 返回的是 MappedByteBuffer,它把文件直接映射到操作系统的内存中,省去了用户态和内核态之间的数据拷贝。

二、Buffer:数据的容器

2.1 Buffer 的本质

Buffer 是一个容器,本质是一个数组。NIO 把所有 IO 数据都先放到 Buffer 里,再从 Buffer 读写数据。

这和 BIO 区别很大:BIO 是直接从 Stream 读/写,NIO 则是通过 Buffer 中转。

BIO:  Channel → Stream(直接读写)
NIO:  Channel → Buffer → 应用(Buffer 中转)

2.2 核心概念:position / limit / capacity

Buffer 有三个核心指针,理解它们是掌握 NIO 的关键:

指针含义
capacity容量,Buffer 能容纳的最大数据量,创建后不变
position当前位置,下一个读写操作的起始位置
limit限制,表示可读写的数据边界

初始状态:

capacity: 10
[0][1][2][3][4][5][6][7][8][9]
  ^
 position = 0
 limit = capacity = 10

写入了 5 个字节后(buffer.put()):

capacity: 10
[0][1][2][3][4][5][6][7][8][9]
  ^                       ^
 position = 5            limit = 10

2.3 flip() 的作用

flip() 是最容易让人困惑的操作。为什么要 flip?因为要从"写模式"切换到"读模式":

ByteBuffer buffer = ByteBuffer.allocate(10);

// 写 5 个字节
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.put((byte) 3);
buffer.put((byte) 4);
buffer.put((byte) 5);

System.out.println("写完后 position=" + buffer.position()); // 5

// flip!切换到读模式
buffer.flip();

System.out.println("flip后 position=" + buffer.position()); // 0
System.out.println("flip后 limit=" + buffer.limit());        // 5

// 现在可以读了
while (buffer.hasRemaining()) {
    System.out.println(buffer.get()); // 1, 2, 3, 4, 5
}

flip() 的源码:

public final Buffer flip() {
    limit = position;    // 把 limit 设置为当前 position
    position = 0;       // position 重置为 0
    mark = -1;           // 清除 mark
    return this;
}

flip 把 limit 设置为 position(即有效数据的末尾),然后把 position 重置为 0。这样读操作就会从头开始,只读到有效数据。

2.4 clear() 和 compact()

读完或写完数据后,需要重置 Buffer 来复用:

// clear():清空缓冲区,但数据还在,只是重置了指针
buffer.clear();  // position=0, limit=capacity

// compact():只清除已读部分,保留未读部分
buffer.compact(); // position=未读数据数量, limit=capacity
💡

什么时候用 clear?什么时候用 compact?

  • clear:Buffer 内容全部不要了,直接丢弃
  • compact:还有未读数据需要保留,继续往 Buffer 写新数据时用 compact :::

2.5 【直观类比】Buffer 的读写切换

想象你有一个录音机(Buffer):

  1. 录音状态(写模式):磁带从开头开始录,每录一段 position 就往后移动
  2. 录完了,按下播放键(flip):磁带倒回去,limit 设定在刚才录制结束的位置
  3. 播放状态(读模式):从头播放,position 移动,只播放到 limit 为止
  4. 播放完,磁带洗干净了(clear):可以重新录音

2.6 ByteBuffer 的类型

类型说明
HeapByteBuffer在 JVM 堆上分配,GC 会管理
DirectByteBuffer直接分配在操作系统的内存中,减少一次拷贝,但创建/销毁较慢
// 堆内 Buffer(普通)
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

// 直接 Buffer(零拷贝友好)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

:::warning ⚠️ DirectByteBuffer 的创建和销毁比堆内 Buffer 慢很多,适合长期存在、频繁使用的场景(比如 Netty 的 ByteBuf 默认是直接内存)。短期用完就丢的场景,用普通 HeapByteBuffer 更快。

三、Selector:多路复用器

3.1 为什么需要 Selector

BIO 的问题是:一个线程一次只能处理一个连接。线程在等待 IO 的时候什么都干不了。

NIO 的 Selector 解决了这个问题:一个线程可以同时监听多个 Channel 的 IO 事件

BIO 线程模型:                    NIO 线程模型:
                              
  Thread 1                        Thread 1
    |                               |
  accept() ──→ 连接1               Selector
    |                            /    |    \
  read()  ──→ 连接1             Ch1   Ch2   Ch3
    |                           |     |     |
  write() ──→ 连接1            事件   事件   无事件
    |
  Thread 2
    |
  accept()
    |
  read()
    ...

Selector 就像一个前台接待员:一个人可以同时接待多个访客,哪个访客的事情准备好了就去处理。

3.2 SelectionKey:注册事件的标记

Channel 要注册到 Selector 上,并指定关心什么事件:

事件SelectionKey 常量含义
OP_ACCEPT1 << 4 = 16接受连接事件(ServerSocketChannel 用)
OP_CONNECT1 << 3 = 8连接就绪事件(客户端用)
OP_READ1 << 0 = 1读就绪事件
OP_WRITE1 << 2 = 4写就绪事件
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.socket().bind(new InetSocketAddress(8080));
serverChannel.configureBlocking(false); // 必须是非阻塞

Selector selector = Selector.open();

// 注册到 Selector,关心 ACCEPT 事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

3.3 Selector 的工作流程

Selector selector = Selector.open();

serverChannel.register(selector, SelectionKey.OP_ACCEPT);
clientChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

// 核心循环
while (true) {
    // 阻塞等待有 IO 事件发生
    int readyCount = selector.select();
    
    if (readyCount == 0) continue; // 没有事件
    
    // 获取所有就绪的 SelectionKey
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> it = selectedKeys.iterator();
    
    while (it.hasNext()) {
        SelectionKey key = it.next();
        
        if (key.isAcceptable()) {
            // 接受新连接
            ServerSocketChannel server = (ServerSocketChannel) key.channel();
            SocketChannel client = server.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        }
        
        if (key.isReadable()) {
            // 读数据
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int n = client.read(buffer);
            if (n > 0) {
                buffer.flip();
                // 处理数据...
            }
        }
        
        if (key.isWritable()) {
            // 写数据
            SocketChannel client = (SocketChannel) key.channel();
            // 写逻辑...
        }
        
        it.remove(); // 处理完必须移除,否则会重复处理
    }
}

3.4 select() 的多种重载

方法说明
select()阻塞,直到至少有一个 Channel 就绪
select(long timeout)阻塞最多 timeout 毫秒
selectNow()非阻塞,立即返回
// 经典用法:select + selectedKeys 遍历
while (running) {
    selector.select(); // 阻塞
    for (SelectionKey key : selector.selectedKeys()) {
        // 处理事件
    }
}

3.5 attach() 和 attachment

每个 SelectionKey 可以绑定一个附件对象:

// 注册时绑定附件
clientChannel.register(selector, SelectionKey.OP_READ, someObject);

// 取出附件
Object attachment = key.attachment();

常见的附件用法:把业务处理对象、Buffer 等绑定到 key 上,处理时直接取出。

四、完整的 NIO 服务器示例

4.1 Echo 服务器

public class NioEchoServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.socket().bind(new InetSocketAddress(8080));
        serverChannel.configureBlocking(false);
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        
        System.out.println("NIO Echo 服务器启动,端口 8080");
        
        while (true) {
            int n = selector.select(); // 阻塞等待事件
            if (n == 0) continue;
            
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                
                try {
                    if (key.isAcceptable()) {
                        handleAccept(key, selector);
                    }
                    if (key.isReadable()) {
                        handleRead(key);
                    }
                    if (key.isWritable()) {
                        handleWrite(key);
                    }
                } catch (IOException e) {
                    key.cancel();
                    key.channel().close();
                }
            }
        }
    }
    
    private static void handleAccept(SelectionKey key, Selector selector) throws IOException {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel client = server.accept();
        client.configureBlocking(false);
        
        // 注册读事件,并附加一个 Buffer
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        client.register(selector, SelectionKey.OP_READ, buffer);
        
        System.out.println("新连接: " + client.getRemoteAddress());
    }
    
    private static void handleRead(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        buffer.clear();
        
        int n = client.read(buffer);
        if (n == -1) {
            key.cancel();
            client.close();
            System.out.println("连接关闭: " + client.getRemoteAddress());
            return;
        }
        
        buffer.flip();
        // 把读到的数据设为待写
        key.interestOps(SelectionKey.OP_WRITE);
    }
    
    private static void handleWrite(SelectionKey key) throws IOException {
        ByteBuffer buffer = (ByteBuffer) key.attachment();
        SocketChannel client = (SocketChannel) key.channel();
        
        // 回显数据
        client.write(buffer);
        
        // 切换回读模式
        buffer.clear();
        key.interestOps(SelectionKey.OP_READ);
    }
}

4.2 与 BIO 对比

// BIO:一个连接一个线程
ExecutorService pool = Executors.newFixedThreadPool(100);
ServerSocket server = new ServerSocket(8080);
while (true) {
    Socket socket = server.accept();
    pool.submit(() -> handle(socket));
}

// NIO:一个线程管理所有连接
Selector selector = Selector.open();
// 注册 Channel,监听事件
while (true) {
    selector.select(); // 一个线程等待所有事件
    // 处理所有就绪的 Channel
}
💡

NIO 的优势在于:即使有 10000 个连接,只要同时活跃的事件不多,一个线程就能搞定。BIO 需要 10000 个线程,成本天差地别。

五、常见踩坑

5.1 ❌ 错误示范:忘记 remove SelectionKey

while (it.hasNext()) {
    SelectionKey key = it.next();
    
    if (key.isReadable()) {
        // 处理读事件...
    }
    
    // ❌ 忘记 remove!下次 select() 返回时这个 key 还在
    // 导致重复处理,或者无限循环
}

正确做法:处理完一个 key 后必须 remove。

5.2 ❌ 错误示范:在 Selector 线程外关闭 Channel

// 另一个线程关闭了 Channel,但没有取消 key
new Thread(() -> channel.close()).start();

// selector.select() 可能永远阻塞,因为对应的 key 已经失效

正确做法:关闭 Channel 时调用 key.cancel(),或确保 Selector 能感知到 Channel 的关闭。

5.3 ❌ 错误示范:忘记处理 OP_WRITE

有时候 Channel 无法立即写入(比如发送缓冲区满了),导致数据丢失:

// 正确做法:注册 OP_WRITE,当可以写的时候再写
key.interestOps(SelectionKey.OP_WRITE);

// 或者使用 Buffer 的 hasRemaining() 判断
if (buffer.hasRemaining()) {
    key.interestOps(SelectionKey.OP_WRITE);
} else {
    key.interestOps(SelectionKey.OP_READ);
}

5.4 ❌ 错误示范:Buffer 设置太小导致数据截断

ByteBuffer buffer = ByteBuffer.allocate(64); // 太小了
client.read(buffer); // 如果数据超过 64 字节,会被截断

正确做法:预估数据大小,或者循环读取直到读完。

六、生产最佳实践

6.1 设置合理的 Buffer 大小

// 普通场景
ByteBuffer buffer = ByteBuffer.allocate(4096);

// 高性能场景:用直接内存
ByteBuffer directBuffer = ByteBuffer.allocateDirect(65536);

// 文件传输:用文件 Channel + transferTo
FileChannel inChannel = new RandomAccessFile("source.txt", "rw").getChannel();
FileChannel outChannel = new RandomAccessFile("dest.txt", "rw").getChannel();
long transferred = inChannel.transferTo(0, inChannel.size(), outChannel);

6.2 使用 SelectorProvider 创建 Channel

// 默认实现
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();

// 也可以指定 Provider(用于特殊场景)
Selector selector = SelectorProvider.provider().openSelector();

6.3 多路复用的线程模型

单 Selector 的问题:Selector 本身是阻塞的,如果处理一个事件耗时很长,会影响其他事件:

// 单 Selector 问题
while (true) {
    selector.select();
    for (SelectionKey key : selector.selectedKeys()) {
        // 如果这里处理很慢,其他 Channel 就得不到响应
        processHeavy(key);
    }
}

解决方案:分发给线程池处理耗时操作

ExecutorService workers = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());

while (true) {
    selector.select();
    for (SelectionKey key : selector.selectedKeys()) {
        // 把耗时操作丢给线程池
        workers.submit(() -> processHeavy(key));
    }
}

七、面试追问链

第一层:基础概念

面试官问:"NIO 的三大组件是什么?"

Channel(通道)、Buffer(缓冲区)、Selector(选择器)。Channel 是数据流,Buffer 是容器,Selector 是事件监听器。

第二层:Buffer 机制

面试官追问:"Buffer 的 flip() 是什么作用?"

flip 把 limit 设置为当前 position,然后 position 重置为 0。从写模式切换到读模式时必须调用。

第三层:Selector 原理

面试官追问:"Selector 是怎么做到一个线程管理多个连接的?"

底层基于操作系统的 select/poll/epoll 机制。注册到 Selector 的 Channel 会把自身的 fd(文件描述符)交给 Selector 管理。Selector 调用 select() 时,内核会检查所有 fd 的 IO 状态,返回就绪的 fd。Java 代码只需遍历就绪的 SelectionKey 处理即可。

第四层:与 BIO 对比

面试官追问:"NIO 比 BIO 好在哪里?什么场景用 NIO?"

NIO 用一个线程管理多个连接,避免了线程数量随连接数线性增长的问题。适合连接数多但每个连接活跃度低的场景(如聊天服务器、长连接网关)。如果连接数少且每个连接都需要持续处理,用 BIO 更简单。

【学习小结】

  • Channel:双向数据通道,可同时读写
  • Buffer:数据容器,三个指针 position/limit/capacity
  • flip():写转读时调用,重置指针
  • Selector:多路复用器,一线程管多连接
  • NIO 适合高并发、低活跃度场景
  • Selector + 线程池 = 生产级 NIO 服务器