「线程数加到 800 了,还是上不去」
1 月中旬看压测报告,开放平台那个网关服务的并发一直卡在 4000 左右。压测的同学在群里说:「Tomcat 线程数已经从 200 加到 800 了,加不动了,再加 Full GC 就开始频繁。」
这个症状太典型了——线程成了并发的天花板。我把之前攒的 Loom 资料翻出来,下了一个 Loom 的 early-access 构建跑了一轮。说明一下:虚拟线程在 JDK 19 才作为预览特性(JEP 425)进主线,我这次用的是 Loom 的 EA 构建,命令和 API 都还不是最终形态,仅供体验,不能上生产。
先量化平台线程的天花板在哪
Java 里的 Thread 一直是一对一映射到操作系统线程的。这个模型的成本有三项:
1. 内存
每个线程要预留栈空间,-Xss 默认 1 MB(我们线上配的是 -Xss512k)。注意这是预留的虚拟地址空间,不是立刻占用的物理内存,但上限是实打实的:
$ ulimit -s
8192 # 8 MB,操作系统栈
$ jcmd 1 VM.native_memory | grep -A 5 "Thread"
- Thread (reserved=1048576KB, committed=124928KB)
(thread #1000) # 1000 个线程
(stack: reserved=1042432KB, committed=118784KB)
(malloc=5794KB #6005)
1000 个线程 reserved 了 1 GB 虚拟内存,committed 122 MB。我们压到 800 个 Tomcat 线程时,光线程栈的 committed 就接近 100 MB,这还没算每个线程的 TLAB 和内核侧的 task_struct。
2. 上下文切换
我用 vmstat 抓了压测期间的上下文切换次数:
$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
34 0 0 3821444 212304 8892340 0 0 0 284 61234 184233 62 27 11 0 0
41 1 0 3712882 212304 8894122 0 0 0 312 66012 202188 65 25 10 0 0
cs 是每秒上下文切换次数,18 万到 20 万。sy(内核态 CPU)占 25%~27%,这部分基本都是线程调度和系统调用的开销。四分之一 CPU 花在调度上,不是在干活。
3. 创建成本
// 测量创建 10000 个线程的耗时
long t0 = System.nanoTime();
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < 10_000; i++) {
Thread t = new Thread(() -> LockSupport.park());
t.start();
threads.add(t);
}
System.out.printf("platform thread: %d ms%n", (System.nanoTime() - t0) / 1_000_000);
// platform thread: 4187 ms
10000 个平台线程 4.19 秒,平均每个 0.42 ms。而且到 6000 多个的时候开始报 OutOfMemoryError: unable to create new native thread。
虚拟线程:JVM 接手调度
虚拟线程是 M:N 调度:大量虚拟线程跑在少量"载体线程"(carrier thread)上,载体线程默认是一个 ForkJoinPool,并行度等于 CPU 核数。虚拟线程在执行阻塞操作(比如 LockSupport.park、socket.read)时,会自动从载体线程上卸载(unmount),载体线程立刻去跑别的虚拟线程。
创建方式有三种:
// 1. 直接启动
Thread vThread = Thread.startVirtualThread(() -> {
System.out.println("hello from " + Thread.currentThread());
});
// 2. 更精细的控制(命名、未捕获异常处理器)
Thread.ofVirtual()
.name("order-task-", 0)
.uncaughtExceptionHandler((t, e) -> log.error("vt error", e))
.start(task);
// 3. 最常用:每个任务一个虚拟线程的 Executor
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 100_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // close() 会等待所有任务完成
第三种是我最推荐的用法。虚拟线程不需要池化——创建一个的成本极低,池化反而会限制并发。以前"线程池大小怎么配"这个经典问题,在虚拟线程世界里直接消失了。
压测对比
我写了个模拟场景:每个任务阻塞 1 秒(模拟一次外部 HTTP 调用),看 10 万个任务全部完成要多久,以及内存占用。
// 平台线程版,200 个线程的池
ExecutorService pool = Executors.newFixedThreadPool(200);
// 虚拟线程版
ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor();
| 方案 | 线程数 | 10 万任务耗时 | 峰值 RSS | 上下文切换(cs/s) |
|---|---|---|---|---|
| FixedThreadPool(200) | 200 | 502.3 s | 412 MB | 18,400 |
| FixedThreadPool(1000) | 1000 | 101.7 s | 688 MB | 94,200 |
| VirtualThreadPerTask | 100000 | 4.9 s | 1.24 GB | 3,100 |
耗时从 502 秒降到 4.9 秒,102 倍。但别被这个数字迷惑——这个测试是纯阻塞场景(任务什么都不干,就睡 1 秒),是虚拟线程最理想的情况。换成纯计算任务,虚拟线程不会比平台线程快,因为 CPU 核数没变。
内存这块值得单独说:10 万个虚拟线程占用 1.24 GB,平均每个约 13 KB。它的栈是可增长的 chunk 栈,存在 Java 堆里,随调用深度增长,GC 可以回收,不像平台线程那样一次性预留 1 MB。
结构化并发
Loom 还带了一个 StructuredTaskScope(孵化阶段),解决的是"多个并发任务的生命周期管理"问题。
以前我们这样写:提交三个子任务到线程池,用 CompletableFuture.allOf 等待。问题在于,如果其中一个失败了,另外两个还在后台跑,你得自己记得取消,否则它们会一直占着资源。
// 传统写法
CompletableFuture<Order> f1 = supplyAsync(() -> queryOrder(id), pool);
CompletableFuture<User> f2 = supplyAsync(() -> queryUser(uid), pool);
CompletableFuture<Risk> f3 = supplyAsync(() -> queryRisk(uid), pool);
try {
allOf(f1, f2, f3).join(); // f1 失败时,f2 f3 还在跑
} catch (Exception e) {
// 忘了 cancel,两个任务白跑
}
StructuredTaskScope 用代码块界定作用域,离开作用域时所有子任务必定已经结束或被取消:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<Order> order = scope.fork(() -> queryOrder(id));
Subtask<User> user = scope.fork(() -> queryUser(uid));
Subtask<Risk> risk = scope.fork(() -> queryRisk(uid));
scope.join(); // 等全部结束
scope.throwIfFailed(); // 有失败就抛
// 这里三个结果都能拿到,且子任务已全部终止
return new Detail(order.get(), user.get(), risk.get());
}
ShutdownOnFailure 的语义是:任意一个子任务失败,立刻中断其他子任务。ShutdownOnSuccess 则相反,任意一个成功就取消其余的(适合"多个数据源取最快那个")。
这个 API 在 JDK 19 里放在 jdk.incubator.concurrent 模块,需要 --add-modules jdk.incubator.concurrent,运行时也会打 incubator 警告。
踩到的两个坑
1. synchronized 会把虚拟线程钉在载体线程上
这是当前实现最大的限制。虚拟线程在 synchronized 块里发生阻塞时,无法卸载,会把载体线程一起占住。我实测了一下:
static final Object LOCK = new Object();
// 场景:10 万个任务,每个都在 synchronized 块里 sleep 1 秒
executor.submit(() -> {
synchronized (LOCK) {
Thread.sleep(1000); // 这里会 pin 住 carrier
}
});
8 核机器上,10 万个任务耗时 501.4 s(跟 200 线程池一个量级)
jcmd Thread.dump_to_file -format=json /tmp/vt.json
# 看到大量 " pinned to carrier " 的线程
验证 pin 是否发生,可以用这个 JVM 参数,它会在 pin 时打印堆栈:
-Djdk.tracePinnedThreads=full
Thread[#31,ForkJoinPool-1-worker-1,5,CarrierThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:183)
java.base/jdk.internal.vm.Continuation.onPinned(Continuation.java:393)
...
com.xxx.Demo.lambda$main$1(Demo.java:42) <-- monitors:1
解决办法是把 synchronized 换成 ReentrantLock。我在改完之后的复测:
改前(synchronized):501.4 s
改后(ReentrantLock):5.2 s
2. ThreadLocal 会被放大十万倍
虚拟线程也支持 ThreadLocal,语法上完全一样。但以前一个 200 线程池最多 200 份 ThreadLocal 副本,现在可能有 10 万份。我们有个埋点框架在 ThreadLocal 里放了一个 2000 大小的数组,10 万虚拟线程算下来就是 800 MB 往上。
Loom 提供了 ScopedValue 作为替代,但它在 JDK 19 里还是孵化状态,我没测。当前阶段的建议:虚拟线程里慎用 ThreadLocal,尤其是放大数据结构的那种。
现在能用吗
我的判断是 2022 年不能上生产,理由有三:
- API 未定稿。虚拟线程在 JDK 19 是预览(JEP 425),
StructuredTaskScope是孵化(JEP 428),两者都要加--enable-preview/--add-modules才能跑,且后续版本还会改。 - 生态没跟上。我们用的 Tomcat 10.1、Undertow、Netty 5 都还没适配虚拟线程;HikariCP 这类池化组件在虚拟线程下反而成了瓶颈——池子只有 20 个连接,10 万个虚拟线程全在等这 20 个连接,等于把瓶颈从线程转移到了连接池。
- 调试工具不成熟。
jstack打印出来的虚拟线程堆栈是扁平的,看不出 Continuation 的层次;SkyWalking 9 那会儿的探针也没适配,链路会断。
真正适合虚拟线程的场景是:IO 密集、阻塞式写法、并发量高。我们那个开放平台网关就符合——它调外部三方接口,平均等待 380 ms,纯阻塞调用。等 JDK 19 转正确认之后,我打算先在它上面试点,把 Tomcat 的执行器换成虚拟线程版:
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
先到这
《虚拟线程初探:JDK 19 预览版体验》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。