Skip to content

第一次写聊天室的时候,我用传统 IO 写服务端:每个客户端一个线程,300 个人在线就开 300 个线程,内存哗哗涨、线程来回切换,代码还到处是锁。后来换成 NIO——一个线程管住所有连接,代码量反而更少。这篇把 NIO 的三件套(Channel / Buffer / Selector)和非阻塞模型讲清楚,服务端示例是实际跑通的。

一、解决什么问题:传统 IO 的痛点

传统 IO(java.io)是流式 + 阻塞的,有两个硬伤:

  1. 阻塞:一个线程 read() 数据,数据没到就卡在那,什么都干不了
  2. 一线程一连接:要同时处理 N 个连接,就得开 N 个线程
java
// 传统 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:谁有事件(来连接/来数据/可写)就处理谁

NIO 服务端模型:Selector 单线程管多连接

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(收到什么回什么),完整走一遍三件套:

java
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 有两种分配方式:

java
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 → 回显 → 关闭),服务端日志与上文输出一致。