BIO · NIO · SELECTOR · EPOLL · REACTOR · NETTY · TOMCAT NIO CONNECTOR · 虚拟线程
一万个连接,要几个线程
一个连接的大部分时间都在等:等用户点下一下,等下一个请求,等数据库回话。IO 模型决定的是“等”这件事由谁来做:BIO 让线程守着连接等;NIO 把等交给内核的 epoll,线程只处理已经有数据的连接;虚拟线程让你照样写阻塞代码,由 JVM 在底下换成 epoll。所以连接数可以是线程数的几十倍。代价是事件循环里绝不能阻塞,业务里的阻塞调用最后还是得有线程去等。
假设场景接着 三高知识地图 往下挖:那页算的是“同时在忙的线程 = QPS × RT”,这页回答线程为什么会被“等”占住、Tomcat 为什么能开 8192 个连接却只有 200 个线程、Netty 和虚拟线程各解决了什么。select / poll / epoll 本身怎么工作,Redis 线程模型那页已经讲过,这里一句带过。标实测的数字来自 ⑤ 的程序,在 2 核 ARM 云主机上用 JDK 17 跑(虚拟线程那组用 JDK 21)。
①要解决的问题:连接很多,但大多在发呆
浏览器的 keep-alive、App 的长连接、网关到后端的连接池、调下游的 HTTP 连接,都会让一台服务器同时挂着成千上万个连接。它们绝大部分时间没有数据。
②朴素做法:一个连接配一个线程(BIO)
最好写的服务端:accept() 一个连接,就开一个线程,线程里 readLine() 读请求、写响应。没数据的时候,线程就停在 readLine() 里睡着。
ServerSocket ss = new ServerSocket(port);
while (true) {
Socket s = ss.accept(); // 来一个连接
new Thread(() -> serve(s)).start(); // 开一个线程守着它
}
// serve:while ((line = in.readLine()) != null) { 回 pong }
// 没数据时,线程就停在 readLine() 里
10007 个线程
挂 1 万个连接,JVM 线程数就是 1 万出头。实测
RSS 1.0~1.3 GB
两次实测分别是 984 MB 和 1347 MB,NIO 版只要 58 MB。实测
开连接慢 4 倍
打开 1 万个连接 2.6 秒,NIO 版 0.7 秒,多出的时间花在建线程上。实测
换成线程池也不行
池子 200 个线程,第 201 个连接就要排队,哪怕前 200 个连接全在发呆。
线程被空闲的连接占着。线程是贵东西(栈、内核调度、切换),连接本身却很便宜(一个文件描述符加几 KB 缓冲)。
缺的那块:让一个线程能同时“等”很多连接,谁有数据就处理谁。这就是 IO 多路复用。
server.tomcat.threads.max 开到几千。Tomcat 的 NIO 连接器里,空闲的 keep-alive 连接本来就不占工作线程(⑤ 手推),线程不够通常是请求在“等下游”,开大线程只是把压力推给数据库和下游。③几种思路:谁来等
Java 后端会碰到的写法有五种。区别只有一个:连接没数据时,是线程在等,还是内核在等。
| 写法 | 谁在等 | 业务代码怎么写 | 典型代表 | 代价 |
|---|---|---|---|---|
| BIO + 每连接一线程 | 平台线程 | 阻塞、顺序写 | 早期 Tomcat 的 BIO 连接器(8.5 起已删除) | 连接多了线程爆炸 |
| NIO 多路复用(Reactor) | 内核 epoll,一个线程盯一大批 | 事件回调,不许阻塞 | Netty、Tomcat 的 NIO 连接器、Redis | 代码难写;事件循环里一阻塞,所有连接一起等 |
| NIO 收连接 + 线程池跑业务 | 连接空闲时内核等;请求处理时工作线程等 | 阻塞、顺序写 | Tomcat + Spring MVC 默认就是这种 | 在途请求数仍受线程数限制 |
| 响应式(全程非阻塞) | 内核 | Mono / Flux 链式写 | Spring WebFlux、Spring Cloud Gateway | 数据库、SDK 都得换非阻塞版(R2DBC 等),调试难 |
| 虚拟线程 | JVM 把阻塞的 socket 交给 epoll,虚拟线程挂起 | 阻塞、顺序写 | JDK 21 + Spring Boot 3.2 起的 spring.threads.virtual.enabled | 下游仍要限流;JDK 21 有钉住(pinning)问题 |
选择规则:普通业务服务用 Tomcat + Spring MVC(NIO 收连接、线程池跑业务),线程不够先查是不是在等下游;网关、推送、IM 这种连接多、业务轻的用 Netty / WebFlux;想要阻塞写法又要高并发,看 虚拟线程。本页先讲透多路复用,再看这三种各自怎么用它。
④关键洞察:一次读分两段,线程只该花在第二段
先知道这些
socket 就是一个 fd
每个 TCP 连接在内核里有一个接收缓冲区,在进程里是一个文件描述符(fd)。网卡收到的数据先进内核缓冲区,read() 再把它拷到你的程序里。
TCP 是字节流
TCP 只保证字节按顺序到,不保证“一次 read 正好一个请求”。怎么切出一条条消息是应用层的事(⑥ 坑 6)。
select / poll / epoll
都能让一个线程同时等很多 fd;epoll 事先登记一次,之后只把有数据的 fd 交回来。细节见 Redis 线程模型。
平台线程 vs 虚拟线程
平台线程一对一绑一个操作系统线程;虚拟线程是 JVM 调度的轻量线程,阻塞时把自己从载体线程上卸下来。
洞察 1一次 read 有两段,五种 IO 模型的区别全在“谁在哪段被卡住”
- 多路复用并不比阻塞 IO “更快”,单个连接的延迟差不多。它省的是线程:第 1 段的等待从“一个连接一个线程”变成“一万个连接一个线程”。
- Java NIO 就是这一行(Linux 上 Selector 的实现是 epoll)。Java 的 AIO(NIO.2 的
AsynchronousSocketChannel)在 Linux 上是用 epoll 加线程池模拟出来的,不是真正的第 5 行,所以 Netty、Tomcat 默认都不用它。
洞察 2连接便宜,线程贵:所以要把两者拆开
| 一个空闲连接 | 一个平台线程 | 一个虚拟线程 | |
|---|---|---|---|
| 内存 | 内核 socket 结构 + 收发缓冲(按需分配,空闲时很小) | 栈预留 2 MB 地址空间(本机 aarch64 的 ThreadStackSize = 2040 KB),实际用到的部分常驻;实测 1 万个线程多出约 0.7~1 GB | 栈帧存在堆上,用多少占多少 |
| 创建 | accept 一次 | 系统调用 + 内核建任务,实测 1 万个慢 1.9 秒 | 普通对象分配 |
| 调度 | 不需要 | 内核调度,线程越多上下文切换越多 | JVM 在少量载体线程上切换 |
| 上限 | ulimit -n(本机 1048576) | ulimit -u、内存、vm.max_map_count | 堆大小 |
实测里虚拟线程版 RSS 417 MB,其中 317 MB 是堆:每个连接一个 BufferedReader(加上它底下的字节缓冲约 24 KB),BIO 版也有同样的一份;NIO 版所有连接共用一个 64 字节的缓冲区,堆只用了 7 MB。省内存靠的不只是“少开线程”,还有“不给空闲连接分配缓冲”。Netty 的池化 ByteBuf 做的就是后一件事。
洞察 3Reactor:少数线程管连接,另一批线程跑业务Tomcat 和 Netty 都是这个形状
- 所以 Tomcat 能
maxConnections=8192、threads.max=200:连接受连接计数管,在途请求受工作线程管,两件事被拆开了。 - 接上三高那页的账:Little 定律里的“线程”,指的就是这 200 个工作线程;“RT”里最大的一块,是线程在 JDBC、HTTP 调用上阻塞等待的时间。
- Tomcat 只把“等下一个请求”这段交给了 epoll;请求一旦开始处理,等数据库、等下游仍然占着工作线程。要把这段也省掉,只有两条路:全程非阻塞(WebFlux + R2DBC),或者虚拟线程。
洞察 4为什么事件循环里绝不能阻塞设计对比
逼出这个规矩的约束:一个事件循环线程要服务成千上万个连接。它在某个连接上睡 200 ms,其余所有连接这 200 ms 里都没人管。虚拟线程反过来:一个连接一个(虚拟)线程,某个线程睡着,JVM 把它从载体线程上卸下来,载体线程去跑别的。
NIO 事件循环里 sleep 200 ms实测
虚拟线程里 sleep 200 ms实测,JDK 21
决定胜负的约束:谁来承担阻塞。事件循环把“等”交给了内核,自己就不能再等;业务代码里的 JDBC、大多数 SDK 都是阻塞的,所以 Tomcat 保留了一个阻塞的工作线程池,Netty 要求把阻塞活交给单独的业务线程池。虚拟线程让阻塞写法变便宜,但 Little 定律还在:等下游的请求数照样是 QPS × RT,压力会转移到连接池和下游上。
比喻对不上的地方:常把事件循环比作“一个服务员照看很多桌”。真实的 Netty 是每个 CPU 核一个事件循环(默认 2 × 核数个),每个连接固定属于其中一个;一个事件循环卡住,只影响它名下的那一批连接,但这一批通常也有几千个。
语法Selector 最少要认识这几行
Selector sel = Selector.open();
server.configureBlocking(false);
server.register(sel, SelectionKey.OP_ACCEPT);
while (true) {
sel.select(); // 睡到有事
for (SelectionKey k : sel.selectedKeys()) {
if (k.isAcceptable()) { /* accept,注册 OP_READ */ }
if (k.isReadable()) { /* read 到 ByteBuffer,处理 */ }
}
sel.selectedKeys().clear(); // 不清就会重复处理
}
# 同时接入的连接上限(默认 8192)
server.tomcat.max-connections=8192
# 内核全连接队列长度(默认 100)
server.tomcat.accept-count=100
# 同时处理请求的工作线程(默认 200)
server.tomcat.threads.max=200
# 空闲 keep-alive 连接最多留多久(不设 = connectionTimeout,内嵌 Tomcat 是 60 秒)
server.tomcat.keep-alive-timeout=60s
# JDK 21 + Boot 3.2 起:工作线程换成虚拟线程
spring.threads.virtual.enabled=true
⑤手推一遍:一个 keep-alive 连接穿过 Tomcat
先跟着一个连接走一遍 ④ 那张图,看每一步占了谁;再用一个 100 行的程序,把三种写法各压 1 万个连接。
按 Tomcat 10.1.55 源码核对:NioEndpoint 一个 Acceptor、一个 Poller;acceptCount 是 bind() 的 backlog;maxConnections 默认 8192;HTTP/1.1 的 connectionTimeout 默认 60000 ms,keepAliveTimeout 不设就用它;maxKeepAliveRequests 默认 100。Tomcat 自带的 server.xml 把 connectionTimeout 写成 20000,Spring Boot 内嵌时不读那个文件。
压 1 万个连接:三种写法各要多少资源
import java.io.*;
import java.lang.management.ManagementFactory;
import java.net.*;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.Iterator;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
/** 一个回声服务,三种写法:bio(一连接一线程)、nio(一个线程 + Selector)、vt(虚拟线程,JDK 21)。
* 收到 "ping" 回 "pong";收到 "slow" 先睡 200 ms 再回(模拟一次阻塞调用)。 */
public class IoServer {
static final AtomicInteger conns = new AtomicInteger();
public static void main(String[] a) throws Exception {
String mode = a[0]; int port = Integer.parseInt(a[1]);
startStats(mode);
if (mode.equals("nio")) nio(port); else blocking(mode, port);
}
// ---- bio / vt:阻塞写法,每个连接一个线程 ----
static void blocking(String mode, int port) throws Exception {
ExecutorService vt = null;
if (mode.equals("vt")) // 反射调用,好让 --release 8 也能编译;只有 JDK 21+ 能跑这个模式
vt = (ExecutorService) java.util.concurrent.Executors.class.getMethod("newVirtualThreadPerTaskExecutor").invoke(null);
ServerSocket ss = new ServerSocket(port, 4096);
while (true) {
final Socket s = ss.accept();
conns.incrementAndGet();
Runnable r = () -> serve(s);
if (vt != null) vt.execute(r); else new Thread(r).start();
}
}
static void serve(Socket s) {
try (BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.US_ASCII));
OutputStream out = s.getOutputStream()) {
String line;
while ((line = in.readLine()) != null) { // 没数据时,线程就停在这里等
if (line.equals("slow")) Thread.sleep(200);
out.write("pong\n".getBytes(StandardCharsets.US_ASCII));
}
} catch (Exception ignored) { }
conns.decrementAndGet();
}
// ---- nio:一个线程用 Selector 管所有连接 ----
static void nio(int port) throws Exception {
Selector sel = Selector.open();
ServerSocketChannel ss = ServerSocketChannel.open();
ss.bind(new InetSocketAddress(port), 4096);
ss.configureBlocking(false);
ss.register(sel, SelectionKey.OP_ACCEPT);
ByteBuffer buf = ByteBuffer.allocate(64);
ByteBuffer pong = ByteBuffer.wrap("pong\n".getBytes(StandardCharsets.US_ASCII));
while (true) {
sel.select(); // 睡到有连接“有事”为止
Iterator<SelectionKey> it = sel.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey k = it.next(); it.remove();
if (k.isAcceptable()) {
SocketChannel c = ss.accept();
c.configureBlocking(false);
c.register(sel, SelectionKey.OP_READ);
conns.incrementAndGet();
} else if (k.isReadable()) {
SocketChannel c = (SocketChannel) k.channel();
buf.clear();
int n = c.read(buf);
if (n < 0) { k.cancel(); c.close(); conns.decrementAndGet(); continue; }
// 演示用:假设一行在一次 read 里读完(真实代码要处理半包,见 ⑥)
String msg = new String(buf.array(), 0, n, StandardCharsets.US_ASCII);
for (String line : msg.split("\n")) {
if (line.equals("slow")) Thread.sleep(200); // 在事件循环里阻塞:所有连接一起等
pong.rewind(); c.write(pong);
}
}
}
}
}
// ---- 每 2 秒打印:连接数、线程数、RSS ----
static void startStats(String mode) {
Thread t = new Thread(() -> {
while (true) {
try { Thread.sleep(2000); } catch (InterruptedException e) { return; }
System.gc();
long heap = (Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()) >> 20;
System.out.printf("[%s] 连接 %d JVM 线程 %d RSS %d MB 其中堆已用 %d MB%n", mode, conns.get(),
ManagementFactory.getThreadMXBean().getThreadCount(), rssMb(), heap);
}
});
t.setDaemon(true); t.start();
}
static long rssMb() {
try {
for (String l : Files.readAllLines(Paths.get("/proc/self/status")))
if (l.startsWith("VmRSS:")) return Long.parseLong(l.replaceAll("\\D", "")) / 1024;
} catch (IOException ignored) { }
return -1;
}
}
IoClient.java:一个线程开 1 万个连接,再测“有人发 slow 时别人等多久”
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/** 用一个线程打开 N 个连接,各发一次 ping;然后测:有人发 slow 时,其他 100 个连接的 ping 要等多久。 */
public class IoClient {
public static void main(String[] a) throws Exception {
int n = Integer.parseInt(a[0]), port = Integer.parseInt(a[1]);
Selector sel = Selector.open();
SocketChannel[] cs = new SocketChannel[n];
long t0 = System.nanoTime();
for (int i = 0; i < n; i++) {
cs[i] = SocketChannel.open(new InetSocketAddress("127.0.0.1", port));
cs[i].configureBlocking(false);
cs[i].register(sel, SelectionKey.OP_READ, i);
}
long tConn = System.nanoTime();
for (SocketChannel c : cs) send(c, "ping\n");
waitPongs(sel, n, null);
long tPong = System.nanoTime();
System.out.printf("打开 %d 个连接 %d ms,%d 个 ping 全部回来又用了 %d ms%n",
n, (tConn - t0) / 1_000_000, n, (tPong - tConn) / 1_000_000);
Thread.sleep(5000); // 全部空闲,让服务端打印线程数和内存
// 连接 0 发一个要处理 200 ms 的请求,紧接着 100 个别的连接各发 ping
long[] start = new long[n]; long[] lat = new long[n];
send(cs[0], "slow\n");
Thread.sleep(5);
for (int i = 1; i <= 100; i++) { start[i] = System.nanoTime(); send(cs[i], "ping\n"); }
waitPongs(sel, 101, (i, now) -> lat[i] = (now - start[i]) / 1_000_000);
long[] l = Arrays.copyOfRange(lat, 1, 101); Arrays.sort(l);
System.out.printf("有人发 slow 时,其他 100 个 ping:中位数 %d ms,最慢 %d ms%n", l[50], l[99]);
for (SocketChannel c : cs) c.close();
}
interface OnPong { void at(int i, long now); }
static void send(SocketChannel c, String s) throws Exception {
ByteBuffer b = ByteBuffer.wrap(s.getBytes(StandardCharsets.US_ASCII));
while (b.hasRemaining()) c.write(b);
}
static void waitPongs(Selector sel, int expect, OnPong cb) throws Exception {
ByteBuffer buf = ByteBuffer.allocate(64); int got = 0;
while (got < expect) {
sel.select();
Iterator<SelectionKey> it = sel.selectedKeys().iterator();
while (it.hasNext()) {
SelectionKey k = it.next(); it.remove();
buf.clear(); int r = ((SocketChannel) k.channel()).read(buf);
for (int j = 0; j < r; j++) if (buf.get(j) == '\n') {
got++; if (cb != null) cb.at((Integer) k.attachment(), System.nanoTime());
}
}
}
}
}
$ javac -encoding UTF-8 --release 8 IoServer.java IoClient.java $ java -Xmx512m IoServer bio 19111 & java IoClient 10000 19111 打开 10000 个连接 2617 ms,10000 个 ping 全部回来又用了 1538 ms 有人发 slow 时,其他 100 个 ping:中位数 4 ms,最慢 6 ms [bio] 连接 10000 JVM 线程 10007 RSS 1347 MB 其中堆已用 294 MB $ java -Xmx512m IoServer nio 19112 & java IoClient 10000 19112 打开 10000 个连接 679 ms,10000 个 ping 全部回来又用了 414 ms 有人发 slow 时,其他 100 个 ping:中位数 196 ms,最慢 201 ms [nio] 连接 10000 JVM 线程 7 RSS 58 MB 其中堆已用 7 MB $ java21 -Xmx512m IoServer vt 19113 & java IoClient 10000 19113 打开 10000 个连接 944 ms,10000 个 ping 全部回来又用了 913 ms 有人发 slow 时,其他 100 个 ping:中位数 8 ms,最慢 13 ms [vt] 连接 10000 JVM 线程 13 RSS 417 MB 其中堆已用 317 MB
-Xmx512m,客户端和服务端在同一台机器上,走 127.0.0.1。bio 和 vt 的堆主要是每个连接一个 BufferedReader;RSS 两次跑会差几百 MB(第一次 bio 984 MB、vt 448 MB),看数量级就行。⑥边界和变体:连接这一层会在哪出事
第一圈的坑每条标了根源在 ④ 的哪一条;第二圈是团队里该怎么配;第三圈是零拷贝、Netty 的 epoll 传输、io_uring 和版本差异。
注意事项
- 现象
- Netty 服务或 WebFlux 接口偶尔全体变慢,CPU 却不高;某一个慢请求过后,同一批连接的请求一起超时。⑤ 实测 196 ms
- 根源
- 在 handler 或 Reactor 链里调了 JDBC、同步 HTTP、
Thread.sleep、加了锁的同步代码,事件循环线程被卡住,它名下的几千个连接都没人管。 - 做法
- Netty:阻塞的 handler 放进单独的
EventExecutorGroup,或交给有界业务线程池。WebFlux:Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()),更好的是换非阻塞驱动。测试里用 BlockHound 抓阻塞调用。
- 现象
- 下游自己的 RT 很正常,调用方的 RT 却很高;线程 dump 里大量线程停在从连接池拿连接的地方。
- 根源
- 按 Little 定律,需要的连接数 = 调用 QPS × 下游 RT。Apache HttpClient 5 的池子默认每个目标地址 5 条、总共 25 条;4.x 是 2 条、20 条(源码核对)。Spring Boot 3.5 的
RestClient/RestTemplate自动配置在 classpath 上有 HttpClient 5 时优先用它,而且不改池子大小。 - 做法
- 每个目标的连接数按“QPS × RT × 1.5”配(Boot 里用
HttpComponentsClientHttpRequestFactoryBuilder.withConnectionManagerCustomizer);设“从池里拿连接”的超时;客户端全局复用一个,不要每次请求 new。
- 现象
- 夜里或低峰过后,第一批请求报
NoHttpResponseException、Connection reset,重试一次就好。 - 根源
- 连接在客户端池子里空闲着,服务端已经按自己的空闲超时把它关了(内嵌 Tomcat 默认 60 秒、Nginx
keepalive_timeout默认 75 秒、云负载均衡常常更短),客户端还拿它发请求。 - 做法
- 客户端的空闲回收时间短于服务端的空闲超时,让客户端先关;复用前校验空闲太久的连接(HttpClient 4.x 默认空闲 2 秒以上就先校验);幂等请求自动重试一次。
- 现象
- 调用方报
Cannot assign requested address;ss -s里 timewait 有好几万。 - 根源
- 主动关闭的一方要在 TIME_WAIT 里待 60 秒(Linux 固定)。本机临时端口范围 32768~60999,共 28232 个;对同一个目标 IP 和端口,每秒新建超过约 470 个短连接(28232 ÷ 60),端口就会被占满。
- 做法
- 复用连接:连接池、keep-alive,别每个请求 new 一个客户端。
tcp_tw_recycle在 NAT 环境下会出错,Linux 4.12 已经把它删了,别去找它。
- 现象
- 突发流量时,部分客户端连都连不上,报连接超时;服务端日志里什么也没有。
- 根源
- 连接计数到了
maxConnections或 Acceptor 来不及取,握手完成的连接堆在内核队列里。队列长度取acceptCount和net.core.somaxconn(本机 4096)中小的那个,满了内核就丢掉后来的握手包。 - 做法
- 看
ss -lnt:监听端口的 Recv-Q 是当前排队数,Send-Q 是上限;nstat -az TcpExtListenOverflows看溢出次数。根本办法是加机器或在入口限流,队列开大只是让客户端多等一会儿。
- 现象
- 自定义 TCP 协议在本机测试好好的,上线后偶尔解析出错,消息被截断或两条粘在一起。
- 根源
- TCP 只保证字节有序,一次
read可能读到半条,也可能读到两条半。⑤ 的演示代码假设一次 read 正好一行,只在本机小包下成立。 - 做法
- 协议里必须有分帧:长度字段或分隔符。每个连接一个累积缓冲区,攒够一帧再处理。Netty 现成的
LengthFieldBasedFrameDecoder、LineBasedFrameDecoder。
- 现象
- JDK 21 上打开
spring.threads.virtual.enabled,压力一大,所有请求都不动了。 - 根源
- JDK 21 里,虚拟线程在
synchronized块里阻塞会钉住(pin)载体线程,卸不下来;载体线程默认和 CPU 核数一样多,几个一钉满,所有虚拟线程都跑不了。老版本的驱动、连接池内部常用synchronized。 - 做法
- JDK 24(JEP 491)起
synchronized不再钉住。留在 JDK 21 就用 JFR 的jdk.VirtualThreadPinned事件找出来,换成ReentrantLock;虚拟线程不要池化,对下游用信号量限制并发。
- 现象
- Netty 服务跑几天后被 OOM Killer 杀掉,堆内存监控却一直很平稳。
- 根源
- Netty 为了少拷贝,默认用直接内存(堆外)和引用计数的
ByteBuf。哪里漏了release(),堆外内存就一直不还,堆监控看不到。 - 做法
- 设
-XX:MaxDirectMemorySize给堆外封顶;测试环境开-Dio.netty.leakDetection.level=advanced找泄漏;监控里加上直接内存的用量。
使用规范
连接超时、读超时、从池里拿连接的超时,三个都设。
连接池排队(坑 2)、线程被下游借走
客户端空闲回收时间 < 服务端 keep-alive 超时 < 负载均衡空闲超时。
复用到已关闭的连接(坑 3)
Netty handler、WebFlux 链里不调 JDBC、同步 HTTP、sleep;阻塞活交给有界的业务线程池。
一个慢请求拖住几千个连接(坑 1)
长度字段优先,分隔符要考虑转义。
半包粘包(坑 6)
ulimit -n、net.core.somaxconn、net.ipv4.ip_local_port_range;监控 ss -s 的 estab 和 timewait。
文件句柄耗尽、队列溢出(坑 5)、端口耗尽(坑 4)
线程不再限并发,就给下游加信号量或舱壁;ThreadLocal 别缓存大对象,每个虚拟线程各有一份。
下游和连接池被压垮、内存膨胀(坑 7)
优化和深处
零拷贝:第 2 段也能省
发文件时,普通写法要把数据从页缓存拷到用户空间、再拷回 socket 缓冲区。sendfile 让内核直接把页缓存交给网卡,用户空间那两次拷贝都没了。Kafka 发消息、Nginx 发静态文件、Netty 的 FileRegion 都靠它。开了 TLS 就要先在用户空间加密,零拷贝基本失效。实测和细节见 Kafka 存储与零拷贝。
Netty 为什么还要自带 epoll 传输
- JDK 的 Selector 是水平触发,每次 select 都要分配就绪集合;Netty 的
EpollEventLoopGroup直接用 JNI 调 epoll,默认边缘触发,垃圾更少,还能用SO_REUSEPORT等 Linux 专有选项。 - 历史包袱:早年 JDK 在 Linux 上有 epoll 空轮询的 bug,
select()没事件也立刻返回,CPU 打满。Netty 的应对是数提前返回的次数,超过阈值(io.netty.selectorAutoRebuildThreshold,默认 512)就重建 Selector,这段代码一直留到今天。
io_uring:Linux 上真正的异步 IO
Linux 5.1(2019 年)加入。应用和内核共用两个环形队列:应用把“读这个 socket 到这块缓冲区”放进提交队列,内核做完把结果放进完成队列,两段都不占线程,批量提交时系统调用也少了。Netty 有 io_uring 传输;JDK 自己的 NIO 还没有用它。
WebFlux 不是“更快”,是“更省线程”
请求少、下游快时,WebFlux 和 Spring MVC 的 RT 差不多,代码还更难写。它赢的场景是连接多、等待长:网关、聚合多个下游的接口、推送。前提是整条链都是非阻塞的,一个阻塞的驱动就把它拉回坑 1。有了虚拟线程以后,普通业务服务大多没必要为了并发换 WebFlux。
版本差异
| 点 | JDK 8 / Spring Boot 2 | JDK 17 / Spring Boot 3 | JDK 21 |
|---|---|---|---|
| NIO Selector | Linux 上就是 epoll | 同左 | 同左;虚拟线程的阻塞 socket 也由 JDK 内部的 poller 用 epoll 等 |
| Socket / ServerSocket | 老的 PlainSocketImpl(C 代码) | JDK 13 起(JEP 353)换成基于 NIO 的 NioSocketImpl,为虚拟线程铺路 | 阻塞的 socket 读写在虚拟线程上会挂起而不是占住线程 |
| 虚拟线程 | 无 | 无 | JEP 444 正式版;Boot 3.2 起 spring.threads.virtual.enabled;synchronized 钉住问题到 JDK 24(JEP 491)才解决 |
| Tomcat | Boot 2 用 Tomcat 9,默认 NIO 连接器(BIO 连接器 8.5 起已删除) | Boot 3 用 Tomcat 10.1,连接器默认值同左(10.1.55 源码核对) | 开虚拟线程后工作线程池换成“每个请求一个虚拟线程”,Acceptor 和 Poller 不变 |
下一圈
- Netty 源码:EventLoop 的循环、ChannelPipeline、ByteBuf 池化
- Reactor 和背压:WebFlux 怎么防止快的生产者压垮慢的消费者
- HTTP/2 和 gRPC:一条连接上的多路复用和流控
- 虚拟线程源码:Continuation 怎么挂起和恢复
- TCP 参数调优:缓冲区大小、keepalive 探测、拥塞控制
- io_uring 在 Java 里的现状和 Netty 的 io_uring 传输
- 已做:虚拟线程下一圈:什么时候用、pinning 的坑(挂载卸载、钉住的发现和修法;Continuation 源码那条还没做)
↺回到第⑤步:改 IoServer
预期结果是按 ④ 和代码推出来的,没有在本机跑过,你跑出来的数字应该落在附近。
练习 1 · 把 slow 挪出事件循环
nio 模式里,收到 slow 就交给一个 Executors.newFixedThreadPool(4);业务线程睡完后把“要回 pong 的连接”放进一个并发队列,再 sel.wakeup(),由事件循环线程统一写。
预期:其他 100 个 ping 的中位数从 196 ms 回到个位数毫秒,和 vt 模式差不多;JVM 线程多 4 个。直接在业务线程里 c.write 也能跑,但会和事件循环同时写同一个连接。
用到:坑 1、④ 洞察 3
练习 2 · 先猜,再拆包发
改 IoClient:把连接 1 的 "ping\n" 拆成 "pi" 和 "ng\n" 两次发,中间睡 10 ms。先猜 nio 服务端会回几个 pong。
预期:两个。第一次 read 到 "pi",被当成一行回了 pong;第二次 read 到 "ng" 又回一个。bio 和 vt 模式用 readLine(),会攒到换行符,只回一个。修法:每个连接挂一个 StringBuilder(key.attach),攒到换行再处理。
用到:坑 6
练习 3 · 用短连接压一次
写个客户端:每个 ping 都新开连接、收到 pong 就关,循环 2 万次。跑的时候另开一个终端看 ss -tan state time-wait | wc -l。
预期:TIME_WAIT 堆到上万个,并且出现在客户端这一侧(主动关闭的一方),60 秒后才慢慢消失;跑得够快、次数够多时会报 Cannot assign requested address。
用到:坑 4
1. 用一句话说出这页的精华。
IO 模型决定“等数据”由谁来等:BIO 让线程等,多路复用让内核替一个线程等一万个连接,虚拟线程让 JVM 把阻塞写法换成多路复用;所以连接和线程可以拆开,但事件循环里不能阻塞,业务里的阻塞等待仍然要有人承担。
← 开头 + ④
2. 一连接一线程的 BIO,痛在哪?
线程被空闲的连接占着。实测 1 万个连接要 10007 个线程、1 GB 以上内存,建连接还慢;换成线程池又会让第 201 个连接排队,哪怕前 200 个都在发呆。
← ②
3. nio 模式里一个 slow 让别人都等了 196 ms,根源是 ④ 的哪一条?
洞察 4:一个事件循环线程服务所有连接,它在 slow 上睡着,其他连接的就绪事件没人处理。虚拟线程版不受影响,因为睡着的是那个连接自己的虚拟线程。
← ④ 洞察 4 + ⑤ 实测
4. 预测:默认参数的 Spring Boot,5000 个浏览器挂着 keep-alive 空闲,另有 50 个请求正在等数据库。工作线程用了几个?连接计数是多少?
工作线程 50 个:空闲的 keep-alive 连接挂在 Poller 上,不占工作线程。连接计数 5050,还没到 8192。
← ④ 洞察 3 + ⑤ 手推
5. 预测:9000 个客户端同时连默认 Tomcat,会怎样?
前 8192 个被 accept;之后 Acceptor 暂停,握手完成的连接堆在内核全连接队列里,最多 min(100, somaxconn) 个;再后面的握手包被丢,客户端重传,最后连接超时。有连接关掉、计数降下来,Acceptor 才继续取。
← ④ 洞察 3 + 坑 5
6. 预测:服务每秒调下游 200 次,下游 RT 100 ms,用 HttpClient 5 默认连接池,会怎样?
需要 200 × 0.1 = 20 条连接,默认每个目标只有 5 条。这条路的吞吐上限是 5 ÷ 0.1 = 50 次每秒,其余请求排队等连接,调用方 RT 越来越长,看起来像“下游慢了”。
← 坑 2 + 三高页洞察 1
7. 预测:打开虚拟线程,HikariCP 还是 10 个连接,每秒 200 个请求,每个占连接 100 ms。线程还是瓶颈吗?瓶颈在哪?
线程不再是瓶颈:每个请求一个虚拟线程,想开多少开多少。但需要 200 × 0.1 = 20 个数据库连接,只有 10 个,一半请求的虚拟线程排在 Hikari 上等,RT 变长,堆里积压的虚拟线程越来越多。瓶颈从 Tomcat 线程挪到了连接池。
← ④ 洞察 4 + 坑 7