本节目标:说清虚拟线程「轻」在哪里,以及 JDK 25 里 pin 到底还会不会发生、在什么条件下发生。
适用版本:JDK 25(Temurin 25.0.5);Javalin 7.2.4。
承接的问题
卷二 9.2 已经把 libraryd 的 Javalin 请求线程换成了虚拟线程,当时给出的数字是:2000 个平台线程约 307MB RSS,2000 个虚拟线程约 63MB。但那只回答了「能不能创建很多」,没有回答「它们跑在哪」。
这个问题在排障时很关键。线上出现「虚拟线程数量 5000,但 CPU 只有 30%」时,需要判断是下游慢、还是载体被占住了;出现「开了虚拟线程吞吐没涨」时,需要判断是锁竞争、还是调用路径里有 pin。要回答这些,得先看清三件事:
- 虚拟线程挂在谁身上运行,载体有多少个。
Thread.sleep、socketRead这类阻塞发生时,载体发生了什么。- 什么情况下虚拟线程会「粘」在载体上,让载体无法复用。
这一节用三个实验分别回答,并在最后把它接到 libraryd 的 Javalin 配置上。
载体线程:ForkJoinPool 的 worker
虚拟线程由 JVM 调度到一个 ForkJoinPool 上执行,池里的平台线程叫载体线程(carrier)。默认并行度等于 Runtime.getRuntime().availableProcessors(),本机是 10。
创建 2000 个虚拟线程,每个先记下自己挂在哪个载体上,然后在 CountDownLatch 上等:
for (int i = 0; i < count; i++) {
Thread.ofVirtual().name("loan-vt-" + i).start(() -> {
String before = carrier();
carriers.add(before);
try {
release.await();
Thread.sleep(20);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
carriers.add(carrier());
done.countDown();
});
}
本机输出:
virtualThreads=2000 distinctCarriersWhileRunnable=10
platformThreads=17
distinctCarriers=10 [ForkJoinPool-1-worker-8, ForkJoinPool-1-worker-9, ForkJoinPool-1-worker-6, ...]
- 2000 个虚拟线程同时处于可运行状态,只用到了 10 个载体。
Thread.getAllStackTraces().size()仍然是 17,虚拟线程不在这个集合里。要观测虚拟线程数量,得用 JFR 的jdk.VirtualThreadStart/jdk.VirtualThreadEnd,或者线程转储里的虚拟线程段。- 载体线程的名字是
ForkJoinPool-1-worker-N。日志里看到这个名字,说明当前代码跑在载体上;看到VirtualThread[#42,loan-vt-7]/runnable@ForkJoinPool-1-worker-3,说明是一个虚拟线程挂在 3 号载体上。
载体数量可以调:
java -Djdk.virtualThreadScheduler.parallelism=1 \
-Djdk.virtualThreadScheduler.maxPoolSize=1 \
-cp out CarrierPin sleep
parallelism 是常驻载体数,maxPoolSize 是载体池的上限。生产环境一般不要动这两个参数:默认值按 CPU 核数设置,改小会让阻塞型任务排队,改大只是增加上下文切换。
挂起与迁移:睡一觉就换了载体
虚拟线程的栈存在堆上(Continuation 对象),挂起时把栈帧从载体线程的栈上卸下来,恢复时再装回去。所以「虚拟线程 → 载体」不是固定绑定。
让 6 个虚拟线程各循环 4 次「打印当前载体 → 睡 15ms」,并行度设为 2:
StringBuilder path = new StringBuilder();
for (int step = 0; step < 4; step++) {
path.append(carrier()).append(step == 3 ? "" : " -> ");
Thread.sleep(15);
}
System.out.println("borrow-" + id + " carriers: " + path);
本机输出:
borrow-1 carriers: ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-2
borrow-0 carriers: ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-1
borrow-2 carriers: ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-2
borrow-3 carriers: ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-1
borrow-4 carriers: ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-2
borrow-5 carriers: ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-2 -> ForkJoinPool-1-worker-1 -> ForkJoinPool-1-worker-1
同一个虚拟线程在 4 次 sleep 前后落在了不同的载体上。这就是「挂起释放载体」的直接证据:睡着的 15ms 里,载体去跑别的虚拟线程了,醒来时调度器把它分配到哪个空闲载体就是哪个。
推论有三条:
- 不要依赖「当前线程」做线程绑定。
ThreadLocal跟着虚拟线程走(它是虚拟线程自己的字段),但载体线程的ThreadLocal、Thread.currentThread().getName()里的载体名,都不能用来标识业务请求。 - 不要在虚拟线程里做 CPU 密集的长计算。它不会主动让出载体,10 个载体被 10 个计算任务占满后,其余几千个虚拟线程都没法推进。
synchronized块里的sleep不一定会挂起,这取决于 JDK 版本和是否 pin,下一节用实验说明。
pinning:JDK 25 的边界
「pin」指虚拟线程在阻塞时无法释放载体,载体被占住,调度器只能再开一个平台线程来跑其他虚拟线程。所有载体都被 pin 时,吞吐退回平台线程模型。
JDK 21 时代最著名的结论是「synchronized 会 pin,改用 ReentrantLock」。这个结论在 JDK 25 已经不成立:JDK 24 的 JEP 491 允许虚拟线程在 synchronized 块或方法中阻塞时释放载体。
设计一个能区分「pin / 不 pin」的实验。把载体池限制成 1 个(parallelism=1、maxPoolSize=1),这样一旦载体被占住,就没有第二个载体来救场。再起一个虚拟线程做「心跳」,每 1ms 醒一次并计数。然后让被测虚拟线程阻塞 500ms:
Thread ticker = Thread.ofVirtual().name("vt-ticker").start(() -> {
while (ticking) {
Thread.sleep(1);
ticks++;
}
});
switch (mode) {
case "sleep" -> Thread.sleep(500);
case "sync-sleep" -> {
synchronized (CarrierPin.class) {
Thread.sleep(500);
}
}
case "native" -> usleep.invokeExact(500_000); // FFM 调用 libc 的 usleep
case "sync-native" -> {
synchronized (CarrierPin.class) {
usleep.invokeExact(500_000);
}
}
default -> throw new IllegalArgumentException(mode);
}
四种模式各跑一次,ticks 是被测线程阻塞期间的计数:
javac --release 25 -d out CarrierPin.java
java -Djdk.virtualThreadScheduler.parallelism=1 \
-Djdk.virtualThreadScheduler.maxPoolSize=1 \
--enable-native-access=ALL-UNNAMED -cp out CarrierPin sleep
本机输出(Apple M1 Pro,macOS 26.3,Temurin 25.0.5):
mode=sleep blockedMs=398 tickerTicks=312
mode=sync-sleep blockedMs=400 tickerTicks=282
mode=native blockedMs=401 tickerTicks=0
usleep rc=0
mode=sync-native blockedMs=415 tickerTicks=1
逐行解释:
sleep:312 次心跳。虚拟线程睡着的 400ms 里,载体一直在跑心跳线程,这是正常的挂起。sync-sleep:282 次心跳。在synchronized块里sleep,载体同样被释放了。JEP 491 生效,synchronized不再 pin。native:0 次心跳。FFM 调用usleep期间,JVM 看到的是一条正在执行的本地调用,不知道它要睡 500ms,载体被占住,心跳线程一次都没跑。sync-native:1 次心跳,和native同因。
所以 JDK 25 里剩下的 pin 场景主要是本地方法/外部函数调用期间,以及类初始化等少数 JVM 内部路径。业务代码里的 synchronized、ReentrantLock、Object.wait 都不再是 pin 的来源。
这里要特别提醒一个观测陷阱。用 JFR 采集同样的运行:
java -XX:StartFlightRecording=filename=native.jfr,settings=profile \
-Djdk.virtualThreadScheduler.parallelism=1 \
--enable-native-access=ALL-UNNAMED -cp out CarrierPin native
jfr summary native.jfr | grep -i pinned
本机输出:
jdk.VirtualThreadPinned 0 0
native 那次明明把载体占满了 400ms,事件计数却是 0。原因是 JVM 只有在虚拟线程尝试阻塞时才判定 pin;本地调用期间它看到的仍然是「运行中」,没有阻塞请求可记。结论很实际:不能把「没有 jdk.VirtualThreadPinned 事件」当成「没有 pin」的证据。要验证本地调用是否占住载体,用上面的心跳法或直接看载体线程的 CPU/栈。
在 libraryd 里落地
第一处是 HTTP 层。卷二已经这么配了,这里补上 API 的确认方式。Javalin 7.2.4 的 ConcurrencyConfig 只有一个布尔开关:
javap -classpath javalin-7.2.4.jar io.javalin.config.ConcurrencyConfig
本机输出:
public final class io.javalin.config.ConcurrencyConfig {
public boolean useVirtualThreads;
public kotlin.Lazy<io.javalin.http.util.AsyncExecutor> executor;
public io.javalin.config.ConcurrencyConfig();
}
配置与路由注册:
package com.plume.library.app;
import com.plume.library.web.Routes;
import io.javalin.Javalin;
public final class AppMain {
public static Javalin startHttp(AppConfig config, Services services) {
Javalin app = Javalin.create(cfg -> {
cfg.concurrency.useVirtualThreads = true;
Routes.register(cfg.routes, services);
});
app.start(config.httpPort());
return app;
}
}
Routes 只依赖 JavalinDefaultRoutingApi,路由注册不绑定 Javalin 实例,测试时可以直接注册到一个假的 API 上。请求线程会由 Jetty 的 JettyServerThreadPool-Virtual-* 创建,每个请求一个虚拟线程。
第二处是请求内的并行查询。卷二 9.2 用固定工作池做过搜索聚合,现在可以换成「每个任务一个虚拟线程」:
package com.plume.library.web;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class SearchService {
private final BookSearchPort search;
private final HoldingsPort holdings;
public SearchService(BookSearchPort search, HoldingsPort holdings) {
this.search = search;
this.holdings = holdings;
}
public List<SearchHit> search(String keyword) throws Exception {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<List<Book>> books = executor.submit(() -> search.byKeyword(keyword));
Future<List<Holdings>> copies = executor.submit(() -> holdings.byKeyword(keyword));
return merge(books.get(), copies.get());
}
}
}
三个 50ms 的查询用它并发跑,本机实测总耗时 63ms:
happy elapsedMs=63
这里有一个必须说清的点:newVirtualThreadPerTaskExecutor() 每个任务新建一个虚拟线程,close() 会等待所有任务结束。它适合「一次请求内扇出几个查询」这种短生命周期场景,不适合当常驻线程池用(6.3 会讲为什么)。
同一段代码在失败时的表现并不好:
failed after 408ms: IllegalStateException, siblingCancelled=false
一个任务 50ms 就失败了,另一个要跑 400ms,invokeAll 仍然等到 400ms 才返回,慢的那个也没有被取消。这就是 6.2 要解决的问题。
常见错误与边界
错误一:还按 JDK 21 的经验躲 synchronized。 「虚拟线程里不能用 synchronized,要换 ReentrantLock」在 JDK 25 已经不成立。上面实验里 sync-sleep 的心跳是 282 次,载体正常释放。真正要躲的是本地调用期间的阻塞。
错误二:把虚拟线程放进固定大小的池。 Executors.newFixedThreadPool(32, Thread.ofVirtual().factory()) 只创建 32 个虚拟线程并复用,退化成平台线程模型,还额外背了调度的开销。虚拟线程不需要池化。
错误三:用 Thread.getAllStackTraces() 统计虚拟线程。 它只返回平台线程。本机 2000 个虚拟线程时仍然是 17 个平台线程,用它做监控会得出「没有虚拟线程」的错误结论。
错误四:在虚拟线程里跑 CPU 密集任务并期待吞吐提升。 载体数等于核数,CPU 密集任务没有阻塞可以释放载体,开更多虚拟线程只会增加调度开销。虚拟线程优化的是「等待」,不是「计算」。
边界:pin 不是唯一的性能问题。 synchronized 不 pin 了,但它仍然是互斥锁。libraryd 如果保留全局 synchronized 的借阅检查,所有借阅请求还是在同一个 monitor 上排队,虚拟线程只是让排队不占载体,不会让锁消失。
边界:本地调用阻塞仍然占载体。 JDBC 驱动如果是纯 Java 的 socket 读写,走的是 Socket 的虚拟线程友好路径,会挂起;如果驱动内部用了 JNI 或 FFM 做加密、压缩,那段就可能 pin。判断方法是在自己的负载上做心跳实验,不要照搬别人的结论。
小结
- 虚拟线程由
ForkJoinPool调度,载体数默认等于 CPU 核数;本机 2000 个虚拟线程只用了 10 个载体。 - 虚拟线程挂起时释放载体,恢复时可能换一个载体;本机实验中同一个虚拟线程在 4 次
sleep前后落在不同 worker 上。 - JDK 24 的 JEP 491 之后,
synchronized里的阻塞不再 pin;本机sync-sleep心跳 282 次,与普通sleep的 312 次同量级。 - 本地调用/外部函数调用期间的阻塞仍然占住载体;本机
usleep500ms 期间心跳 0 次。 jdk.VirtualThreadPinned事件在本地调用场景下计数为 0,不能拿「没有事件」证明「没有 pin」。- libraryd 用
cfg.concurrency.useVirtualThreads = true开启请求虚拟线程,请求内扇出用newVirtualThreadPerTaskExecutor()。
扇出能跑起来,但失败时的取消要自己写。下一节用 StructuredTaskScope 把「三个查询要么全成功、要么全取消」变成作用域语义。
阅读导航:上一节:5.3 同步器:CountDownLatch、Semaphore 与 CyclicBarrier · 下一节:6.2 结构化并发:StructuredTaskScope 与作用域取消 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。