Skip to content

NIO 实战

我第一次用 NIO 写聊天室,踩了三个通宵的坑。

那是我接手的一个即时通讯项目,最初用 BIO 写的,100 个在线用户还勉强能跑。后来产品搞了个活动,用户蹭蹭往上涨,服务端直接 OOM 了。

痛定思痛,我决定上 NIO。

聊天服务器:踩坑记录

java
public class NIOChatServer {
    private final Selector selector = Selector.open();
    private final Map<String, SocketChannel> clients = new ConcurrentHashMap<>();

    public static void main(String[] args) throws IOException {
        new NIOChatServer().start(8080);
    }

    public void start(int port) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.socket().bind(new InetSocketAddress(port));
        serverChannel.configureBlocking(false);
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("聊天服务器启动,端口 " + port);

        while (true) {
            selector.select();
            for (SelectionKey key : selector.selectedKeys()) {
                if (key.isAcceptable()) {
                    handleAccept(key);
                }
                if (key.isReadable()) {
                    handleRead(key);
                }
            }
            selector.selectedKeys().clear();
        }
    }

    private void handleAccept(SelectionKey key) throws IOException {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel client = server.accept();
        client.configureBlocking(false);
        client.register(selector, SelectionKey.OP_READ);
        clients.put(client.getRemoteAddress().toString(), client);
        broadcast("用户 " + getClientName(client) + " 加入了聊天\n");
        sendTo(client, "欢迎!当前在线 " + clients.size() + " 人\n");
    }

    private void handleRead(SelectionKey key) throws IOException {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(512);

        int read = client.read(buffer);
        if (read > 0) {
            buffer.flip();
            byte[] data = new byte[buffer.remaining()];
            buffer.get(data);
            String message = new String(data, StandardCharsets.UTF_8);

            if (message.trim().equals("/quit")) {
                clients.remove(client.getRemoteAddress().toString());
                client.close();
                broadcast("用户 " + getClientName(client) + " 离开了\n");
            } else {
                String formatted = getClientName(client) + ": " + message;
                broadcast(formatted);
            }
        } else if (read == -1) {
            clients.remove(client.getRemoteAddress().toString());
            client.close();
            broadcast("用户 " + getClientName(client) + " 断开了\n");
        }
    }

    private void broadcast(String message) {
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
        for (SocketChannel client : clients.values()) {
            try {
                while (buffer.hasRemaining()) {
                    client.write(buffer);
                }
                buffer.rewind();
            } catch (IOException e) {
                // 忽略
            }
        }
    }

    private void sendTo(SocketChannel client, String message) throws IOException {
        ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
        while (buffer.hasRemaining()) {
            client.write(buffer);
        }
    }

    private String getClientName(SocketChannel client) {
        return "Client-" + client.getRemoteAddress().toString().split(":")[1];
    }
}

坑一:broadcast 的 buffer 没重置

java
// ❌ 第一版代码
private void broadcast(String message) {
    ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
    for (SocketChannel client : clients.values()) {
        while (buffer.hasRemaining()) {
            client.write(buffer); // 写完第一个 client 后,buffer 没重置
        }
        // 后面的 client 收不到消息
    }
}

// ✅ 修正
private void broadcast(String message) {
    ByteBuffer buffer = ByteBuffer.wrap(message.getBytes(StandardCharsets.UTF_8));
    for (SocketChannel client : clients.values()) {
        try {
            while (buffer.hasRemaining()) {
                client.write(buffer);
            }
            buffer.rewind(); // 重置 position=0,给下一个 client 用
        } catch (IOException e) {
            // 忽略
        }
    }
}

坑二:没处理半包

如果用户发了一条很长的消息(比如粘贴了一段代码),TCP 可能会分包到达。第一版代码直接 read() 就处理,结果消息截断了。

解决方案是加一个协议解析器,按 \n 分行处理:

java
// 每个客户端维护一个读缓冲区
private Map<SocketChannel, ByteBuffer> readBuffers = new ConcurrentHashMap<>();

private void handleRead(SelectionKey key) throws IOException {
    SocketChannel client = (SocketChannel) key.channel();
    ByteBuffer buffer = readBuffers.computeIfAbsent(client, k -> ByteBuffer.allocate(1024));

    int read = client.read(buffer);
    if (read > 0) {
        buffer.flip();
        // 按行处理
        while (buffer.hasRemaining()) {
            byte b = buffer.get();
            if (b == '\n') {
                // 完整一行,解析
                processMessage(client, lineBuilder.toString());
                lineBuilder = new StringBuilder();
            } else {
                lineBuilder.append((char) b);
            }
        }
        buffer.compact(); // 压缩未处理完的数据
    }
}

文件传输服务器:直接缓冲区的好处

NIO 的 FileChannel 配合直接缓冲区,文件复制性能可以接近系统原生:

java
public class NIOFileServer {

    // 基础版:简单复制
    public static void copyFile(String src, String dst) throws IOException {
        try (
            FileChannel in = new FileInputStream(src).getChannel();
            FileChannel out = new FileOutputStream(dst).getChannel()
        ) {
            ByteBuffer buffer = ByteBuffer.allocateDirect(8192);
            while (in.read(buffer) != -1) {
                buffer.flip();
                out.write(buffer);
                buffer.clear();
            }
        }
    }

    // 进阶版:带进度显示
    public static void copyFileWithProgress(String src, String dst)
            throws IOException {
        FileChannel in = new FileInputStream(src).getChannel();
        FileChannel out = new FileOutputStream(dst).getChannel();
        long size = in.size();
        long transferred = 0;
        ByteBuffer buffer = ByteBuffer.allocateDirect(8192);

        try {
            while (transferred < size) {
                buffer.clear();
                long read = in.read(buffer);
                buffer.flip();
                long written = out.write(buffer);
                transferred += written;
                int percent = (int) (transferred * 100 / size);
                System.out.printf("传输进度: %d%%\r", percent);
            }
            System.out.println();
        } finally {
            in.close();
            out.close();
        }
    }
}

为什么用 allocateDirect()

因为 FileChannel 和网络 socket 打交道时,直接缓冲区可以避免数据在 JVM 堆和系统内核之间来回复制。文件越大,这个优化越明显。

经验总结

写完这个项目,我总结了 NIO 开发的几条血泪教训:

  1. selectedKeys 必须清空——不清空的话,同一个事件会被处理 N 次
  2. buffer 必须重置或压缩——广播消息时尤其容易踩坑
  3. 处理半包问题——TCP 不保证消息边界
  4. 非阻塞模式下 accept/read/write 都可能返回 null/0/-1——都要处理
  5. 生产环境用 Netty——自己写的 NIO 总有考虑不周的地方

直接用 NIO 写生产代码是勇敢的选择,但大多数时候你只是在重复造轮子。

如果你的项目不是专注文档解析或网络协议教育,直接上 Netty 吧。

下一节,我们来看看 NIO 的增强版——NIO.2。

基于 VitePress 构建