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索引**