第一次写聊天室的时候,我用传统 IO 写服务端:每个客户端一个线程,300 个人在线就开 300 个线程,内存哗哗涨、线程来回切换,代码还到处是锁。后来换成 NIO——一个线程管住所有连接,代码量反而更少。这篇把 NIO 的三件套(Channel / Buffer / Selector)和非阻塞模型讲清楚,服务端示例是实际跑通的。
一、解决什么问题:传统 IO 的痛点
传统 IO(java.io)是流式 + 阻塞的,有两个硬伤:
- 阻塞:一个线程
read()数据,数据没到就卡在那,什么都干不了 - 一线程一连接:要同时处理 N 个连接,就得开 N 个线程
// 传统 IO 服务端:每个连接一个线程
while (true) {
Socket socket = serverSocket.accept(); // 阻塞等连接
new Thread(() -> {
InputStream in = socket.getInputStream();
byte[] buf = new byte[1024];
while (in.read(buf) != -1) { /* 处理 */ } // 阻塞读,一个线程伺候一个连接
}).start(); // 300 个连接 = 300 个线程
}300 个连接就是 300 个线程——线程栈内存、上下文切换开销全来了。NIO 的核心目标:一个线程处理成千上万个连接。
二、NIO 三件套:Channel、Buffer、Selector
NIO(java.nio,New IO,Java 1.4 引入)换了一套模型,核心是三个角色:
Channel(通道) 连接两端,只管传输(双向)
↓ 数据流经
Buffer(缓冲区) 中转站,数据先读进缓冲区再统一处理
↓
Selector(选择器)一个线程盯着所有 Channel:谁有事件(来连接/来数据/可写)就处理谁Channel:通道
比 IO 的流高级的地方:双向(一个 Channel 既能读又能写),且非阻塞。常见的有:
| Channel | 对应场景 |
|---|---|
ServerSocketChannel | 服务端监听端口(accept 连接) |
SocketChannel | 一条 TCP 连接(读/写数据) |
FileChannel | 文件读写 |
Buffer:缓冲区
数据从 Channel 读出来先进 Buffer,处理完写回 Channel 也从 Buffer 走。Buffer 内部有三个指针:
position(写/读的位置) limit(可操作边界) capacity(总容量)
写模式:position 往后挪,limit = capacity
flip():写 → 读(position 归零,limit = 刚才写到的位置)
读模式:position 往后挪
clear():读 → 写(position 归零,limit = capacity)最容易错的一步:写完必须 flip() 再读——不 flip,position 还在末尾,读出来是空的。
Selector:多路复用(NIO 的灵魂)
Selector 让一个线程管 N 个 Channel:
- Channel 把感兴趣的事件注册到 Selector(
OP_ACCEPT有连接来 /OP_READ有数据 /OP_WRITE可写) selector.select()阻塞等待——有事件才返回- 返回后逐个处理"就绪"的 Channel,处理完继续
select()
没数据的连接不打扰线程,这就是"多路复用":一个线程盯着整片连接,谁有动静处理谁。
三、服务端怎么写:EchoServer(实跑验证)
一个单线程 EchoServer(收到什么回什么),完整走一遍三件套:
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
public class NioEchoServer {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open();
server.bind(new InetSocketAddress(9088));
server.configureBlocking(false); // ① 非阻塞
server.register(selector, SelectionKey.OP_ACCEPT); // ② 注册"有连接来"
System.out.println("NIO EchoServer 启动,单线程监听");
ByteBuffer buf = ByteBuffer.allocate(1024);
while (true) {
selector.select(); // ③ 阻塞等事件
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
if (key.isAcceptable()) { // 新连接
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ); // ④ 新连接也注册进来
System.out.println("收到新连接:" + client.getRemoteAddress());
} else if (key.isReadable()) { // 有数据
SocketChannel client = (SocketChannel) key.channel();
buf.clear();
int n = client.read(buf);
if (n == -1) { client.close(); System.out.println("连接关闭"); }
else {
buf.flip(); // ⑤ 写完 flip 再读
client.write(buf); // 原样回显
System.out.println("回显:" + new String(buf.array(), 0, buf.limit()));
}
}
}
}
}
}实测:起 3 个客户端同时连接 + 发消息,整个服务端只有 1 个线程(main 线程的事件循环):
NIO EchoServer 启动,单线程监听
收到新连接:/127.0.0.1:63881
回显:hello nio A
连接关闭
收到新连接:/127.0.0.1:63882
回显:hello nio B
连接关闭
收到新连接:/127.0.0.1:63884
回显:hello nio C
连接关闭3 个连接、3 次收发,全是那一个线程通过 Selector 完成的——N 个连接 = 1 个线程,多路复用实锤。
四、Buffer 的坑:flip 与 clear
Buffer 用法上有几个高频翻车点:
| 操作 | 什么时候 | 干什么 |
|---|---|---|
allocate(n) | 创建 | 分配堆上缓冲区(allocateDirect 是堆外) |
flip() | 写完要读时 | 写模式 → 读模式(position 归零,limit 指到数据末尾) |
clear() | 读完要写时 | 读模式 → 写模式(position 归零,limit = capacity) |
compact() | 读完还剩一点没处理 | 把剩余数据挪到开头,继续写 |
忘了 flip() 是最经典的 bug:写完直接 get(),position 还在末尾,读出来全是 0 或空。
五、DirectBuffer 与"零拷贝"(和 OOM 的关系)
Buffer 有两种分配方式:
ByteBuffer.allocate(1024); // 堆上:受 -Xmx 管,GC 管
ByteBuffer.allocateDirect(1024); // 堆外(直接内存):少一次"内核 → JVM 堆"的拷贝网络数据到达内核缓冲区后,普通 Buffer 要多拷一次到 JVM 堆;DirectBuffer 让内核数据直达堆外内存,少拷一次——Netty 高性能的秘密之一。
代价:堆外内存不受 -Xmx 管、不归 GC 管(靠 Cleaner 释放),只分配不释放就会打爆 -XX:MaxDirectMemorySize,抛 Direct buffer memory——见《内存溢出(OOM):类型、定位与实战排查》第 ⑤ 种类型。
六、NIO 的继承者:Netty
NIO 的三件套是地基,但直接用写生产代码很累:半包/粘包(一次 read 读到的可能不是完整消息)、编解码、多线程模型都要自己处理。Netty 把这些全封装好了:
- 线程模型:Boss 线程 accept + Worker 线程读数据(多线程版 Selector)
- 编解码器:拆包组包开箱即用
- 大量生产级组件:重连、心跳、背压
你现在用的 Tomcat(8+)、Elasticsearch、Kafka 客户端,底层都是 NIO/Netty——包括日常开发里高并发网关、IM、推送服务,都是这套模型。
小结
- 为什么需要 NIO:传统 IO 阻塞 + 一线程一连接,撑不住高并发
- 三件套:Channel(双向通道)+ Buffer(中转缓冲区)+ Selector(多路复用,1 线程管 N 连接)
- 写服务端:ServerSocketChannel 注册 OP_ACCEPT → accept 后注册 OP_READ → select() 事件循环
- Buffer 坑:写完 flip、读完 clear,忘 flip 读出来是空
- DirectBuffer:堆外少一次拷贝,但不受堆管——和 OOM 的 Direct buffer memory 直接相关
- Netty:NIO 的生产级封装,高并发网络服务的实际选择
NIO 和内存的关系(堆外、直接内存)见《内存溢出(OOM)》和《JVM 内存模型与垃圾回收》;高并发下的线程模型(为什么不能无脑开线程)见《Java 并发编程》。
验证说明:EchoServer(Selector + 非阻塞 + flip 读写)本地 javac 编译运行实测——单线程事件循环同时处理 3 个客户端连接(accept → 注册 OP_READ → 回显 → 关闭),服务端日志与上文输出一致。
