Skip to content

06.3 — NIO 核心

定位: 非阻塞 IO 模型 — 一个线程管理成千上万连接,理论基础是一切网络框架的基石 面试高频度: ⭐⭐⭐⭐ 考查方式: Buffer 原理、Selector 事件驱动、BIO vs NIO、零拷贝、直接内存

一、这是什么?为什么需要它?

传统 BIO 的问题

BIO 模型(一个连接一个线程):
  ServerSocket server = new ServerSocket(8080);
  while (true) {
      Socket socket = server.accept();  // 阻塞,等待连接
      new Thread(() -> handle(socket)).start();  // 每个连接一个线程
  }

问题:
  - 10000 个连接 = 10000 个线程
  - 线程切换开销巨大
  - 大多数线程在等待 IO(阻塞浪费)
  - C10K 问题(Concurrent 10K Connections)

根源:BIO 的 read/write 是阻塞的。

NIO 如何解决

NIO 模型(一个线程管所有连接):
  Selector.select() → 返回有事件发生的连接
  → 处理这些连接(读/写)
  → 继续 select

核心思想:Reactor 模式(事件驱动)
  1 个 Selector 监控 N 个 Channel
  → 只有真正可读/可写时才处理
  → 线程不再空等

二、原理拆解

2.1 三大核心组件

┌─────────────────────────────────────────────────────────────┐
│              NIO 三大核心组件                                 │
│                                                              │
│  ┌──────────┐   ┌──────────┐   ┌──────────┐                │
│  │  Buffer  │   │ Channel  │   │ Selector │                │
│  │  缓冲区  │   │   通道   │   │  选择器  │                │
│  ├──────────┤   ├──────────┤   ├──────────┤                │
│  │ 读数据到 │   │ 数据来源 │   │ 监控多个 │                │
│  │ 此处    │   │ 或目的地 │   │ Channel  │                │
│  │         │   │          │   │          │                │
│  │ ByteBuf │   │ SocketCh │   │ select() │                │
│  │ CharBuf │   │ FileCh   │   │ →就绪集合 │                │
│  │ IntBuf  │   │ ServerSc │   │          │                │
│  └──────────┘   └──────────┘   └──────────┘                │
│                                                              │
│  使用流程:                                                   │
│  Channel.read(Buffer)     ← 从通道读数据到缓冲区              │
│  Channel.write(Buffer)    ← 从缓冲区写数据到通道              │
│  Selector.select()        ← 监控多个 Channel 的事件           │
└─────────────────────────────────────────────────────────────┘

2.2 Buffer 核心

java
public abstract class Buffer {
    private int position;  // 当前读/写位置
    private int limit;     // 可读/写的上限
    private int capacity;  // 缓冲区总容量
}
Buffer 的状态切换:

        写模式                   读模式
  ┌──────────┐          ┌──────────┐
  │ position=0│  flip()  │ position=0│
  │ limit=cap │ ──────→  │ limit=写入量│
  │ capacity=N│          │ capacity=N│
  └──────────┘          └──────────┘
       ↑ flip() 切换               │
       │                          │
       └──────────────────────────┘
              clear() / compact()
java
// 标准使用模式
ByteBuffer buffer = ByteBuffer.allocate(1024);

// 1. 从 Channel 读入 Buffer
channel.read(buffer);  // position = 读了多少字节

// 2. 切换为读模式
buffer.flip();  // limit = position; position = 0

// 3. 从 Buffer 读出数据
while (buffer.hasRemaining()) {
    byte b = buffer.get();  // 读取一个字节
}

// 4. 清空/压缩
buffer.clear();   // 清空整个缓冲区
// 或
buffer.compact(); // 保留未读数据前移

直接内存 vs 堆内存:

java
// 堆内存(HeapByteBuffer)
ByteBuffer heapBuf = ByteBuffer.allocate(1024);
// 数据在 JVM 堆中 → 读写需要复制到直接内存

// 直接内存(DirectByteBuffer)
ByteBuffer directBuf = ByteBuffer.allocateDirect(1024);
// 数据在操作系统内存 → 减少一次拷贝
// 适合:大文件、网络 IO 频繁的场景

2.3 Selector 事件驱动

Selector 的工作流程:

1. 创建 Selector
   Selector selector = Selector.open();

2. 注册 Channel 和事件
   channel.register(selector, SelectionKey.OP_READ);

   可注册的事件:
   OP_ACCEPT   — 新连接(ServerSocketChannel)
   OP_CONNECT  — 连接就绪(SocketChannel)
   OP_READ     — 可读
   OP_WRITE    — 可写

3. 选择就绪事件(阻塞)
   int readyCount = selector.select();  // 阻塞直到有事件
   // 或
   int readyCount = selector.selectNow();  // 非阻塞,立即返回

4. 处理就绪事件
   Set<SelectionKey> keys = selector.selectedKeys();
   for (SelectionKey key : keys) {
       if (key.isAcceptable()) { ... }    // 接收新连接
       if (key.isReadable()) { ... }      // 读取数据
       if (key.isWritable()) { ... }      // 写入数据
       iterator.remove();  // 必须手动移除!
   }

2.4 BIO vs NIO 线程模型对比

BIO:
                    线程池
  Client1 ──→ ┌───────────┐
              │ Thread-1  │ ← 阻塞在 read()
  Client2 ──→ ├───────────┤
              │ Thread-2  │ ← 阻塞在 read()
  Client3 ──→ ├───────────┤
              │ Thread-3  │ ← 阻塞在 read()
  Client4 ──→ ├───────────┤
              │ ...       │ ← waiting
              └───────────┘
  连接数 = 线程数 → 10000 连接 = 10000 线程

NIO:
                     一个线程
  Client1 ──→ ┌───────────────┐
  Client2 ──→ │               │
  Client3 ──→ │  Thread-1     │ ← select() → 遍历就绪的 Channel
  Client4 ──→ │  (Selector)   │ ← 没有事件就阻塞
  ClientN ──→ │               │
              └───────────────┘
  连接数 >> 线程数 → 10000 连接 = 1 线程

2.5 零拷贝(Zero-Copy)

传统 IO 的 4 次拷贝:
  磁盘 → 内核空间 → 用户空间 → 内核空间 → 网卡

  read(file)  write(socket)
  = 2 次系统调用 + 4 次上下文切换 + 2 次 CPU 拷贝

NIO 零拷贝(FileChannel.transferTo()):
  磁盘 → 内核空间 → 网卡
  (操作系统在内核空间直接传输,不经过用户空间)

  transferTo() = 1 次系统调用
  = 上下文切换减半,无 CPU 拷贝

  适用于:文件服务器、大文件传输

三、图解全景

┌─────────────────────────────────────────────────────────────┐
│            NIO 服务端完整工作流                               │
│                                                              │
│  ① ServerSocketChannel 注册 OP_ACCEPT                       │
│      │                                                      │
│  ② Selector.select() → 有新连接                             │
│      │                                                      │
│      ▼                                                      │
│  ③ accept() → SocketChannel                                 │
│      │                                                      │
│      ▼                                                      │
│  ④ SocketChannel 注册 OP_READ                               │
│      │                                                      │
│  ⑤ Selector.select() → 有数据可读                           │
│      │                                                      │
│      ▼                                                      │
│  ⑥ channel.read(buffer) → position 移动                    │
│      │                                                      │
│      ▼                                                      │
│  ⑦ buffer.flip() → 切换到读模式                             │
│      │                                                      │
│      ▼                                                      │
│  ⑧ buffer.get(bytes) → 处理数据                             │
│      │                                                      │
│      ▼                                                      │
│  ⑨ buffer.clear() / compact() → 准备下次写入                │
└─────────────────────────────────────────────────────────────┘

四、实战验证

4.1 简易 NIO Echo Server

java
public class NioEchoServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();

        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ssc.configureBlocking(false);  // 必须非阻塞
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        ByteBuffer buffer = ByteBuffer.allocate(256);

        while (true) {
            selector.select();  // 阻塞等待事件
            for (SelectionKey key : selector.selectedKeys()) {
                if (key.isAcceptable()) {
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    sc.register(selector, SelectionKey.OP_READ);
                } else if (key.isReadable()) {
                    SocketChannel sc = (SocketChannel) key.channel();
                    buffer.clear();
                    int read = sc.read(buffer);
                    if (read == -1) {
                        sc.close();  // 连接关闭
                    } else {
                        buffer.flip();
                        sc.write(buffer);  // echo back
                    }
                }
                selector.selectedKeys().remove(key);  // 必须移除!
            }
        }
    }
}

4.2 直接内存 vs 堆内存性能对比

java
// 大文件读写测试(简化版)
int SIZE = 100 * 1024 * 1024;  // 100MB

// 堆内存(两次拷贝:堆 → 直接内存 → 磁盘)
ByteBuffer heapBuf = ByteBuffer.allocate(SIZE);
long start = System.nanoTime();
FileChannel fc.write(heapBuf);  // 隐式拷贝到 DirectBuffer
// 间接:堆 → 直接内存 → 设备

// 直接内存(一次拷贝:直接内存 → 磁盘)
ByteBuffer directBuf = ByteBuffer.allocateDirect(SIZE);
fc.write(directBuf);  // 直接 → 设备
// 大文件场景下直接内存快 20-50%

五、面试视角

追问答案要点
BIO、NIO、AIO 有什么区别?BIO 阻塞一连接一线程;NIO 非阻塞多路复用;AIO 异步回调
select() 和 selectNow() 的区别?select() 阻塞直到有事件;selectNow() 非阻塞立即返回(可能为0)
NIO 的 selector.selectedKeys() 为什么要 remove?Selector 不自动移除,不 remove 下次会重复处理
直接内存有什么缺点?分配/回收比堆内存慢(-XX:MaxDirectMemorySize 控制大小),适合长生命周期的大 Buffer
Netty 和 NIO 的关系?Netty 封装了 NIO 的复杂性,提供更易用的 API(编解码、内存池等)

📚 相关链接

  • **字节流与字符流** — 传统 IO 基础
  • **缓冲流与装饰器模式** — 缓冲设计对比
  • **直接内存** — 直接内存在 JVM 中的原理
  • ← 返回 **IO索引**

Knowledge4J — Java 知识库