《Java 网络编程基础》1.2 BIO、NIO、AIO 与 Reactor 模型

阻塞 Echo 与非阻塞 Selector Echo 各跑一遍,用线程栈、read 返回值和事件日志对比两种模型;再拆开 BIO、NIO、AIO 与 Reactor 的边界,说明 linkd 为什么用单线程事件循环加业务线程池,而不是给每个连接开一个线程。

本节目标:能说清 BIO、NIO、AIO 各自在哪里等待,以及 Reactor 与它们的边界。
适用版本:JDK 25(Temurin 25.0.5+8);Socket/ServerSocket;SocketChannel/Selector。

承接的问题

1.1 把一次请求的路径拆开了,但没有回答「谁在等」。健康检查只有一个连接,怎么等都行;linkd 的 9090 端口要同时挂住很多 keep-alive 连接,其中大部分时间没有数据。如果每个连接都占一个平台线程,线程数会跟着连接数线性上涨。

先别急着背「BIO 慢、NIO 快」。这一节把两个可运行的服务并排跑起来:

  • 阻塞 Echo:accept() 后为每个连接新建一个线程,线程里阻塞 read();
  • 非阻塞 Echo:一个 Selector 管所有连接,事件循环线程只在就绪时读写。

跑完之后看三样东西:线程名、线程栈、read() 的返回值。这三样比任何定义都更能说明差别。然后再把 BIO、NIO、AIO、Reactor 四个词放回它们各自的位置。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

阻塞 Echo:每个连接一个线程

先写一个最小的阻塞服务,监听 9091:

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class BlockingEchoExample {
    public static void main(String[] args) throws Exception {
        try (ServerSocket server = new ServerSocket(9091, 16,
                InetAddress.getByName("127.0.0.1"))) {
            System.out.println("blocking echo on 127.0.0.1:9091");
            while (true) {
                Socket socket = server.accept();
                System.out.println("accept " + socket.getRemoteSocketAddress());
                new Thread(() -> handle(socket), "echo-" + socket.getPort()).start();
            }
        }
    }
    static void handle(Socket socket) {
        try (socket;
             InputStream in = socket.getInputStream();
             OutputStream out = socket.getOutputStream()) {
            byte[] buf = new byte[1024];
            int n;
            while ((n = in.read(buf)) != -1) {
                System.out.println(Thread.currentThread().getName()
                        + " read " + n + " bytes");
                out.write(buf, 0, n);
                out.flush();
            }
            System.out.println(Thread.currentThread().getName() + " EOF");
        } catch (IOException e) {
            System.out.println(Thread.currentThread().getName()
                    + " error " + e.getMessage());
        }
    }
}

用 nc 发一行,服务端原样回显:

printf 'hello\n' | nc -w 2 127.0.0.1 9091

本机输出:

hello

服务端日志:

blocking echo on 127.0.0.1:9091
accept /127.0.0.1:59812
echo-59812 read 6 bytes
echo-59812 EOF

线程名 echo-59812 里的数字是客户端端口。每个连接一个线程,线程名能直接对上连接,这是阻塞模型最舒服的地方。in.read(buf) 在数据没来时会一直停在原生方法里;out.write 在发送缓冲区满时也会阻塞。代码是线性的,异常处理也简单。

但「阻塞」不等于「线程状态一定是 WAITING」。在连接保持打开时抓线程栈:

jcmd <pid> Thread.print | grep -A6 '"echo-'

本机输出(节选):

"echo-60893" #26 [29967] prio=5 os_prio=31 cpu=1.39ms elapsed=1.35s
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.SocketDispatcher.read0(java.base@25.0.5/Native Method)
	at sun.nio.ch.SocketDispatcher.read(java.base@25.0.5/SocketDispatcher.java:48)
	at sun.nio.ch.NioSocketImpl.tryRead(java.base@25.0.5/NioSocketImpl.java:255)
	at sun.nio.ch.NioSocketImpl.implRead(java.base@25.0.5/NioSocketImpl.java:305)
	at sun.nio.ch.NioSocketImpl.read(java.base@25.0.5/NioSocketImpl.java:354)

注意状态是 RUNNABLE,不是 WAITING。线程确实卡在 read0 这个原生方法里等数据,但 JVM 看不到内核里的等待,只能标成 RUNNABLE。所以「线程转储里 RUNNABLE 很多」不等于「CPU 很忙」,还要看栈顶是不是 SocketDispatcher.read0、FileDispatcher.read0 这类原生 I/O。

阻塞模型的代价不在单次 read(),而在空闲连接也占着线程。一个 keep-alive 连接在两次请求之间可能空闲几十秒,这段时间线程仍然存在,栈仍然占着内存,调度器仍然要管理它。连接数上去以后,线程创建、栈内存和上下文切换会一起变成问题。第 12 章会用业务线程池和虚拟线程分别处理这个问题;本节先把基线跑出来。连接数少、每个连接都有持续流量时,阻塞模型最简单也最好调试,linkd 只在测试和对照实验里保留它,9090 的主循环不用。

非阻塞 Selector Echo:一个线程管多个连接

非阻塞版本监听 9092。核心是三步:把通道设成非阻塞、注册到 Selector、在事件循环里处理就绪 key。

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
public class NonBlockingEchoExample {
    public static void main(String[] args) throws Exception {
        ServerSocketChannel server = ServerSocketChannel.open();
        server.bind(new InetSocketAddress("127.0.0.1", 9092), 16);
        server.configureBlocking(false);
        Selector selector = Selector.open();
        server.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("non-blocking echo on 127.0.0.1:9092");
        while (true) {
            selector.select();
            Iterator<SelectionKey> it = selector.selectedKeys().iterator();
            while (it.hasNext()) {
                SelectionKey key = it.next();
                it.remove();
                if (key.isAcceptable()) {
                    SocketChannel ch = server.accept();
                    ch.configureBlocking(false);
                    ch.register(selector, SelectionKey.OP_READ,
                            ByteBuffer.allocate(1024));
                    System.out.println("accept " + ch.getRemoteAddress()
                            + ", keys=" + selector.keys().size());
                } else if (key.isReadable()) {
                    echoOnRead(key);
                } else if (key.isWritable()) {
                    flushOnWrite(key);
                }
            }
        }
    }
    static void echoOnRead(SelectionKey key) throws IOException {
        SocketChannel ch = (SocketChannel) key.channel();
        ByteBuffer buf = (ByteBuffer) key.attachment();
        int n = ch.read(buf);
        System.out.println("read n=" + n + " position=" + buf.position());
        if (n == -1) {
            key.cancel();
            ch.close();
            return;
        }
        if (n == 0) {
            return;
        }
        buf.flip();
        int written = ch.write(buf);
        System.out.println("write " + written + " bytes, remaining="
                + buf.remaining());
        if (buf.hasRemaining()) {
            key.interestOps(SelectionKey.OP_WRITE);
        } else {
            buf.clear();
        }
    }
    static void flushOnWrite(SelectionKey key) throws IOException {
        SocketChannel ch = (SocketChannel) key.channel();
        ByteBuffer buf = (ByteBuffer) key.attachment();
        ch.write(buf);
        if (!buf.hasRemaining()) {
            buf.clear();
            key.interestOps(SelectionKey.OP_READ);
        }
    }
}

同样用 nc 发一行:

printf 'world\n' | nc -w 2 127.0.0.1 9092

本机输出:

world

服务端日志:

non-blocking echo on 127.0.0.1:9092
accept /127.0.0.1:59813, keys=2
read n=6 position=6
write 6 bytes, remaining=0
read n=-1 position=0

日志里没有 echo-59813 这样的新线程。keys=2 是监听套接字加客户端连接两个 key;所有读写都在 main 线程里完成。再抓线程栈:

jcmd <pid> Thread.print | grep -A5 '"main"'

本机输出(节选):

"main" #3 [9731] prio=5 os_prio=31 cpu=48.56ms elapsed=195.19s
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.KQueue.poll(java.base@25.0.5/Native Method)
	at sun.nio.ch.KQueueSelectorImpl.doSelect(KQueueSelectorImpl.java:121)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:130)

main 停在 KQueue.poll,这是 macOS 上 Selector 的底层实现。一个线程、一个 select() 调用,就能同时等待监听套接字和所有已连接套接字。连接数增加时,增加的是 key 和缓冲区,不是线程。
代价也很具体:

  • 半包和粘包要自己处理。read n=6 只是这一次读到的 6 字节,不保证是一次完整消息。HTTP 请求分片到达时,第一次可能只读到请求行的一半。
  • 写可能只写一部分。ch.write(buf) 返回实际写出的字节数;发送缓冲区满时可能小于 remaining,必须保留剩余部分并注册 OP_WRITE,等下一次可写再继续。
  • 任何阻塞调用都会拖住所有连接。事件循环里不能做 JDBC 查询、不能调用阻塞 HTTP 客户端、不能读大文件;这些操作要交给业务线程池。
  • 状态要自己维护。每个连接读到哪、还要写多少、是否半关闭,都要挂在 key 的 attachment 或 Connection 对象上。

这段代码还有一个故意留下的简化:echoOnRead 把 buf 直接 flip() 后写出,写不完就保留 buf;下一次 OP_READ 到来时,如果上一个响应还没写完,read(buf) 会继续往同一个 buf 的 position 后面写,状态就乱了。真实实现要么在 OP_WRITE 期间不再读,要么为读和写各准备一个缓冲区。linkd 的 Connection 用独立的发送队列解决这个问题,后文会看到。

BIO、NIO、AIO 与 Reactor 的边界

把四个词放在一张表里,先看「谁在等」:

名称谁等待线程模型典型 API主要代价
BIO调用线程阻塞在 read/write一个连接一个线程Socket、ServerSocket空闲连接占线程,连接数受线程数限制
NIOSelector 等待就绪事件一个或少量事件循环线程SocketChannel、Selector、ByteBuffer半包、部分写、状态机,代码复杂度高
AIO内核/JDK 完成后回调回调线程池AsynchronousSocketChannel回调线程模型、排序与背压更难控制
Reactor事件循环等待就绪,再分发给 Handler单 Reactor / 多 Reactor / 主从基于 NIO 的 Selector不是 I/O 模型,而是分发结构;阻塞任务要隔离

几个容易混淆的边界:
NIO 不等于「非阻塞」。 NIO 是 New I/O,包含 Buffer、Channel、Selector 三件套;SocketChannel 默认是阻塞的,必须显式 configureBlocking(false) 才能注册到 Selector。只用 FileChannel 读文件、不用 Selector,也叫 NIO,但和网络事件循环无关。
AIO 不等于「更快的 NIO」。 AIO 把「等待完成」从调用线程挪到回调,适合连接数多、每个操作独立的场景;但回调在哪个线程执行、多个回调之间如何排序、写队列满了如何背压,都需要额外设计。JDK 的 AIO 实现依赖平台,本卷不把 macOS 上的 AIO 当成 epoll/IOCP 的等价物,也不声称它的性能一定优于 Selector。linkd v1 不用 AIO,原因是它需要的是显式的就绪控制和可预测的线程归属,而不是把完成事件交给一个不透明的线程池。
Reactor 不是第四种 I/O 模型。 Reactor 是建立在 NIO 就绪事件上的分发模式:事件循环调用 select(),把 OP_READ 分给 Handler.onReadable,把 OP_WRITE 分给 Handler.onWritable。单 Reactor 是一个线程同时处理 accept、read、write;多 Reactor 把 accept 和 I/O 分到不同线程;主从 Reactor 再把业务处理隔离出去。第 6 章会实现单线程和多线程版本,第 12 章会讲业务线程池。
虚拟线程不取消这些边界。 JDK 25 的虚拟线程让「阻塞风格」的代码可以承载更多连接,阻塞在 socket I/O 时虚拟线程会从载体线程卸载;但超时、背压、半包、优雅关闭一个都不会消失。linkd v1 的服务器默认仍是平台线程加 Selector,虚拟线程只在 12.2 的业务线程池里做对照。

选型先问三个问题:连接数是几十还是几万;业务里有没有阻塞调用;团队能不能维护状态机。答案偏向 BIO、NIO/Reactor 还是虚拟线程,取决于这三个约束,而不是「哪个更新」。

在 linkd 里落地

linkd 的 Handler 接口只定义三个回调,和 Selector 的就绪事件一一对应:

package com.plume.linkd.core;
public interface Handler {
    void onReadable(Connection c);
    void onWritable(Connection c);
    void onClosed(Connection c);
}

一个回显用的 Handler 可以这样写。它不关心线程,只关心「可读时读一次、把字节交给连接」:

package com.plume.linkd.app;
import com.plume.linkd.core.Connection;
import com.plume.linkd.core.Handler;
import java.io.IOException;
import java.nio.ByteBuffer;
public final class EchoHandler implements Handler {
    private final ByteBuffer in = ByteBuffer.allocate(1024);
    @Override
    public void onReadable(Connection c) {
        try {
            in.clear();
            int n = c.channel().read(in);
            if (n == -1) {
                c.close();
                return;
            }
            if (n == 0) {
                return;
            }
            in.flip();
            ByteBuffer out = ByteBuffer.allocate(in.remaining());
            out.put(in).flip();
            c.enqueue(out);
        } catch (IOException e) {
            c.close();
        }
    }
    @Override
    public void onWritable(Connection c) {
        // 发送队列由 Connection 维护,Handler 不直接写通道
    }
    @Override
    public void onClosed(Connection c) {
        // 连接级资源在这里释放
    }
}

这里把读到的字节复制到新的 out,而不是直接把 in 交给 Connection。原因是 in 会在下一次 onReadable 里被 clear() 复用;如果发送队列还持有 in,下一次读就会改写尚未写出的响应。复制有成本,但正确;第 5 章会用 BufferPool 和所有权转移减少这次复制。

EventLoop 负责把 Handler 挂到通道上,并提供业务线程回投的入口:

EventLoop loop = new EventLoop();
loop.register(channel, new EchoHandler());
loop.execute(() -> System.out.println("run on event loop"));

register(SocketChannel, Handler) 内部必须先把通道设成非阻塞,再注册 OP_READ;execute(Runnable) 把任务放进队列并调用 selector.wakeup(),让事件循环从 select() 返回后执行。业务线程处理完阻塞调用,通过 execute 把「生成响应」这一步交回事件循环,再调用 Connection.enqueue。这条回投链是 Reactor 模型里最容易写错的地方:直接在业务线程里写 SocketChannel,会把响应字节交错;只入队不 wakeup(),事件循环可能还在睡,响应要等下一次事件才发出去。

第二个落点是缓冲区所有权。BufferPool.acquire(int minCapacity) 拿到的缓冲区,在 Connection.enqueue 之后不能再被调用方复用;release 必须等写完之后由事件循环调用。谁分配、谁释放、什么时候释放,要在 Connection 的发送队列里写清楚。第 13 章会讲直接内存的生命周期,这里先把规则立住:入队即转移所有权,发送完成才归还。

常见错误与边界

错误一:把阻塞通道注册到 Selector。 SocketChannel 默认阻塞,直接 register 会抛:

java.nio.channels.IllegalBlockingModeException

正确顺序是 configureBlocking(false) 之后再 register。服务端的 ServerSocketChannel 也一样;只把它设成非阻塞、忘了客户端通道,会在第一次 accept 后的注册处失败。

错误二:忽略 write() 的返回值。 下面这种写法在发送缓冲区满时会丢字节:

// 反例:假设一次 write 能写完
ch.write(ByteBuffer.wrap(response));

write() 返回实际写出的字节数,可能小于 remaining。必须检查 buf.hasRemaining(),把剩余部分保留下来,注册 OP_WRITE,等下一次可写继续写。HTTP 响应体越大,这个问题越容易出现;小回显测试往往看不出来。

错误三:把 read() 返回 0 当成连接关闭。 非阻塞模式下 0 只是「现在没数据」,-1 才是 EOF。把 0 当 EOF 会在客户端刚连上、还没发请求时关掉连接;把 -1 当 0 继续等,会在对端关闭后留下一个永远不会再就绪的 key。

边界:单次运行的线程栈不能证明性能。 本节只证明了「阻塞模型每连接一个线程、非阻塞模型一个线程管多个连接」,没有证明后者吞吐更高。吞吐取决于请求大小、连接空闲比例、业务耗时和机器核数;第 15 章会用 ab 和 JFR 给出可复现的测量方法,不在这里下结论。

小结

  • 阻塞 Echo 每个连接一个线程,read() 停在原生方法里;线程转储可能显示 RUNNABLE,因为 JVM 看不到内核里的等待。
  • 非阻塞 Echo 用一个 Selector 管多个连接,read() 返回 0 表示暂时没数据,-1 才是 EOF。
  • AIO 用完成回调替代就绪通知,线程归属和背压更复杂;本卷不把 macOS 上的 AIO 当成更快的 NIO。
  • Reactor 是建立在 NIO 就绪事件上的分发结构,不是第四种 I/O 模型;单 Reactor、多 Reactor、主从 Reactor 的区别在分发层,不在系统调用层。
  • linkd 用 EventLoop + Handler + Connection.enqueue 把「等待」「处理」「写回」分开;阻塞业务必须回投,缓冲区入队即转移所有权。

下一节把 linkd 的四个 Maven 模块、JDK 25 环境和 9090/8080 端口约定搭起来,然后用 curl、lsof、netstat 验证这条事件循环真的在跑。

阅读导航:上一节:1.1 从一次 HTTP 请求看 Java 网络栈 · 下一节:1.3 linkd 项目与实验环境 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发