本节目标:把 libraryd 的请求线程从平台线程换成虚拟线程,并说清楚 JDK 25 里哪些旧结论已经失效。
适用版本:JDK 25(Temurin 25.0.5);Javalin 7.2.4;PostgreSQL 17。
承接的问题
9.1 的工作池让 libraryd 有了可控的后台并发,但 HTTP 请求仍然跑在 Jetty 的平台上。一个搜索请求要等两次 SQL,一个借阅请求要等事务提交;线程在等 I/O 时被占住,Jetty 只能靠增加平台线程数来撑并发。平台线程的栈在创建时就预留,1000 个线程已经是几百 MB 的量级,继续往上加并不便宜。
虚拟线程改变的是「等待的代价」:它由 JVM 调度到少量载体线程(carrier)上,阻塞时释放载体,栈按需增长。一个请求一个虚拟线程,代码仍然可以写成最简单的同步阻塞风格,不需要把 JDBC 调用改写成回调或响应式流。
但它不会改变下游容量。libraryd 的 HikariCP 仍然只有 10 个连接,数据库仍然只有那么多并发能力。虚拟线程能让 10000 个请求都「活着」,其中 9990 个在等连接;如果队列没有边界,过载只是从线程池转移到了连接池。这一节要把这两件事分开。
JDK 25 里的虚拟线程
虚拟线程在 JDK 21 通过 JEP 444 成为正式特性,不是预览 API,也不需要 --enable-preview。JDK 25 里可以直接写:
Thread thread = Thread.ofVirtual()
.name("libraryd-request-1")
.start(() -> handleRequest());
thread.join();
也可以用「每个任务一个虚拟线程」的执行器:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = executor.submit(() -> loadIsbn("978000000001"));
System.out.println(future.get());
}
ExecutorService 继承了 AutoCloseable,close() 会等待已提交任务结束。虚拟线程默认是守护线程,Thread.currentThread().isVirtual() 返回 true。本机跑一个最小探针:
isVirtual=true daemon=true thread=VirtualThread[#26,probe-1]/runnable@ForkJoinPool-1-worker-1
executor=true/
builder=true
输出里的 runnable@ForkJoinPool-1-worker-1 表示这个虚拟线程当前挂载在哪个载体线程上。虚拟线程的名字可以为空,Thread.ofVirtual().start(...) 不指定名字时就是空字符串,日志里不好认,生产代码应该显式命名或使用请求 ID。
JDK 24 的 JEP 491 解决了 synchronized 阻塞时 pin 住载体线程的问题,JDK 25 继承了这个行为。这一点很关键:网上大量「虚拟线程遇到 synchronized 就会 pin」的文章写于 JDK 21,不能直接套到 JDK 25。后文会用 JFR 实际验证。
创建数量与内存:同机对比
先做一个只创建线程、让它们同时存活的实验。核心循环用 Thread.ofPlatform() 和 Thread.ofVirtual() 创建同样数量的线程,每个线程在两个 CountDownLatch 上等待:
Runnable body = () -> {
allStarted.countDown();
try {
release.await();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Thread t = switch (kind) {
case "platform" -> Thread.ofPlatform().name("p-" + i).unstarted(body);
case "virtual" -> Thread.ofVirtual().name("v-" + i).unstarted(body);
default -> throw new IllegalArgumentException(kind);
};
t.start();
编译并在同一台机器上分别运行,平台线程统一加 -Xss512k,用 macOS 的 /usr/bin/time -l 读取最大 RSS:
javac -d /tmp/vt-lab ThreadCost.java
/usr/bin/time -l java -Xss512k -cp /tmp/vt-lab ThreadCost platform 2000
/usr/bin/time -l java -Xss512k -cp /tmp/vt-lab ThreadCost virtual 2000
/usr/bin/time -l java -Xss512k -cp /tmp/vt-lab ThreadCost virtual 100000
本机输出(同一台机器、同一次会话,数值只用于同机对比):
kind=platform requested=2000 platformThreads=2006 createMs=760
maximum resident set size 307527680
kind=virtual requested=2000 platformThreads=17 createMs=60
maximum resident set size 62849024
kind=virtual requested=100000 platformThreads=17 createMs=142
maximum resident set size 194478080
三点观察:
- 同样 2000 个线程,平台线程的 RSS 约 307 MB,虚拟线程约 63 MB。平台线程的栈在创建时预留,虚拟线程的栈按需增长,这是差异的主要来源。
- 100000 个虚拟线程仍然只占约 194 MB,创建耗时 142ms。这个数字不是「虚拟线程一定比平台线程快」的证明,只是说明创建数量不再是主要瓶颈。
Thread.getAllStackTraces().size()只列出平台线程(2000 个线程时是 2006,100000 个虚拟线程时仍是 17),虚拟线程不会出现在这个集合里。要观察虚拟线程数量,应该用 JFR 事件或线程转储,而不是这个方法。
这些数字依赖 JVM 参数、操作系统和机器内存,不能跨环境下结论。真正要带走的是方法:在同机、同参数下对比,记录命令,不把一次运行的 RSS 当成产品指标。
Javalin 7 如何启用虚拟线程
Javalin 7.2.4 的 API 和 6.x 有变化:路由方法不在 Javalin 实例上,而是在 JavalinConfig.routes 上。启用虚拟线程则通过 cfg.concurrency.useVirtualThreads。本机用 javap 确认过 ConcurrencyConfig 的字段:
public final class io.javalin.config.ConcurrencyConfig {
public boolean useVirtualThreads;
...
}
最小可用写法:
import io.javalin.Javalin;
Javalin app = Javalin.create(cfg -> {
cfg.concurrency.useVirtualThreads = true;
cfg.routes.get("/thread", ctx ->
ctx.result(Thread.currentThread().toString()));
cfg.routes.get("/sleep", ctx -> {
Thread.sleep(200);
ctx.result(Thread.currentThread().toString());
});
});
app.start(18080);
不要写成 app.get(...) 或 app.before(...),这两个方法在 7.2.4 的 Javalin 类上不存在。用同样代码分别启动虚拟线程版(18080)和平台线程版(18081),curl 的结果:
--- virtual on
VirtualThread[#42,JettyServerThreadPool-Virtual-7]/runnable@ForkJoinPool-1-worker-1
--- virtual off
Thread[#31,JettyServerThreadPool-31,5,main]
虚拟线程版由 JettyServerThreadPool-Virtual-* 命名,平台线程版是普通的 JettyServerThreadPool-*。五个并发的 /sleep 请求在虚拟线程版里分别挂到载体线程上,200ms 的等待不会占住平台线程。
验证方式写清楚:先用 javap -classpath javalin-7.2.4.jar io.javalin.config.ConcurrencyConfig 确认字段,再用最小工程启动两个端口,最后用 curl 对比线程名。不要只看博客里的 app.get 示例,7.x 的 API 已经变了。
在 libraryd 里落地
AppMain 里把 Javalin 的请求线程切到虚拟线程,路由注册统一走 cfg.routes:
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 实例:
package com.plume.library.web;
import io.javalin.router.JavalinDefaultRoutingApi;
public final class Routes {
public static void register(JavalinDefaultRoutingApi routes, Services services) {
routes.get("/health", ctx -> ctx.json(services.health().check()));
routes.get("/api/books/{isbn}", ctx -> {
String isbn = ctx.pathParam("isbn");
ctx.json(services.books().get(isbn));
});
}
}
这里有一个容易忽略的连锁反应:卷一 LibraryService 用 synchronized 把借阅检查串行化。到了虚拟线程,synchronized 仍然互斥,所有借阅请求还是排队;虚拟线程只是让排队更便宜,不会让锁消失。libraryd 卷二已经在第 5 章把并发控制交给 PostgreSQL 的唯一索引和条件更新,服务层不应再保留全局锁。否则 10000 个虚拟线程也只会在同一个 monitor 上排队。
请求线程虚拟化之后,9.1 的工作池仍然保留。原因不是线程创建贵,而是数据库连接只有 10 个。后台的并行查询如果每个请求开 5 个任务,10 个并发请求就会产生 50 个任务抢 10 个连接。可以让工作池继续做有界队列,也可以改用虚拟线程加信号量:
package com.plume.library.app;
import java.util.concurrent.Callable;
import java.util.concurrent.Semaphore;
public final class DbConcurrencyGate {
private final Semaphore permits;
public DbConcurrencyGate(int maxConcurrent) {
if (maxConcurrent < 1) {
throw new IllegalArgumentException("maxConcurrent 必须大于 0");
}
this.permits = new Semaphore(maxConcurrent, true);
}
public <T> T call(Callable<T> task) throws Exception {
permits.acquire();
try {
return task.call();
} finally {
permits.release();
}
}
}
信号量的许可数应该不大于 Hikari 的 maximumPoolSize,否则只是把等待从连接池挪到数据库。虚拟线程负责「等待不占平台线程」,信号量负责「同时只有这么多任务进入数据库」,两者解决的是不同问题。
pinning 与 synchronized 的边界
「虚拟线程遇到 synchronized 会 pin」是 JDK 21 时代最常见的警告。pin 的意思是虚拟线程在阻塞时无法释放载体线程,载体被占住,调度器只能再开一个平台线程来跑其他虚拟线程;如果所有载体都被 pin,吞吐会退回平台线程模型。
JDK 24 的 JEP 491 允许虚拟线程在 synchronized 块或方法中阻塞时释放载体,JDK 25 默认包含这个修复。用一个可复现的实验验证:只给调度器一个载体线程(-Djdk.virtualThreadScheduler.parallelism=1),一个平台线程不停自增计数,另一个虚拟线程分别做三件事:
switch (mode) {
case "plain-sleep" -> Thread.sleep(300);
case "sync-sleep" -> {
synchronized (LOCK) { Thread.sleep(300); }
}
case "sync-wait" -> {
synchronized (LOCK) { LOCK.wait(300); }
}
default -> throw new IllegalArgumentException(mode);
}
如果发生 pin,唯一的载体被占住,平台线程的计数在 300ms 内不会增长;如果没有 pin,计数会持续增长。本机输出:
mode=plain-sleep ticksWhileBlocked=32909003
mode=sync-sleep ticksWhileBlocked=26806192
mode=sync-wait ticksWhileBlocked=33863386
三种模式下载体都被释放了。再用 JFR 确认没有 jdk.VirtualThreadPinned 事件:
java -XX:StartFlightRecording=filename=pin.jfr,settings=profile \
-Djdk.virtualThreadScheduler.parallelism=1 \
-cp /tmp/vt-lab PinningDemo sync-sleep
jfr print --events jdk.VirtualThreadPinned pin.jfr
本机输出为空,说明 synchronized 里的 Thread.sleep 和 Object.wait 都没有产生 pin 事件。这个结论只覆盖这两个场景;仍然可能 pin 的情况包括本地方法、外部函数调用,以及一些 JVM 内部路径。判断方法不是背 JDK 版本,而是在自己的负载上用 JFR 看 jdk.VirtualThreadPinned。
还要区分「不 pin」和「不互斥」。synchronized 不 pin 了,但它仍然是互斥锁。两个虚拟线程竞争同一个 monitor,只有一个能进入临界区,另一个还是要等。libraryd 的借阅规则如果继续用全局 synchronized,虚拟线程带来的并发度会被这把锁吃掉。正确做法是第 5 章的条件更新:
update loans
set returned_on = ?
where isbn = ? and returned_on is null
让数据库的唯一索引和行锁承担并发控制,而不是在 JVM 里锁住整个服务。
结构化并发用不用
JDK 25 里 StructuredTaskScope 仍然是预览 API。直接编译会得到:
错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
(请使用 --enable-preview 以启用预览 API)
要用它,编译和运行都必须加 --enable-preview,并且 class 文件版本和运行时 JDK 要严格匹配。对一个要交付、要进 CI、要让别人用 JDK 25 直接运行的项目来说,这是额外的工具链约束。本卷不采用结构化并发,第 10 章用 CompletableFuture 做异步编排;它的组合能力足够,且不需要预览开关。
如果团队已经接受预览特性,结构化并发的价值在取消和错误传播:父作用域失败时,子任务会被取消,不会留下孤儿任务。CompletableFuture 需要自己实现这套规则,第 10 章会看到 allOf 并不会 fail-fast。这个取舍要写进团队约定,而不是在代码里混用两种模型。
顺带说明:JDK 25 的 ScopedValue 已经是正式 API,可以作为不可变请求上下文的选择。但它的作用是「绑定一个作用域内的值」,不是替代日志框架的 MDC;第 10.3 节仍会用 MDC 展示异步上下文丢失,因为日志系统读的是 MDC。
常见错误与边界
「把虚拟线程放进固定大小的池。」 Executors.newFixedThreadPool(32, Thread.ofVirtual().factory()) 只会创建 32 个虚拟线程并复用,等于把虚拟线程当成平台线程用,既没有数量优势,也失去了「一个任务一个线程」的简单性。虚拟线程不需要池化,需要限制的是下游资源。
「每个虚拟线程都放一个大 ThreadLocal。」 虚拟线程可以有几万个,每个线程一份 ThreadLocal 就是几万份对象。请求上下文应该尽量小,能用参数传递就用参数,必须绑定时优先考虑 ScopedValue 或显式的上下文对象。第 10.3 节会展示 MDC 在异步线程里丢失,以及如何传递和清理。
「虚拟线程能提高 CPU 密集型任务的吞吐。」 不能。虚拟线程的载体线程数默认等于 CPU 核数,CPU 密集型任务没有阻塞可以释放载体,开更多虚拟线程只会增加调度开销。libraryd 的瓶颈是 JDBC 等待,不是 CPU 计算,所以虚拟线程合适。
「开了虚拟线程就不用管连接池。」 10000 个虚拟线程同时请求数据库,Hikari 仍然只给 10 个连接,其余 9990 个在 getConnection() 上等。连接池的等待时间会变成请求延迟,队列没有上限时还会把内存推高。虚拟线程和连接池限流要一起用。
「Javalin 7 还写 app.get。」 7.2.4 的 Javalin 类没有 get 方法,路由在 cfg.routes 上。用旧版示例会直接编译失败。升级 Javalin 时先看 javap 或官方迁移说明,不要凭记忆改 API。
小结
- 虚拟线程在 JDK 21 成为正式特性,JDK 25 不需要预览开关;
Thread.ofVirtual()和newVirtualThreadPerTaskExecutor()可以直接用。 - 同机对比中,2000 个平台线程约占 307 MB RSS,2000 个虚拟线程约 63 MB;100000 个虚拟线程约 194 MB,创建耗时 142ms。数值只用于同机比较。
- Javalin 7.2.4 用
cfg.concurrency.useVirtualThreads = true开启虚拟线程,路由必须注册在cfg.routes上。 - JDK 25 的
synchronized不再 pin 载体线程;用 JFR 的jdk.VirtualThreadPinned验证自己的负载,不要沿用 JDK 21 的结论。 - 不 pin 不等于不互斥;libraryd 的借阅并发应交给数据库条件更新,而不是全局
synchronized。 - 虚拟线程解决等待成本,不解决下游容量;连接池和信号量仍然要限制同时进入数据库的任务数。
StructuredTaskScope在 JDK 25 仍是预览 API,本卷用CompletableFuture,不引入--enable-preview。
请求线程已经换成虚拟线程,但队列满了以后调用方看到什么还没有定义。下一节把拒绝策略、队列深度和 503 响应接起来,让过载在日志和 HTTP 契约里都可见。
阅读导航:上一节:9.1 ExecutorService:线程池参数与队列 · 下一节:9.3 背压与拒绝策略:让过载可见 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。