把「每请求一线程」的服务从固定平台线程池换成虚拟线程,收益到底有多少,用嘴说不清楚,得量出来。
这篇文章用 JDK 自带的 HttpServer 搭了一个阻塞式服务,用固定并发数的压测客户端在 50 / 200 / 1000 / 5000 四档并发下分别跑固定池与虚拟线程,配上 JFR 录制、载体线程调参、CPU 密集反例和池化虚拟线程的反面教材。
文里的每个数字都是本机(Apple M1 Pro / JDK 25)跑出来的,原始输出都贴在对应的表格前后;跑不出来的地方会直说是推测或未验证。

实验环境与口径

机器与 JDK

1
2
3
4
5
6
7
8
9
10
11
12
$ java -version
java version "25" 2025-09-16 LTS
Java(TM) SE Runtime Environment (build 25+37-LTS-3491)
Java HotSpot(TM) 64-Bit Server VM (build 25+37-LTS-3491, mixed mode, sharing)

$ sysctl -n machdep.cpu.brand_string
Apple M1 Pro
$ sysctl -n hw.ncpu hw.memsize
10
17179869184
$ sysctl -n kern.osversion
25G83

服务端、压测端都是同一个 JDK,全部用 JDK 自带工具(java/javac/jfr/jcmd),没有引入任何第三方压测框架。测试期间机器上还有别的进程在跑(uptime 读数在 5~10 之间),因此每个数据点跑 3 次取中位数,对照组与实验组交替执行,横向对比仍然可用——但绝对数字不要当成「这台机器的极限」。

被测服务与压测客户端

被测服务是一个单文件 LoadServer,用 com.sun.net.httpserver.HttpServer,handler 里 Thread.sleep(50) 模拟一次下游阻塞调用(JDBC 查询、HTTP 调用都属于这一类:线程在等待期间几乎不消耗 CPU)。同一个服务通过 --mode 切换三种执行器:

  • platformExecutors.newFixedThreadPool(200),就是典型的每请求一线程池;
  • virtualExecutors.newVirtualThreadPerTaskExecutor()
  • virtual-fixedExecutors.newFixedThreadPool(200, Thread.ofVirtual().factory()),反面教材,后面单独说。

压测客户端 LoadClient 是手写的:固定并发数的平台线程,每个线程持有一条 keep-alive 长连接,串行发请求并记录每条请求的端到端延迟。口径统一为:预热 1 秒(不计入统计),统计窗口 5 秒,窗口结束后等待在途请求收尾(drainMs)。单次运行的请求总数就是表格里的 requests

三个先量清楚的基线

一、单进程平台线程上限。 迁移的动机常写成「平台线程稀缺」,稀缺到什么程度得实测:

1
2
3
4
5
$ java -Xss256k -cp classes ThreadLimit
[0.357s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 256k, guardsize: 16k, detached.
[0.357s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-4070"
created=4070 error=java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
heapUsed=11.8MB

能创建 4070 条,加上 JVM 自己的 20 多条内部线程正好是 4096,与 sysctl kern.num_taskthreads(4096)一致——这条上限是进程级的,跟堆内存没关系(失败时堆只用了 11.8MB)。后面 5000 并发那一档会被这条上限直接卡住,处理方式见下文。

二、Thread.sleep(50) 实际睡多久。 定时器的精度决定了整篇文章里延迟的地板:

1
2
3
4
5
6
7
8
9
$ java SleepAccuracy
sleep(50): avg=53.910ms min=50.271ms max=55.201ms
sleep(50): avg=53.882ms min=50.145ms max=55.125ms
sleep(50): avg=53.813ms min=50.036ms max=55.220ms

# 稍后重跑一次,确认这个量级是稳定的
sleep(50): avg=53.965ms min=50.062ms max=55.097ms
sleep(50): avg=54.231ms min=50.086ms max=55.079ms
sleep(50): avg=54.040ms min=50.103ms max=55.064ms

(每次 java 都会连跑 3 轮,每轮 200 次。)

单线程下 sleep(50) 平均要 53.8~54.2ms,多出来的是定时器合并与线程唤醒调度。「下游 50ms」的服务,单个线程的服务时间按 53.9ms 算,固定 200 条线程的理论吞吐上限是 200 / 0.0539 ≈ 3710 QPS——记住这个数,后面曲线上的平台会正好落在这里。

三、压测客户端不是免费的。 5000 并发需要 5000 条客户端线程,而上面已经量到单进程上限是 4070,所以 5000 那一档的客户端被拆成两个进程、各 2500 并发,QPS 取两个进程之和,延迟取两份原始样本合并后再算分位。这一档会单独标注。

基准骨架

服务端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
* 被压测的阻塞式服务:每个请求在 handler 里等待 downstreamMs 毫秒(模拟下游阻塞 IO),
* 可选再烧 cpuMicros 微秒 CPU(模拟下游阻塞占比不同的负载)。
*
* mode=platform 固定大小平台线程池
* mode=virtual 每任务一条虚拟线程
* mode=virtual-fixed 固定大小的池 + 虚拟线程工厂(反面教材)
*/
public class LoadServer {

public static void main(String[] args) throws Exception {
int port = 8080;
int pool = 200;
int downstreamMs = 50;
int cpuMicros = 0;
String mode = "virtual";

for (String arg : args) {
String[] kv = arg.split("=", 2);
if (kv.length != 2) {
continue;
}
switch (kv[0]) {
case "--port" -> port = Integer.parseInt(kv[1]);
case "--pool" -> pool = Integer.parseInt(kv[1]);
case "--downstream-ms" -> downstreamMs = Integer.parseInt(kv[1]);
case "--cpu-micros" -> cpuMicros = Integer.parseInt(kv[1]);
case "--mode" -> mode = kv[1];
default -> throw new IllegalArgumentException("unknown arg " + arg);
}
}

ExecutorService executor = switch (mode) {
case "platform" -> Executors.newFixedThreadPool(pool);
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
case "virtual-fixed" ->
Executors.newFixedThreadPool(pool, Thread.ofVirtual().name("vt-worker-", 0).factory());
default -> throw new IllegalArgumentException("unknown mode " + mode);
};

final int downstream = downstreamMs;
final int cpu = cpuMicros;
HttpServer server = HttpServer.create(new InetSocketAddress(port), 8192);
server.setExecutor(executor);
java.util.concurrent.atomic.AtomicLong handled = new java.util.concurrent.atomic.AtomicLong();
server.createContext("/", exchange -> {
handle(exchange, downstream, cpu);
handled.incrementAndGet();
});
server.start();

// 收工时打印处理量与进程 CPU 时间,用来算「每请求消耗多少 CPU」
final long bootCpuNanos = java.lang.management.ManagementFactory.getOperatingSystemMXBean()
instanceof com.sun.management.OperatingSystemMXBean bootOs ? bootOs.getProcessCpuTime() : -1;
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
long cpuNanos = java.lang.management.ManagementFactory.getOperatingSystemMXBean()
instanceof com.sun.management.OperatingSystemMXBean os ? os.getProcessCpuTime() : -1;
long n = handled.get();
long deltaNanos = cpuNanos - bootCpuNanos; // 扣掉 JVM 启动与 JIT 预热那部分 CPU
System.out.printf("STATS requests=%d processCpuMs=%d cpuAfterStartMs=%d cpuUsPerRequest=%.1f%n",
n, cpuNanos / 1_000_000, deltaNanos / 1_000_000, n == 0 ? -1 : deltaNanos / 1000.0 / n);
System.out.flush();
}));

System.out.printf("SERVER mode=%s pool=%d downstreamMs=%d cpuMicros=%d port=%d parallelism=%d%n",
mode, pool, downstreamMs, cpuMicros, port,
Integer.getInteger("jdk.virtualThreadScheduler.parallelism", -1));
System.out.flush();

Thread.currentThread().join();
}

private static void handle(HttpExchange exchange, int downstreamMs, int cpuMicros) throws IOException {
try {
if (cpuMicros > 0) {
burnCpu(cpuMicros);
}
if (downstreamMs > 0) {
Thread.sleep(downstreamMs);
}
byte[] body = "OK\n".getBytes();
exchange.getResponseHeaders().set("Content-Type", "text/plain");
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
exchange.close();
}
}

private static void burnCpu(int micros) {
long deadline = System.nanoTime() + micros * 1000L;
double x = 0;
while (System.nanoTime() < deadline) {
for (int i = 0; i < 1000; i++) {
x += Math.sqrt(i);
}
}
if (x == Double.MAX_VALUE) {
System.out.print("");
}
}
}

三点说明。HttpServer.create(addr, 8192) 的第二个参数是 accept 队列长度,5000 并发下默认值不够用。sun.net.httpserver.maxIdleConnections 默认 200,5000 条长连接里的大部分会被服务端主动关掉,所以启动时加了 -Dsun.net.httpserver.maxIdleConnections=20000burnCpuSystem.nanoTime() 卡时间,保证每个请求烧掉的 CPU 时间可控。

压测客户端

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;

/**
* 压测客户端:固定并发数的平台线程,每个线程持有一条 keep-alive 长连接,
* 串行发请求并统计每条请求的端到端延迟。
*
* --concurrency 并发连接数
* --warmup 预热毫秒数(不计入统计)
* --duration 统计窗口毫秒数
* --samples 延迟样本数组容量
* --dump 把原始延迟样本写到文件(多进程分片时用来合并分位)
*/
public class LoadClient {

static final String REQUEST = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: keep-alive\r\n\r\n";

public static void main(String[] args) throws Exception {
int port = 8080;
int concurrency = 200;
int warmupMs = 1000;
int durationMs = 5000;
int capacity = 4_000_000;
String dumpPath = null;

for (String arg : args) {
String[] kv = arg.split("=", 2);
if (kv.length != 2) {
continue;
}
switch (kv[0]) {
case "--port" -> port = Integer.parseInt(kv[1]);
case "--concurrency" -> concurrency = Integer.parseInt(kv[1]);
case "--warmup" -> warmupMs = Integer.parseInt(kv[1]);
case "--duration" -> durationMs = Integer.parseInt(kv[1]);
case "--samples" -> capacity = Integer.parseInt(kv[1]);
case "--dump" -> dumpPath = kv[1];
default -> throw new IllegalArgumentException("unknown arg " + arg);
}
}

long[] samples = new long[capacity];
AtomicInteger sampleIdx = new AtomicInteger();
AtomicLong errors = new AtomicLong();
AtomicLong reconnects = new AtomicLong();

CountDownLatch ready = new CountDownLatch(concurrency);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(concurrency);
final boolean[] measuring = {false};
final boolean[] stopped = {false};

byte[] request = REQUEST.getBytes();
final int connectPort = port;
java.util.concurrent.atomic.AtomicReference<Exception> firstError =
new java.util.concurrent.atomic.AtomicReference<>();
for (int i = 0; i < concurrency; i++) {
Thread t = Thread.ofPlatform().name("load-", i).unstarted(
() -> worker(connectPort, request, ready, start, done, measuring, stopped,
samples, sampleIdx, errors, reconnects, firstError));
t.setDaemon(true);
t.start();
}

ready.await(); // 等所有连接就绪,把建连时间排除在统计窗口之外
if (errors.get() > 0) {
System.out.println("FATAL " + errors.get() + " worker(s) failed to connect; first error:");
firstError.get().printStackTrace(System.out);
System.exit(1);
}
start.countDown();

Thread.sleep(warmupMs);
measuring[0] = true;
long windowStart = System.nanoTime();
Thread.sleep(durationMs);
long windowEnd = System.nanoTime();
measuring[0] = false;
stopped[0] = true;

long drainStart = System.nanoTime();
boolean drained = done.await(120, java.util.concurrent.TimeUnit.SECONDS);
long drainMs = (System.nanoTime() - drainStart) / 1_000_000;

int n = Math.min(sampleIdx.get(), samples.length);
long[] sorted = Arrays.copyOf(samples, n);
Arrays.sort(sorted);

System.out.printf(Locale.ROOT,
"RESULT concurrency=%d requests=%d errors=%d reconnects=%d windowMs=%d drainMs=%d drained=%b "
+ "qps=%.1f p50=%.2fms p95=%.2fms p99=%.2fms max=%.2fms mean=%.2fms%n",
concurrency, n, errors.get(), reconnects.get(), (windowEnd - windowStart) / 1_000_000L, drainMs,
drained, n * 1e9 / (windowEnd - windowStart),
pct(sorted, 50), pct(sorted, 95), pct(sorted, 99), pct(sorted, 100), mean(sorted));

if (dumpPath != null) {
try (java.io.PrintWriter w = new java.io.PrintWriter(dumpPath)) {
for (int i = 0; i < n; i++) {
w.println(samples[i]);
}
}
}
System.exit(0);
}

private static void worker(int port, byte[] request, CountDownLatch ready, CountDownLatch start,
CountDownLatch done, boolean[] measuring, boolean[] stopped,
long[] samples, AtomicInteger sampleIdx, AtomicLong errors,
AtomicLong reconnects, java.util.concurrent.atomic.AtomicReference<Exception> firstError) {
HttpConn conn = null;
boolean connected = false;
try {
conn = new HttpConn(port);
connected = true;
ready.countDown();
start.await();
while (!stopped[0]) {
long t0 = System.nanoTime();
try {
conn.exchange(request);
} catch (IOException e) {
errors.incrementAndGet();
conn.reopen();
reconnects.incrementAndGet();
continue;
}
long dt = System.nanoTime() - t0;
if (measuring[0]) {
int idx = sampleIdx.getAndIncrement();
if (idx < samples.length) {
samples[idx] = dt;
}
}
}
} catch (Exception e) {
errors.incrementAndGet();
firstError.compareAndSet(null, e);
} finally {
if (!connected) {
ready.countDown(); // 连不上的 worker 也必须交还 ready,否则主线程死等
}
if (conn != null) {
try {
conn.close();
} catch (IOException ignored) {
// 已经断开
}
}
done.countDown();
}
}

static class HttpConn implements AutoCloseable {
private final int port;
private Socket socket;
private InputStream in;
private OutputStream out;
private final byte[] buf = new byte[16384];
private int len;

HttpConn(int port) throws IOException {
this.port = port;
open();
}

void open() throws IOException {
socket = new Socket("127.0.0.1", port);
socket.setTcpNoDelay(true);
socket.setSoTimeout(120_000);
// 每个 rep 结束会一次性关掉几千条连接,不设 SO_LINGER(0) 的话这些端口要停在
// TIME_WAIT 里,跑几轮就会把临时端口耗光(BindException: Can't assign requested address)。
socket.setSoLinger(true, 0);
in = socket.getInputStream();
out = socket.getOutputStream();
len = 0;
}

void reopen() {
try {
socket.close();
} catch (IOException ignored) {
// 连接已断开
}
try {
open();
} catch (IOException e) {
throw new RuntimeException(e);
}
}

void exchange(byte[] request) throws IOException {
out.write(request);
out.flush();
int headerEnd;
while (true) {
headerEnd = indexOfCrlfCrlf(buf, len);
if (headerEnd >= 0) {
break;
}
if (len >= buf.length) {
throw new IOException("response header too large");
}
int r = in.read(buf, len, buf.length - len);
if (r < 0) {
throw new EOFException("connection closed");
}
len += r;
}
int contentLength = parseContentLength(buf, headerEnd);
int total = headerEnd + 4 + contentLength;
while (len < total) {
int r = in.read(buf, len, buf.length - len);
if (r < 0) {
throw new EOFException("connection closed in body");
}
len += r;
}
System.arraycopy(buf, total, buf, 0, len - total);
len -= total;
}

public void close() throws IOException {
socket.close();
}

private static int indexOfCrlfCrlf(byte[] b, int length) {
for (int i = 0; i + 3 < length; i++) {
if (b[i] == '\r' && b[i + 1] == '\n' && b[i + 2] == '\r' && b[i + 3] == '\n') {
return i;
}
}
return -1;
}

private static int parseContentLength(byte[] b, int headerEnd) throws IOException {
String head = new String(b, 0, headerEnd);
if (!head.startsWith("HTTP/1.1 200")) {
throw new IOException("unexpected status: " + head.lines().findFirst().orElse(""));
}
for (String line : head.split("\r\n")) {
if (line.regionMatches(true, 0, "Content-Length:", 0, 15)) {
return Integer.parseInt(line.substring(15).trim());
}
}
return 0;
}
}

private static double pct(long[] sorted, int p) {
if (sorted.length == 0) {
return Double.NaN;
}
int idx = (int) Math.ceil(p / 100.0 * sorted.length) - 1;
return sorted[Math.max(0, Math.min(idx, sorted.length - 1))] / 1e6;
}

private static double mean(long[] sorted) {
if (sorted.length == 0) {
return Double.NaN;
}
double sum = 0;
for (long v : sorted) {
sum += v;
}
return sum / sorted.length / 1e6;
}
}

几个刻意的设计:连接在统计窗口之前建好(ready 闩);每个 worker 串行发请求,所以并发连接数就是并发请求数,不会出现「一条连接里压了 N 个在途请求」这种很难解释的形态;连接出错时记录并重连,而不是让整个 run 悄悄降并发;SO_LINGER(0) 是压测脚本特有的处理,别抄进业务代码。

编译与运行:

1
2
3
4
5
6
7
8
9
10
11
12
javac -d classes *.java   # LoadServer / LoadClient / SleepUnderLoad / CpuBench

# 服务端(固定池 200)
java -Dsun.net.httpserver.maxIdleConnections=20000 -cp classes \
LoadServer --port=8100 --mode=platform --pool=200 --downstream-ms=50

# 服务端(每任务一条虚拟线程)
java -Dsun.net.httpserver.maxIdleConnections=20000 -cp classes \
LoadServer --port=8100 --mode=virtual --downstream-ms=50

# 压测端
java -Xss512k -cp classes LoadClient --port=8100 --concurrency=1000 --warmup=1000 --duration=5000

吞吐与延迟:固定线程池的膝盖在哪

四档并发下的曲线

每档都是预热 1 秒 + 统计窗口 5 秒,跑 3 次。原始输出(每个 tag 的三行是一个数据点的三次重复):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
--- platform/pool=200, concurrency=50
RESULT concurrency=50 requests=4670 errors=0 reconnects=0 windowMs=5002 drainMs=55 drained=true qps=933.6 p50=53.94ms p95=55.36ms p99=55.49ms max=60.50ms mean=53.54ms
RESULT concurrency=50 requests=4676 errors=0 reconnects=0 windowMs=5002 drainMs=50 drained=true qps=934.7 p50=53.60ms p95=55.34ms p99=55.43ms max=56.48ms mean=53.35ms
RESULT concurrency=50 requests=4678 errors=0 reconnects=0 windowMs=5004 drainMs=45 drained=true qps=934.7 p50=53.61ms p95=55.36ms p99=55.45ms max=56.48ms mean=53.39ms
--- virtual, concurrency=50
RESULT concurrency=50 requests=4550 errors=0 reconnects=0 windowMs=5005 drainMs=20 drained=true qps=909.1 p50=55.80ms p95=56.33ms p99=56.63ms max=57.07ms mean=55.05ms
RESULT concurrency=50 requests=4550 errors=0 reconnects=0 windowMs=5005 drainMs=3 drained=true qps=909.1 p50=55.77ms p95=56.51ms p99=56.92ms max=58.04ms mean=54.97ms
RESULT concurrency=50 requests=4550 errors=0 reconnects=0 windowMs=5002 drainMs=12 drained=true qps=909.5 p50=55.79ms p95=56.28ms p99=56.57ms max=57.20ms mean=54.95ms
--- platform/pool=200, concurrency=200
RESULT concurrency=200 requests=18878 errors=0 reconnects=0 windowMs=5004 drainMs=52 drained=true qps=3772.0 p50=52.89ms p95=55.28ms p99=55.53ms max=58.68ms mean=52.92ms
RESULT concurrency=200 requests=18844 errors=0 reconnects=0 windowMs=5002 drainMs=54 drained=true qps=3767.0 p50=53.04ms p95=55.29ms p99=55.48ms max=57.73ms mean=52.99ms
RESULT concurrency=200 requests=18908 errors=0 reconnects=0 windowMs=5003 drainMs=52 drained=true qps=3778.8 p50=53.04ms p95=55.32ms p99=55.88ms max=58.17ms mean=53.03ms
--- virtual, concurrency=200
RESULT concurrency=200 requests=17600 errors=0 reconnects=0 windowMs=5002 drainMs=11 drained=true qps=3518.4 p50=56.71ms p95=60.73ms p99=62.15ms max=63.64ms mean=56.42ms
RESULT concurrency=200 requests=16800 errors=0 reconnects=0 windowMs=5007 drainMs=35 drained=true qps=3355.2 p50=60.80ms p95=63.19ms p99=64.48ms max=66.17ms mean=59.55ms
RESULT concurrency=200 requests=16800 errors=0 reconnects=0 windowMs=5010 drainMs=50 drained=true qps=3353.2 p50=60.82ms p95=62.88ms p99=64.09ms max=66.34ms mean=59.85ms
--- platform/pool=200, concurrency=1000
RESULT concurrency=1000 requests=18342 errors=0 reconnects=0 windowMs=5001 drainMs=283 drained=true qps=3667.4 p50=273.18ms p95=281.42ms p99=284.77ms max=294.35ms mean=273.28ms
RESULT concurrency=1000 requests=18262 errors=0 reconnects=0 windowMs=5001 drainMs=271 drained=true qps=3651.5 p50=274.17ms p95=282.64ms p99=285.97ms max=292.20ms mean=274.16ms
RESULT concurrency=1000 requests=18337 errors=0 reconnects=0 windowMs=5009 drainMs=279 drained=true qps=3660.6 p50=273.08ms p95=281.18ms p99=284.28ms max=291.91ms mean=273.23ms
--- virtual, concurrency=1000
RESULT concurrency=1000 requests=81929 errors=0 reconnects=0 windowMs=5001 drainMs=50 drained=true qps=16379.3 p50=61.49ms p95=68.90ms p99=71.72ms max=79.49ms mean=61.36ms
RESULT concurrency=1000 requests=81666 errors=0 reconnects=0 windowMs=5006 drainMs=62 drained=true qps=16311.9 p50=61.81ms p95=67.95ms p99=71.67ms max=79.95ms mean=61.63ms
RESULT concurrency=1000 requests=83566 errors=0 reconnects=0 windowMs=5004 drainMs=65 drained=true qps=16697.3 p50=59.68ms p95=67.58ms p99=70.63ms max=78.69ms mean=59.81ms

5000 并发那一档客户端拆成两个进程,每个进程 2500 条连接,QPS 是两行之和:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--- platform/pool=200, concurrency=5000(两个压测进程,各 2500)
RESULT concurrency=2500 requests=9184 errors=0 reconnects=0 windowMs=5003 drainMs=1379 drained=true qps=1835.5 p50=1361.55ms p95=1381.24ms p99=1386.82ms max=1398.57ms mean=1329.51ms
RESULT concurrency=2500 requests=9137 errors=0 reconnects=0 windowMs=5007 drainMs=1384 drained=true qps=1824.8 p50=1361.45ms p95=1381.64ms p99=1387.44ms max=1393.79ms mean=1329.46ms
RESULT concurrency=2500 requests=9149 errors=0 reconnects=0 windowMs=5001 drainMs=1365 drained=true qps=1829.2 p50=1366.69ms p95=1380.61ms p99=1387.33ms max=1394.49ms mean=1337.38ms
RESULT concurrency=2500 requests=9133 errors=0 reconnects=0 windowMs=5008 drainMs=1369 drained=true qps=1823.6 p50=1366.66ms p95=1380.51ms p99=1386.25ms max=1398.21ms mean=1337.29ms
RESULT concurrency=2500 requests=9132 errors=0 reconnects=0 windowMs=5004 drainMs=1365 drained=true qps=1824.7 p50=1366.10ms p95=1383.35ms p99=1391.26ms max=1412.47ms mean=1333.87ms
RESULT concurrency=2500 requests=9174 errors=0 reconnects=0 windowMs=5001 drainMs=1361 drained=true qps=1834.2 p50=1365.99ms p95=1383.24ms p99=1391.19ms max=1410.32ms mean=1334.83ms
--- virtual, concurrency=5000(两个压测进程,各 2500)
RESULT concurrency=2500 requests=119790 errors=0 reconnects=0 windowMs=5005 drainMs=107 drained=true qps=23930.9 p50=105.04ms p95=150.71ms p99=178.65ms max=203.60ms mean=105.59ms
RESULT concurrency=2500 requests=118448 errors=0 reconnects=0 windowMs=5003 drainMs=141 drained=true qps=23675.3 p50=105.75ms p95=151.62ms p99=180.36ms max=219.91ms mean=106.13ms
RESULT concurrency=2500 requests=128637 errors=0 reconnects=0 windowMs=5006 drainMs=120 drained=true qps=25694.5 p50=98.88ms p95=139.16ms p99=162.53ms max=262.58ms mean=97.47ms
RESULT concurrency=2500 requests=126257 errors=0 reconnects=0 windowMs=5009 drainMs=123 drained=true qps=25203.9 p50=99.52ms p95=143.91ms p99=214.31ms max=272.18ms mean=99.43ms
RESULT concurrency=2500 requests=135116 errors=0 reconnects=0 windowMs=5005 drainMs=171 drained=true qps=26995.9 p50=94.43ms p95=135.02ms p99=157.60ms max=223.00ms mean=92.64ms
RESULT concurrency=2500 requests=135914 errors=0 reconnects=0 windowMs=5010 drainMs=146 drained=true qps=27128.0 p50=92.92ms p95=134.33ms p99=161.90ms max=228.41ms mean=92.10ms

三次取中位数,得到这张表(requests 是单次运行 5 秒窗口内完成的请求数,5000 那一档是两个进程之和;延迟分位是三次中的中位数,5000 那一档由两份原始样本合并后重算):

并发 执行器 QPS requests p50 p95 p99 窗口后收尾 drainMs
50 固定池 200 935 4676 53.61ms 55.36ms 55.45ms 50
50 虚拟线程 909 4550 55.79ms 56.33ms 56.63ms 12
200 固定池 200 3772 18878 53.04ms 55.29ms 55.53ms 52
200 虚拟线程 3355 16800 60.80ms 62.88ms 64.09ms 35
1000 固定池 200 3661 18337 273.18ms 281.42ms 284.77ms 279
1000 虚拟线程 16379 81929 61.49ms 67.95ms 71.72ms 62
5000 固定池 200 3659 18306 1366.06ms 1381.47ms 1387.07ms 1369
5000 虚拟线程 50898 254894 99.19ms 141.56ms 179.66ms 141

膝盖的形状很干净:

  • 固定池在并发 200(正好等于池大小)时达到 3772 QPS,之后完全不再增长——1000 并发 3661 QPS,5000 并发 3659 QPS,与上面用 sleep 精度算出来的理论上限 3710 QPS 基本重合。多出来的并发不会变成吞吐,只会变成排队:p50 从 53ms 涨到 273ms、1366ms,drainMs 也从 52ms 涨到 1369ms(窗口结束后队列里还压着 4800 个请求要排)。
  • 虚拟线程在 1000 并发时就已经是固定池的 4.5 倍(16379 vs 3661),5000 并发时是 13.9 倍(50898 vs 3659),而且 p50 只有 99ms,没有任何队列堆积(drain 141ms)。

用 Little 定律对一遍:QPS × p50 ≈ 并发。5000 并发虚拟线程:50898 × 0.0992 = 5049;5000 并发固定池:3659 × 1.366 = 4998。两组都对得上,说明测的就是稳态而不是启动瞬间。

低并发下虚拟线程反而是慢的

并发 50 时 935 vs 909(低 2.8%),并发 200 时 3772 vs 3355(低 11%),延迟也高出 2~8ms。三次重复之间很稳定,说明这不是噪声:固定池在这个并发区间里线程数刚好够用,虚拟线程只是多付了一层挂载/卸载与调度开销

挂载/卸载只是其中一部分,剩下的大头在阻塞唤醒路径上。把 HTTP 层完全剥掉,只让 200 个并发任务各自 Thread.sleep(50) 100 轮(各跑 3 次),用几十行的小程序就能测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import java.util.Arrays;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

/**
* 隔离实验:同样 200 个并发「任务」,每轮 Thread.sleep(50),比较平台线程与虚拟线程
* 实际睡够的时长。用来解释压测里虚拟线程那一侧多出来的延迟是否来自 sleep 唤醒路径。
*
* 用法:java SleepUnderLoad <platform|virtual> [threads] [iterations] [sleepMs]
*/
public class SleepUnderLoad {

public static void main(String[] args) throws Exception {
String mode = args[0];
int threads = args.length > 1 ? Integer.parseInt(args[1]) : 200;
int iterations = args.length > 2 ? Integer.parseInt(args[2]) : 100;
int sleepMs = args.length > 3 ? Integer.parseInt(args[3]) : 50;

long[] samples = new long[threads * iterations];
CountDownLatch done = new CountDownLatch(threads);
AtomicInteger slot = new AtomicInteger();
Runnable task = () -> {
int base = slot.getAndIncrement() * iterations;
for (int i = 0; i < iterations; i++) {
long t0 = System.nanoTime();
try {
Thread.sleep(sleepMs);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
samples[base + i] = System.nanoTime() - t0;
}
done.countDown();
};

if (mode.equals("virtual")) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < threads; i++) {
executor.submit(task);
}
done.await();
}
} else {
var executor = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
executor.submit(task);
}
done.await();
executor.shutdown();
}

Arrays.sort(samples);
System.out.printf(Locale.ROOT,
"SLEEP mode=%s threads=%d iterations=%d sleepMs=%d p50=%.3fms p95=%.3fms p99=%.3fms max=%.3fms%n",
mode, threads, iterations, sleepMs,
samples[samples.length / 2] / 1e6,
samples[(int) (samples.length * 0.95)] / 1e6,
samples[(int) (samples.length * 0.99)] / 1e6,
samples[samples.length - 1] / 1e6);
System.exit(0);
}
}
1
2
3
4
5
6
7
8
$ java -Xss256k -cp classes SleepUnderLoad platform 200 100 50
SLEEP mode=platform threads=200 iterations=100 sleepMs=50 p50=53.818ms p95=55.052ms p99=55.119ms max=55.944ms
SLEEP mode=platform threads=200 iterations=100 sleepMs=50 p50=53.705ms p95=55.050ms p99=55.094ms max=55.257ms
SLEEP mode=platform threads=200 iterations=100 sleepMs=50 p50=53.767ms p95=55.048ms p99=55.090ms max=55.246ms
$ java -Xss256k -cp classes SleepUnderLoad virtual 200 100 50
SLEEP mode=virtual threads=200 iterations=100 sleepMs=50 p50=55.032ms p95=55.161ms p99=55.752ms max=57.049ms
SLEEP mode=virtual threads=200 iterations=100 sleepMs=50 p50=55.061ms p95=55.164ms p99=56.086ms max=56.616ms
SLEEP mode=virtual threads=200 iterations=100 sleepMs=50 p50=55.068ms p95=55.169ms p99=55.959ms max=56.794ms

同样的「睡 50ms 再被唤醒」,虚拟线程要比平台线程晚 1.3ms 左右,而且 p95 从 55.05ms 抬到 55.16ms——唤醒路径更贵一点点。这个数字对机器负载很敏感(同一份代码在负载更高时重跑,两者的 p50 分别会涨到 56.8ms 与 60.0ms),所以只把它当方向性的证据:虚拟线程的阻塞唤醒比平台线程略贵,量级在 1~4ms,与压测里的 2~8ms 差距同向。

结论:在低并发、线程池还有余量的场景里,迁移过去是负收益,延迟和吞吐都会退一点。迁移的收益来自并发数超过线程池容量之后,收益的大小取决于原来的池有多小、下游有多慢。

JFR:一次录制看到的东西

按默认 profile 录,事件计数是 0

在虚拟线程组上跑 5000 并发(两个客户端进程各 2500),服务端带 JFR 录制:

1
2
3
4
5
6
java -Dsun.net.httpserver.maxIdleConnections=20000 \
-XX:StartFlightRecording=filename=/tmp/vt.jfr,settings=profile \
-cp classes LoadServer --port=8400 --mode=virtual --downstream-ms=50

# 客户端跑完后停录制
jcmd <pid> JFR.stop

这一轮压测客户端(同样是两个进程各 2500 并发):

1
2
RESULT concurrency=2500 requests=130832 errors=0 reconnects=0 windowMs=5007 drainMs=168 drained=true qps=26125.3 p50=94.59ms p95=157.20ms p99=190.52ms max=343.00ms mean=96.22ms
RESULT concurrency=2500 requests=130593 errors=0 reconnects=0 windowMs=5011 drainMs=161 drained=true qps=26060.6 p50=95.09ms p95=156.82ms p99=198.53ms max=342.46ms mean=96.67ms

jfr summary 的输出(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 Version: 2.1
Chunks: 1
Start: 2026-09-15 07:27:10 (UTC)
Duration: 10 s

Event Type Count Size (bytes)
=============================================================
jdk.ThreadSleep 286483 5999413
jdk.GCPhaseParallel 6203 168654
jdk.PromoteObjectInNewPLAB 2316 43350
jdk.ObjectAllocationSample 1998 33267
jdk.NativeLibrary 1047 92661
jdk.SocketWrite 979 41477
jdk.SystemProcess 574 63271
jdk.SocketRead 557 24713
jdk.ExecutionSample 532 6510
jdk.ModuleExport 508 5444
jdk.BooleanFlag 481 14760
jdk.ActiveSetting 376 9700
jdk.NativeMethodSample 348 3889
jdk.ThreadPark 346 12890
...(中间省略了一百多个事件类型)
jdk.VirtualThreadEnd 0 0
jdk.VirtualThreadPinned 0 0
jdk.VirtualThreadStart 0 0

录制是有效的(jdk.ThreadSleep 286483 次、jdk.SocketRead/jdk.SocketWrite 都有数据),但 jdk.VirtualThreadStartjdk.VirtualThreadEndjdk.VirtualThreadPinned 全是 0——这三个事件在 profile 配置里默认关闭,因为一个高吞吐服务每秒可能创建几十万条虚拟线程,打开它们会让录制文件爆炸。所以「迁到虚拟线程后用 JFR 看创建量」这一步,先得把事件打开。

打开事件之后

jfr configure 基于 profile.jfc 生成一份打开这三个事件的配置,再录一遍:

1
2
jfr configure --input $JAVA_HOME/lib/jfr/profile.jfc --output /tmp/vtbench/profile-vt.jfc \
jdk.VirtualThreadStart#enabled=true jdk.VirtualThreadEnd#enabled=true jdk.VirtualThreadPinned#enabled=true

同一档负载,同样的录制方式。这一轮客户端的统计:

1
2
RESULT concurrency=2500 requests=134081 errors=0 reconnects=0 windowMs=5010 drainMs=181 drained=true qps=26761.5 p50=88.81ms p95=153.01ms p99=203.21ms max=318.62ms mean=93.93ms
RESULT concurrency=2500 requests=132218 errors=0 reconnects=0 windowMs=5007 drainMs=174 drained=true qps=26406.4 p50=89.74ms p95=160.49ms p99=199.54ms max=307.61ms mean=95.32ms

jfr summary(节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
 Version: 2.1
Chunks: 2
Start: 2026-09-15 07:28:12 (UTC)
Duration: 10 s

Event Type Count Size (bytes)
=============================================================
jdk.VirtualThreadEnd 299608 4161670
jdk.VirtualThreadStart 299608 4759454
jdk.ThreadSleep 294608 6170106
jdk.GCPhaseParallel 7100 193422
jdk.PromoteObjectInNewPLAB 2507 46953
jdk.NativeLibrary 2095 184920
jdk.ObjectAllocationSample 2025 33962
jdk.SystemProcess 1038 114373
jdk.ModuleExport 1016 10876
jdk.BooleanFlag 962 29039
jdk.SocketWrite 871 36785
jdk.ActiveSetting 752 19020
jdk.SocketRead 596 26373
jdk.ExecutionSample 565 7030
jdk.TenuringDistribution 390 3984
jdk.NativeMethodSample 363 4095
jdk.Checkpoint 327 3676478
jdk.ModuleRequire 312 3120
jdk.ThreadPark 283 10626
...(中间省略)
jdk.VirtualThreadPinned 0 0
jdk.VirtualThreadSubmitFailed 0 0

计数说明的问题:

  • jdk.VirtualThreadStartjdk.VirtualThreadEnd 都是 299608。这次录制覆盖了 1 秒预热 + 5 秒统计窗口,窗口内两个客户端加起来完成了 266299 个请求,加上预热阶段的请求与少量收尾请求,与 30 万条虚拟线程对得上——一个请求一条虚拟线程,没有任何复用。这正是「不要池化虚拟线程」想要的效果,也是它的代价(每次请求一次创建/销毁)。
  • jdk.VirtualThreadPinned0。这不是事件没打开(这次它明明开着),而是 JDK 24 起 JEP 491(Status: Closed / Delivered,Release: 24)改变了 synchronized 的实现,虚拟线程在 synchronized 方法/语句里阻塞不再固定载体,所以「在锁里做阻塞 IO 导致固定」这个 JDK 21~23 上最常见的场景在 JDK 25 上消失了。仍然会固定载体的只剩:native 方法或 FFM 回调里再调回 Java 并阻塞、类加载与类初始化的若干场景。用 jfr view pinned-threads 单独看也是空的:
1
2
$ jfr view pinned-threads /tmp/vt-vtenabled.jfr
No events found for 'Pinned Virtual Threads'.

-Djdk.tracePinnedThreads 已在 JDK 24 随 JEP 491 移除,现在设了也没有输出——排固定问题只能靠 JFR 的 jdk.VirtualThreadPinned(默认阈值 20ms,需要显式打开)。

平台线程数

同一份录制里看线程维度(jfr view --width 120 thread-count,这里的计数只含平台线程):

1
2
3
4
5
6
7
8
9
10
11
12
13
                                                  Java Thread Statistics

Time Active Threads Daemon Threads Accumulated Threads Peak Threads
------------------ ------------------------ ------------------------ ----------------------------- ----------------------
15:28:13 10 8 10 10
15:28:14 10 8 10 10
15:28:15 10 8 10 10
15:28:16 22 20 22 22
15:28:17 22 20 22 22
15:28:18 22 20 22 22
15:28:19 22 20 22 22
15:28:20 22 20 22 22
15:28:21 22 20 22 22

整个服务端进程的峰值平台线程数只有 22 条(还包含 GC、JIT、Attach 这些),却在这一档里维持了 5000 条并发连接、完成了 266299 个请求(创建了 299608 条虚拟线程)。换成固定 200 的池,为了撑住 5000 并发你得先把池调到 5000,而上面已经量过:这台机器单进程创建到 4070 条就失败了。

载体线程调参

虚拟线程的调度器是一个 FIFO 模式的 work-stealing ForkJoinPool,它的 parallelism 默认等于可用核数(这台机器是 10),可以用 -Djdk.virtualThreadScheduler.parallelism 覆盖。下面用 2 / 4 / 10 三档分别跑同一档负载。

纯阻塞负载:载体数几乎不重要

第一个负载是前面那个「handler 只 sleep(50)、几乎不占 CPU」的服务,5000 并发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
--- parallelism=2
RESULT concurrency=2500 requests=136159 errors=0 reconnects=0 windowMs=5004 drainMs=223 drained=true qps=27209.1 p50=87.00ms p95=147.88ms p99=160.19ms max=212.06ms mean=92.15ms
RESULT concurrency=2500 requests=136007 errors=0 reconnects=0 windowMs=5000 drainMs=252 drained=true qps=27198.4 p50=86.77ms p95=147.47ms p99=160.42ms max=212.23ms mean=92.17ms
RESULT concurrency=2500 requests=142893 errors=0 reconnects=0 windowMs=5001 drainMs=107 drained=true qps=28570.5 p50=82.32ms p95=139.67ms p99=169.51ms max=201.13ms mean=86.71ms
RESULT concurrency=2500 requests=144269 errors=0 reconnects=0 windowMs=5018 drainMs=202 drained=true qps=28747.6 p50=82.53ms p95=140.08ms p99=169.34ms max=202.58ms mean=87.05ms
RESULT concurrency=2500 requests=150614 errors=0 reconnects=0 windowMs=5002 drainMs=98 drained=true qps=30105.1 p50=77.09ms p95=137.85ms p99=159.43ms max=206.70ms mean=83.43ms
RESULT concurrency=2500 requests=150540 errors=0 reconnects=0 windowMs=5009 drainMs=208 drained=true qps=30048.6 p50=77.04ms p95=138.49ms p99=165.08ms max=207.16ms mean=83.43ms
--- parallelism=4
RESULT concurrency=2500 requests=156650 errors=0 reconnects=0 windowMs=5004 drainMs=119 drained=true qps=31304.9 p50=71.38ms p95=123.11ms p99=155.36ms max=335.83ms mean=79.63ms
RESULT concurrency=2500 requests=155427 errors=0 reconnects=0 windowMs=5004 drainMs=116 drained=true qps=31056.4 p50=72.60ms p95=123.37ms p99=153.31ms max=357.68ms mean=80.20ms
RESULT concurrency=2500 requests=143271 errors=0 reconnects=0 windowMs=5009 drainMs=123 drained=true qps=28598.9 p50=83.52ms p95=144.74ms p99=202.28ms max=595.88ms mean=89.33ms
RESULT concurrency=2500 requests=143265 errors=0 reconnects=0 windowMs=5005 drainMs=104 drained=true qps=28619.9 p50=83.48ms p95=143.58ms p99=200.73ms max=247.99ms mean=88.17ms
RESULT concurrency=2500 requests=141835 errors=0 reconnects=0 windowMs=5015 drainMs=210 drained=true qps=28277.2 p50=87.03ms p95=139.82ms p99=196.05ms max=475.51ms mean=88.91ms
RESULT concurrency=2500 requests=143882 errors=0 reconnects=0 windowMs=5005 drainMs=193 drained=true qps=28746.5 p50=87.11ms p95=134.56ms p99=162.57ms max=503.11ms mean=87.62ms
--- parallelism=10(默认)
RESULT concurrency=2500 requests=124360 errors=0 reconnects=0 windowMs=5006 drainMs=263 drained=true qps=24838.7 p50=103.51ms p95=145.41ms p99=177.85ms max=248.48ms mean=101.44ms
RESULT concurrency=2500 requests=126191 errors=0 reconnects=0 windowMs=5003 drainMs=138 drained=true qps=25222.9 p50=102.32ms p95=136.91ms p99=173.63ms max=239.52ms mean=98.94ms
RESULT concurrency=2500 requests=127351 errors=0 reconnects=0 windowMs=5016 drainMs=147 drained=true qps=25384.5 p50=102.61ms p95=143.29ms p99=173.25ms max=290.83ms mean=99.42ms
RESULT concurrency=2500 requests=128031 errors=0 reconnects=0 windowMs=5005 drainMs=150 drained=true qps=25578.3 p50=101.49ms p95=134.64ms p99=161.35ms max=278.13ms mean=97.54ms
RESULT concurrency=2500 requests=131168 errors=0 reconnects=0 windowMs=5010 drainMs=119 drained=true qps=26178.6 p50=96.61ms p95=141.69ms p99=170.93ms max=264.25ms mean=94.82ms
RESULT concurrency=2500 requests=133853 errors=0 reconnects=0 windowMs=5006 drainMs=178 drained=true qps=26738.3 p50=97.26ms p95=136.73ms p99=170.09ms max=263.00ms mean=94.29ms
parallelism QPS requests(两个进程之和) p50 p95 p99
2 57318 287162 82.43ms 139.83ms 161.78ms
4 57219 286536 83.50ms 136.98ms 175.11ms
10(默认) 50963 255382 102.04ms 140.31ms 170.56ms

2 条载体跑 5000 并发跟 10 条载体的吞吐是一个量级,甚至更高。原因很直接:Thread.sleep 会让虚拟线程卸载,载体在等待期间是空闲的,一个纯阻塞的负载几乎不产生「需要载体去执行」的时刻,所以载体数在这个负载上根本不是资源。至于为什么 10 反而更低,是因为压测客户端也在同一台 10 核机器上:服务端把 10 个核都占住时,客户端自己的收发与解析开始被挤,端到端延迟从 82ms 升到 102ms——单机压测的常见串扰,不是虚拟线程的问题。真要做精确的载体对比,客户端应该放到另一台机器上。

CPU 占比上去之后:载体数就是吞吐

把负载换掉:handler 不睡,改为每个请求烧 10ms CPU(--downstream-ms=0 --cpu-micros=10000),并发 100,仍然三档载体数:

1
2
3
4
5
6
7
8
9
10
11
12
--- parallelism=2
RESULT concurrency=100 requests=982 errors=0 reconnects=0 windowMs=5010 drainMs=502 drained=true qps=196.0 p50=507.67ms p95=517.48ms p99=628.75ms max=873.22ms mean=509.04ms
RESULT concurrency=100 requests=982 errors=0 reconnects=0 windowMs=5006 drainMs=509 drained=true qps=196.2 p50=497.00ms p95=672.49ms p99=1734.37ms max=2220.83ms mean=465.69ms
RESULT concurrency=100 requests=980 errors=0 reconnects=0 windowMs=5000 drainMs=502 drained=true qps=196.0 p50=507.50ms p95=517.87ms p99=700.22ms max=842.74ms mean=509.03ms
--- parallelism=4
RESULT concurrency=100 requests=1969 errors=0 reconnects=0 windowMs=5001 drainMs=250 drained=true qps=393.7 p50=244.28ms p95=415.43ms p99=497.50ms max=608.29ms mean=254.87ms
RESULT concurrency=100 requests=1976 errors=0 reconnects=0 windowMs=5009 drainMs=253 drained=true qps=394.4 p50=244.09ms p95=395.00ms p99=517.69ms max=689.32ms mean=253.87ms
RESULT concurrency=100 requests=1972 errors=0 reconnects=0 windowMs=5009 drainMs=245 drained=true qps=393.6 p50=243.94ms p95=404.71ms p99=506.98ms max=687.25ms mean=255.08ms
--- parallelism=10
RESULT concurrency=100 requests=4643 errors=0 reconnects=0 windowMs=5007 drainMs=106 drained=true qps=927.3 p50=101.97ms p95=196.76ms p99=446.49ms max=570.57ms mean=107.51ms
RESULT concurrency=100 requests=4656 errors=0 reconnects=0 windowMs=5001 drainMs=112 drained=true qps=930.9 p50=100.76ms p95=228.22ms p99=736.68ms max=1296.91ms mean=111.31ms
RESULT concurrency=100 requests=4688 errors=0 reconnects=0 windowMs=5001 drainMs=108 drained=true qps=937.3 p50=101.79ms p95=179.05ms p99=652.14ms max=896.78ms mean=106.69ms
parallelism QPS 理论值 parallelism / 10ms p50
2 196 200 507.50ms
4 394 400 244.09ms
10 931 1000 101.79ms

这里就是严格线性:2 条载体 196 QPS,4 条 394 QPS,10 条 931 QPS,每一条载体每秒稳定消化 100 个 10ms 的请求。10 那一档比理论值低 7%,因为服务端和压测客户端在抢同一台 10 核机器剩下的那点 CPU。

每请求到底占多少 CPU

要讨论「下游阻塞占比」,得先知道两种 handler 各自消耗多少 CPU。给服务端加一个处理器计数和进程 CPU 时间统计(OperatingSystemMXBean.getProcessCpuTime(),在 SIGTERM 的 shutdown hook 里打印,并扣掉 server.start() 之前那一段启动 CPU),两种 handler 各跑两轮(并发 1000 / 100,其余口径不变):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=== 阻塞型 handler(并发 1000)===
SERVER mode=virtual pool=200 downstreamMs=50 cpuMicros=0 port=8101 parallelism=-1
STATS requests=104000 processCpuMs=8068 cpuAfterStartMs=7979 cpuUsPerRequest=76.7
RESULT concurrency=1000 requests=88000 errors=0 reconnects=0 windowMs=5002 drainMs=35 drained=true qps=17589.6 p50=56.78ms p95=60.11ms p99=69.67ms max=85.68ms mean=56.69ms
=== 阻塞型 handler 第二轮 ===
SERVER mode=virtual pool=200 downstreamMs=50 cpuMicros=0 port=8101 parallelism=-1
STATS requests=104000 processCpuMs=7282 cpuAfterStartMs=7194 cpuUsPerRequest=69.2
RESULT concurrency=1000 requests=87000 errors=0 reconnects=0 windowMs=5005 drainMs=33 drained=true qps=17382.4 p50=57.45ms p95=59.76ms p99=60.84ms max=65.57ms mean=57.33ms
=== CPU 型 handler(并发 100)===
SERVER mode=virtual pool=200 downstreamMs=0 cpuMicros=10000 port=8101 parallelism=-1
STATS requests=5762 processCpuMs=49560 cpuAfterStartMs=49474 cpuUsPerRequest=8586.4
RESULT concurrency=100 requests=4778 errors=0 reconnects=0 windowMs=5003 drainMs=109 drained=true qps=954.9 p50=103.22ms p95=155.41ms p99=186.37ms max=540.17ms mean=104.85ms
=== CPU 型 handler 第二轮 ===
SERVER mode=virtual pool=200 downstreamMs=0 cpuMicros=10000 port=8101 parallelism=-1
STATS requests=5721 processCpuMs=48989 cpuAfterStartMs=48900 cpuUsPerRequest=8547.6
RESULT concurrency=100 requests=4738 errors=0 reconnects=0 windowMs=5005 drainMs=100 drained=true qps=946.6 p50=103.58ms p95=146.97ms p99=210.66ms max=428.11ms mean=105.61ms
  • 阻塞型 handler:进程口径每请求 6977µs CPU,而墙钟服务时间是 5457ms,CPU 占比约 0.13%。也就是说被压测的服务真的是「99.9% 时间在等」。
  • CPU 型 handler:handler 里按墙钟烧 10ms,进程口径实测 8.5ms/请求,差额是线程被抢占后墙钟流逝但没拿到 CPU 的部分。

注意这个数字是全进程口径,包含 HTTP 层的 accept、读请求、写响应,其中不少跑在 HttpServer 自己的 dispatcher 线程上,并不都在载体线程上执行。所以它能回答「这个服务的 CPU 占比有多低」,但不能直接除以单请求 CPU 反推载体数量——那个问题由实验回答更好:2 条载体就能把 5000 并发跑到 57k QPS,说明纯阻塞负载下需要载体的并发数远小于并发本身。

两个实验放在一起看:

负载形态 每请求 CPU(进程口径) 墙钟服务时间 需要载体的并发 载体数 2→10 的吞吐变化
阻塞 50ms(5000 并发) 69~77µs 54~57ms 很少(2 条载体已经够) 57.3k → 51.0k,基本不变
烧 CPU 10ms(100 并发) 8548~8586µs ~10ms 接近 100(并发 × CPU 占比 ≈ 1) 196 → 931,随载体数线性放大

规律是:需要载体的并发数约等于「并发 × CPU 时间占比」。阻塞型服务里这个数很小(0.13% × 5000 ≈ 6.5),默认 parallelism(=核数)绰绰有余,调它是在解决一个不存在的问题;只有当 handler 自己烧 CPU、或者存在不会卸载的阻塞(native 调用、FFM 回调、类初始化等固定场景)时,载体数才会变成吞吐上限。

CPU 密集反例:只买规模不买速度

反例实测

把 HTTP 那一层去掉,直接比较执行器本身。任务是一个定量的整数哈希循环(work 定义工作量,work=0 就是空任务),10 核机器上分别用固定池 10 与虚拟线程跑 10 个任务和 100 个任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;

/**
* CPU 密集任务对照:同一个计算任务分别交给平台线程池与虚拟线程执行器。
* 用法:java CpuBench --mode=platform|virtual|virtual-fixed --pool=N --tasks=M --work=W(W=0 即 nop 基准)
*/
public class CpuBench {

public static void main(String[] args) throws Exception {
String mode = "virtual";
int pool = 10;
int tasks = 10;
int work = 100_000;

for (String arg : args) {
String[] kv = arg.split("=", 2);
switch (kv[0]) {
case "--mode" -> mode = kv[1];
case "--pool" -> pool = Integer.parseInt(kv[1]);
case "--tasks" -> tasks = Integer.parseInt(kv[1]);
case "--work" -> work = Integer.parseInt(kv[1]);
default -> throw new IllegalArgumentException("unknown arg " + arg);
}
}

ExecutorService executor = switch (mode) {
case "platform" -> Executors.newFixedThreadPool(pool);
case "virtual" -> Executors.newVirtualThreadPerTaskExecutor();
case "virtual-fixed" ->
Executors.newFixedThreadPool(pool, Thread.ofVirtual().name("vt-", 0).factory());
default -> throw new IllegalArgumentException(mode);
};

AtomicLong sink = new AtomicLong();
final int workUnits = work;
long t0 = System.nanoTime();
for (int i = 0; i < tasks; i++) {
final int seed = i;
executor.execute(() -> sink.addAndGet(compute(workUnits, seed)));
}
if (executor instanceof ThreadPoolExecutor tpe) {
tpe.shutdown();
tpe.awaitTermination(1, TimeUnit.HOURS);
} else {
executor.close();
}
long elapsedMs = (System.nanoTime() - t0) / 1_000_000;

System.out.printf(Locale.ROOT,
"CPU mode=%s pool=%d tasks=%d work=%d elapsedMs=%d totalCpuS=%.2f nsPerTask=%.0f sink=%d%n",
mode, pool, tasks, work, elapsedMs,
java.lang.management.ManagementFactory.getOperatingSystemMXBean()
instanceof com.sun.management.OperatingSystemMXBean os ? os.getProcessCpuTime() / 1e9 : -1,
elapsedMs * 1e6 / tasks, sink.get());
System.exit(0);
}

private static long compute(int work, int seed) {
long acc = seed + 1;
for (int i = 0; i < work; i++) {
acc = acc * 6364136223846793005L + 1442695040888963407L;
acc ^= acc >>> 29;
}
return acc;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 任务数 10,work=0(nop 基准)
CPU mode=platform pool=10 tasks=10 work=0 elapsedMs=5 totalCpuS=0.06 nsPerTask=500000 sink=55
CPU mode=platform pool=10 tasks=10 work=0 elapsedMs=5 totalCpuS=0.06 nsPerTask=500000 sink=55
CPU mode=platform pool=10 tasks=10 work=0 elapsedMs=5 totalCpuS=0.06 nsPerTask=500000 sink=55
CPU mode=virtual pool=10 tasks=10 work=0 elapsedMs=9 totalCpuS=0.07 nsPerTask=900000 sink=55
CPU mode=virtual pool=10 tasks=10 work=0 elapsedMs=10 totalCpuS=0.07 nsPerTask=1000000 sink=55
CPU mode=virtual pool=10 tasks=10 work=0 elapsedMs=9 totalCpuS=0.07 nsPerTask=900000 sink=55
# 任务数 10,每个任务 5×10^7 次哈希(约 85ms CPU)
CPU mode=platform pool=10 tasks=10 work=50000000 elapsedMs=153 totalCpuS=0.99 nsPerTask=15300000 sink=-2315427775732427116
CPU mode=platform pool=10 tasks=10 work=50000000 elapsedMs=133 totalCpuS=1.01 nsPerTask=13300000 sink=-2315427775732427116
CPU mode=platform pool=10 tasks=10 work=50000000 elapsedMs=130 totalCpuS=0.96 nsPerTask=13000000 sink=-2315427775732427116
CPU mode=virtual pool=10 tasks=10 work=50000000 elapsedMs=139 totalCpuS=1.02 nsPerTask=13900000 sink=-2315427775732427116
CPU mode=virtual pool=10 tasks=10 work=50000000 elapsedMs=148 totalCpuS=0.97 nsPerTask=14800000 sink=-2315427775732427116
CPU mode=virtual pool=10 tasks=10 work=50000000 elapsedMs=144 totalCpuS=1.00 nsPerTask=14400000 sink=-2315427775732427116
# 任务数 100(10 倍超订),work=0(nop 基准)
CPU mode=platform pool=10 tasks=100 work=0 elapsedMs=6 totalCpuS=0.06 nsPerTask=60000 sink=5050
CPU mode=platform pool=10 tasks=100 work=0 elapsedMs=5 totalCpuS=0.06 nsPerTask=50000 sink=5050
CPU mode=platform pool=10 tasks=100 work=0 elapsedMs=5 totalCpuS=0.06 nsPerTask=50000 sink=5050
CPU mode=virtual pool=10 tasks=100 work=0 elapsedMs=14 totalCpuS=0.09 nsPerTask=140000 sink=5050
CPU mode=virtual pool=10 tasks=100 work=0 elapsedMs=10 totalCpuS=0.08 nsPerTask=100000 sink=5050
CPU mode=virtual pool=10 tasks=100 work=0 elapsedMs=11 totalCpuS=0.08 nsPerTask=110000 sink=5050
# 任务数 100(10 倍超订),每个任务 5×10^7 次哈希
CPU mode=platform pool=10 tasks=100 work=50000000 elapsedMs=1262 totalCpuS=8.88 nsPerTask=12620000 sink=-2751234398409375653
CPU mode=platform pool=10 tasks=100 work=50000000 elapsedMs=1245 totalCpuS=8.88 nsPerTask=12450000 sink=-2751234398409375653
CPU mode=platform pool=10 tasks=100 work=50000000 elapsedMs=1270 totalCpuS=8.90 nsPerTask=12700000 sink=-2751234398409375653
CPU mode=virtual pool=10 tasks=100 work=50000000 elapsedMs=1294 totalCpuS=8.99 nsPerTask=12940000 sink=-2751234398409375653
CPU mode=virtual pool=10 tasks=100 work=50000000 elapsedMs=1245 totalCpuS=8.94 nsPerTask=12450000 sink=-2751234398409375653
CPU mode=virtual pool=10 tasks=100 work=50000000 elapsedMs=1247 totalCpuS=8.95 nsPerTask=12470000 sink=-2751234398409375653
任务数 每个任务 CPU 工作量 平台线程池(10) 虚拟线程 nop 基准(同任务数)
10 5×10⁷ 次哈希 ≈ 85ms 133ms 144ms 5ms / 9ms
100 5×10⁷ 次哈希 ≈ 85ms 1262ms 1247ms 5ms / 11ms

(每格都是三次运行的中位数;totalCpuS 是进程 CPU 时间,两边的总 CPU 开销都是 8.9 秒左右,说明做的是同一份功。)

结论要按数据说话:

  • 10 个任务时虚拟线程 144ms 对平台池 133ms,慢 8%;100 个任务时 1247ms 对 1262ms,反而快 1%。
  • nop 基准量出的是测量本身的噪声底:10 个空任务 5ms vs 9ms,100 个空任务 5ms vs 11ms。两者在 CPU 密集场景下的耗时差都落在这个噪声底之内;没有任何一档能看出「虚拟线程更快」或「虚拟线程慢得多」。这恰好就是理论预期:10 核机器上 10 个并行任务,无论线程怎么实现,能用的就是 10 个核。

所以「虚拟线程只买规模不买速度」这句话的精确版本是:它不改变单任务的执行速度,只在并发任务数远大于线程池容量、且任务大部分时间在等待时把吞吐抬起来。CPU 密集任务继续用固定池(大小≈核数)或者并行流即可。

任务创建开销

把任务数抬到 20 万个空任务,就只剩「创建/调度一个任务」的成本:

1
2
3
4
5
6
7
# 20 万个空任务,pool=200
CPU mode=platform pool=200 tasks=200000 work=0 elapsedMs=82 totalCpuS=0.38 nsPerTask=410 sink=20000100000
CPU mode=platform pool=200 tasks=200000 work=0 elapsedMs=93 totalCpuS=0.41 nsPerTask=465 sink=20000100000
CPU mode=platform pool=200 tasks=200000 work=0 elapsedMs=85 totalCpuS=0.35 nsPerTask=425 sink=20000100000
CPU mode=virtual pool=200 tasks=200000 work=0 elapsedMs=207 totalCpuS=1.33 nsPerTask=1035 sink=20000100000
CPU mode=virtual pool=200 tasks=200000 work=0 elapsedMs=252 totalCpuS=1.79 nsPerTask=1260 sink=20000100000
CPU mode=virtual pool=200 tasks=200000 work=0 elapsedMs=219 totalCpuS=1.52 nsPerTask=1095 sink=20000100000
执行器 20 万空任务耗时 单任务开销
平台线程池(200) 85ms 410~465ns
每任务一条虚拟线程 219ms 1035~1260ns

虚拟线程「创建一条」的成本约 1.1 微秒,是往线程池里塞一个任务的 2.5 倍左右(后者只要 0.4 微秒,但前提是线程已经存在、并且复用)。绝对量级上 1 微秒仍然是平台线程创建成本的百分之一量级(上面 4070 条平台线程就已经把进程顶到上限),所以这条数据的意义是:每请求创建一条虚拟线程的开销在 50ms 的下游延迟面前完全可以忽略;但如果你的接口本身只有几微秒的处理时间,这条开销就会出现在延迟分布里。

不要池化虚拟线程

池化虚拟线程的实测

第三种模式 virtual-fixedExecutors.newFixedThreadPool(200, Thread.ofVirtual().factory()):一个容量 200 的固定池,里面的「线程」全是虚拟线程。它在 200 和 5000 两档并发下的表现:

1
2
3
4
5
6
7
8
9
10
11
--- virtual-fixed / pool=200, concurrency=200
RESULT concurrency=200 requests=16600 errors=0 reconnects=0 windowMs=5009 drainMs=28 drained=true qps=3313.7 p50=61.48ms p95=62.61ms p99=63.31ms max=64.25ms mean=60.09ms
RESULT concurrency=200 requests=16800 errors=0 reconnects=0 windowMs=5002 drainMs=4 drained=true qps=3358.5 p50=61.00ms p95=62.83ms p99=67.33ms max=73.78ms mean=59.36ms
RESULT concurrency=200 requests=16800 errors=0 reconnects=0 windowMs=5009 drainMs=9 drained=true qps=3353.9 p50=60.57ms p95=62.80ms p99=63.78ms max=66.12ms mean=59.42ms
--- virtual-fixed / pool=200, concurrency=5000(两个压测进程,各 2500)
RESULT concurrency=2500 requests=8639 errors=0 reconnects=0 windowMs=5010 drainMs=1422 drained=true qps=1724.3 p50=1448.32ms p95=1468.19ms p99=1471.10ms max=1473.05ms mean=1411.89ms
RESULT concurrency=2500 requests=8702 errors=0 reconnects=0 windowMs=5002 drainMs=1410 drained=true qps=1739.7 p50=1448.34ms p95=1468.31ms p99=1472.41ms max=1474.44ms mean=1412.80ms
RESULT concurrency=2500 requests=8507 errors=0 reconnects=0 windowMs=5005 drainMs=1441 drained=true qps=1699.7 p50=1439.55ms p95=1458.53ms p99=1462.88ms max=1465.38ms mean=1406.16ms
RESULT concurrency=2500 requests=8788 errors=0 reconnects=0 windowMs=5004 drainMs=1454 drained=true qps=1756.1 p50=1439.53ms p95=1458.30ms p99=1462.86ms max=1465.03ms mean=1406.95ms
RESULT concurrency=2500 requests=8745 errors=0 reconnects=0 windowMs=5010 drainMs=1426 drained=true qps=1745.5 p50=1453.12ms p95=1470.52ms p99=1474.13ms max=1476.36ms mean=1416.74ms
RESULT concurrency=2500 requests=8539 errors=0 reconnects=0 windowMs=5000 drainMs=1421 drained=true qps=1707.5 p50=1454.23ms p95=1472.49ms p99=1475.05ms max=1477.67ms mean=1410.27ms

和前面两张表放在一起:

执行器 200 并发 QPS 200 并发 p50 5000 并发 QPS 5000 并发 p50
固定平台线程池(200) 3772 53.04ms 3659 1366.06ms
每任务一条虚拟线程 3355 60.80ms 50898 99.19ms
固定池(200) + 虚拟线程工厂 3354 61.00ms 3456 1448.34ms

答案很干脆:virtual-fixed 在 5000 并发下只有 3456 QPS,p50 延迟 1448ms,和固定平台线程池(3659 QPS / 1366ms)是同一档,甚至略差。它把虚拟线程唯一的价值(不受线程数限制的并发规模)原样丢掉了,只留下了虚拟线程那点额外开销。200 并发那一档也是同理:它跟 virtual 一样慢,因为此时池大小正好等于并发,没有规模收益可拿。

固定池的容量限制的是同时在跑的任务数,虚拟线程廉价只意味着「创建它不贵」,并不意味着「一个池里能同时跑的任务变多」。只要任务还得从 200 个「槽位」里抢,排队行为就和平台线程池一模一样。

每任务一线程的证据

回到 JFR:那次 5000 并发的录制里 jdk.VirtualThreadStart = 299608、jdk.VirtualThreadEnd = 299608,窗口内完成请求 266299(不含预热阶段)。两者在同一个数量级上,说明每一次请求都真的新建并销毁了一条虚拟线程,没有任何复用——这就是 newVirtualThreadPerTaskExecutor() 的正常工作方式,也是它该有的样子。

需要限流的时候,正确的位置是下游资源而不是线程:数据库连接池上限、HTTP 客户端的并发上限、或者一个显式的 Semaphore。池化虚拟线程等于用线程槽位来限流,那是把平台线程时代的习惯原样搬了过来。

从这组实验里得到的迁移判断

  1. 先量并发峰值与线程池容量的差。差很小(峰值并发 ≈ 池大小)时迁移是负收益,本机实测低并发下慢 2.8%~11%。
  2. 不要在两个方向都调。迁到虚拟线程后,原来的池大小不再有意义(virtual-fixed 实测退化到平台池水平),需要限流就补 Semaphore 或下游连接池。
  3. 载体线程数不是并发上限。纯阻塞负载下 2 条载体也能撑住 5000 并发;只有当 handler 自己烧 CPU(或存在固定载体的阻塞)时,载体数才等于吞吐上限(实测 10ms CPU 的负载下,2/4/10 条载体对应 196/394/931 QPS)。
  4. CPU 密集任务留在平台线程池。10 核机器上两种实现的耗差落在噪声底之内,迁移在这类负载上换不到可测收益。
  5. JDK 24 之后 pinning 基本从清单上消失。本机在 JDK 25 上跑出的 jdk.VirtualThreadPinned 计数是 0;但要用 JFR 排查,得先用 jfr configure 把这三个事件打开,默认的 profile 里它们是关闭的。
  6. 排障口径要跟着换jdk.tracePinnedThreads 已经没了,线程 dump 要用 jcmd <pid> Thread.dump_to_file -format=json 才能看到虚拟线程;平台线程数会一直停在几十条(本机实测峰值 22 条),「看线程数判断负载」这套经验在迁移后直接失效。

Tomcat / Spring Boot 里的同类旋钮是 server.tomcat.threads.max(Spring Boot 3.x 默认 200),与前面实验里的固定池 200 是同一个位置上的参数:把它调大是在给每请求一线程的实现撑规模,而迁到虚拟线程之后这个参数就该退出调优清单。这一段是类比,没有在本机跑 Tomcat 验证。

总结

  • 固定 200 条平台线程的服务,在 50ms 阻塞型下游上,吞吐在并发 200 处就到顶:200/1000/5000 并发分别是 3772 / 3661 / 3659 QPS,多出来的并发全部变成排队延迟(p50 53ms → 273ms → 1366ms)。这个上限与 sleep(50) 实测的 53.9ms 服务时间推算出的 3710 QPS 吻合。
  • 同一负载换成 newVirtualThreadPerTaskExecutor():1000 并发 16379 QPS,5000 并发 50898 QPS,是固定池的 4.5 倍和 13.9 倍,p50 反而只有 61.5ms 与 99.2ms。
  • 低并发下虚拟线程是负优化:并发 50 时慢 2.8%,并发 200 时慢 11%。把 HTTP 层剥掉单测 Thread.sleep(50),虚拟线程的唤醒 p50 比平台线程高 1.3ms(53.8ms vs 55.0ms,机器负载高时会一起涨到 56.8ms 与 60.0ms),说明差距来自唤醒路径而不是 HTTP 层。
  • JFR 在虚拟线程组上录到 299608 次 jdk.VirtualThreadStart、0 次 jdk.VirtualThreadPinned(JDK 24 起 JEP 491 让 synchronized 不再固定载体),整个服务端进程峰值只有 22 条平台线程。
  • 载体线程数只在 CPU 占比高时才决定吞吐:纯阻塞负载下 parallelism 2/4/10 对应 57318/57219/50963 QPS,10ms CPU 负载下对应 196/394/931 QPS,后者与 parallelism / 每请求CPU时间 严格线性。
  • CPU 密集任务没有速度收益:10/100 个任务下两种实现的耗时差在 ±10% 以内(nop 基准的噪声底就有 4~6ms),总 CPU 时间相同。
  • 池化虚拟线程是最该避免的写法:newFixedThreadPool(200, Thread.ofVirtual().factory()) 在 5000 并发下只有 3456 QPS,比固定平台线程池还低。
  • 单进程平台线程上限在这台 macOS 上是 4070 条(kern.num_taskthreads = 4096 减掉 JVM 内部线程),5000 并发的压测客户端因此必须拆成两个进程。
  • 本组数据都是单机压测,服务端与压测端共享 10 个核,载体数那一组已经能看到客户端被挤的串扰;结论的方向可信,绝对数字不要外推。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集