本节目标:能说清 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 | 空闲连接占线程,连接数受线程数限制 |
| NIO | Selector 等待就绪事件 | 一个或少量事件循环线程 | 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 项目与实验环境 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。