本节目标:把「
spring.threads.virtual.enabled=true到底改了什么」讲清,用本机实测复现虚拟线程被synchronized钉住的现象,并说明虚拟线程的适用边界与@Async的接合点。
适用版本:Spring Boot 4.1.x(Java 21,Temurin 21.0.12.1)
上一节把「提交后执行」落到了同步回调上;这一节处理执行侧。当监听器要真正去调下游 HTTP、发消息这类阻塞动作时,平台线程很快会被占满。虚拟线程是 Java 21 给出的答案,而 Spring Boot 从 3.2 起就有开关接入它。本节讲机制、讲实测、讲边界。
沿用上一节的订单场景:OrderNotificationListener 在 AFTER_COMMIT 里调用下游通知服务。下面的讨论都围绕「这条通知该跑在哪种线程上」。
7.2.1 虚拟线程的两个入口
Java 21 提供两条创建虚拟线程的路径:
// 1) 单条虚拟线程
Thread vt = Thread.ofVirtual().name("notify-", 0).start(() -> notifyDownstream(orderId));
// 2) 每个任务一条虚拟线程的执行器(推荐给服务端)
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
exec.submit(() -> notifyDownstream(orderId));
}
Thread.ofVirtual() 返回一个 builder,Executors.newVirtualThreadPerTaskExecutor() 返回 ExecutorService,每次 submit 都新建一条虚拟线程。Thread.currentThread().isVirtual() 可判断当前是否虚拟线程(本机实测:普通 main 线程返回 false)。
7.2.2 载体线程与挂载 / 卸载
虚拟线程不是「免费线程」,它仍然要被调度到载体线程(carrier thread)上执行。载体来自一个专用的 ForkJoinPool,其并行度默认等于 CPU 核数。当虚拟线程执行阻塞操作(Thread.sleep、socket 读写、park 等)时,JDK 会把它从载体上卸载(unmount),把载体让给其他虚拟线程;阻塞结束后再重新挂载(mount)。这正是「少量载体跑海量阻塞任务」的原理。
载体池可以调,但通常不必:jdk.virtualThreadScheduler.parallelism(并行度,默认 CPU 核数)与 jdk.virtualThreadScheduler.maxPoolSize(最大载体数,默认 256)。7.2.3 的实验正是把它们压到 1 来放大 pinning 的影响。
关键约束:卸载要求虚拟线程的调用栈能被保存和恢复。JDK 21 里有两类情况做不到,于是虚拟线程会被**钉在(pin)**载体上:
| 钉住原因 | JDK 21 行为 | JDK 24 起(JEP 491) |
|---|---|---|
在 synchronized 块/方法内阻塞 | 钉住载体,无法卸载 | 已修复,可正常卸载 |
| 执行 native 方法或外部函数 | 钉住载体 | 仍会钉住 |
7.2.3 本机实测:synchronized 造成的 pinning
下面这段程序在本机 Temurin 21.0.12.1 上运行。为放大影响,把载体并行度压到 1,然后提交 4 个任务,每个任务在 synchronized 里 Thread.sleep(300):
import java.util.concurrent.*;
public class Pin {
static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
System.setProperty("jdk.virtualThreadScheduler.maxPoolSize", "1");
long start = System.nanoTime();
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 4; i++) {
exec.submit(() -> {
synchronized (LOCK) {
Thread.sleep(300); // 阻塞在 synchronized 内
}
return null;
});
}
}
System.out.println("synchronized+sleep elapsed(ms)="
+ (System.nanoTime() - start) / 1_000_000);
}
}
用 -Djdk.tracePinnedThreads=full 运行,本机实测输出(Temurin 21.0.12.1):
VirtualThread[#20]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned(VirtualThread.java:199)
java.base/jdk.internal.vm.Continuation.onPinned0(Continuation.java:393)
java.base/java.lang.VirtualThread.parkNanos(VirtualThread.java:635)
java.base/java.lang.VirtualThread.sleepNanos(VirtualThread.java:807)
java.base/java.lang.Thread.sleep(Thread.java:507)
Pin.lambda$main$0(Pin.java:14) <== monitors:1
java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
java.base/java.lang.VirtualThread.run(VirtualThread.java:329)
synchronized+sleep elapsed(ms)=1239
对照实验:把 synchronized 去掉、只留 Thread.sleep(300),同样并行度 1、同样 4 个任务,本机实测输出:
plain sleep elapsed(ms)=321
两组数字很能说明问题:去掉 synchronized 后 4 个 sleep 重叠,总耗时约 300 ms(一次 sleep 的时长);加上 synchronized 后因为被钉住无法卸载,4 个任务在唯一载体上串行,耗时约 1200 ms(四次 sleep 之和)。reason:MONITOR 与 Pin.lambda$main$0(...) <== monitors:1 就是 pinning 的直接证据。
7.2.4 JDK 24 的修复
JEP 491(Synchronize Virtual Threads without Pinning,本机从 openjdk.org 核实:Status 为 Closed / Delivered,Release 为 24)让虚拟线程在 synchronized 内阻塞时也能卸载。也就是说,上面这段程序在 JDK 24 及以后不会再出现 reason:MONITOR。本机是 21.0.12.1,仍会钉住,所以结论要按版本区分。native 方法与外部函数导致的 pinning 不在 JEP 491 范围内。
实践含义:在 JDK 21 上,若代码里有在 synchronized 里做阻塞 I/O 的热点路径,虚拟线程的收益会被抵消。改造手段是把 synchronized 换成 ReentrantLock(java.util.concurrent.locks),因为 Lock 的阻塞点是可识别的,不会钉住载体。
7.2.5 spring.threads.virtual.enabled 改变了什么
spring.threads.virtual.enabled 的默认值是 false(本机 spring-boot-autoconfigure-4.1.1.jar 的 spring-configuration-metadata.json 核实)。开启后(需 Java 21),Spring Boot 按官方 Release Notes 会做这些替换:
| 组件 | 开启前 | 开启后 |
|---|---|---|
| Servlet 容器请求处理 | 平台线程(nio-8080-exec-*) | 虚拟线程 |
applicationTaskExecutor | ThreadPoolTaskExecutor | 配置了虚拟线程的 SimpleAsyncTaskExecutor |
taskScheduler | ThreadPoolTaskScheduler | 配置了虚拟线程的 SimpleAsyncTaskScheduler |
JDK HttpClient 支撑的自动配置客户端 | 平台线程 | 虚拟线程(4.0 起) |
判断「当前是否虚拟线程模式」的内部依据是 org.springframework.boot.thread.Threading 枚举(本机 javap 核实:PLATFORM / VIRTUAL,方法 isActive(Environment));条件装配用 @ConditionalOnThreading。自动配置里对应的 Bean 方法是 TaskExecutorConfigurations$TaskExecutorConfiguration.applicationTaskExecutorVirtualThreads(SimpleAsyncTaskExecutorBuilder)(本机 javap 核实)。
被忽略的属性。 官方 3.2 Release Notes 明确:虚拟线程模式下 applicationTaskExecutor 是 SimpleAsyncTaskExecutor,因此 spring.task.execution.pool.*(core-size / max-size / queue-capacity 等)全部失效,它们只对池化执行器有意义。仍生效的是 spring.task.execution.thread-name-prefix,以及任何 TaskDecorator bean。spring.task.scheduling.pool.* 同理被忽略,spring.task.scheduling.simple.* 生效。
配置示例:
spring:
threads:
virtual:
enabled: true
task:
execution:
thread-name-prefix: vtask-
# pool.* 在虚拟线程模式下无效,写了也不会生效
没有被改变的部分。 这个开关只覆盖 Spring Boot 自己装配的那几个执行器,不会把下列对象换成虚拟线程:你手写的 ThreadPoolTaskExecutor bean、显式 new Thread(...)、以及第三方库自建的平台线程池(某些数据库驱动的后台线程、消息客户端的心跳线程)。它们仍按自己的方式创建平台线程。排查「开了虚拟线程为什么还有大量平台线程」时,先看是不是这些来源。
7.2.6 Tomcat 与虚拟线程的配合
Servlet 容器侧的开关由 org.springframework.boot.tomcat.autoconfigure.TomcatVirtualThreadsWebServerFactoryCustomizer 承担(本机 spring-boot-tomcat-4.1.1.jar javap 核实,它实现 WebServerFactoryCustomizer<ConfigurableTomcatWebServerFactory> 与 Ordered)。它把 Tomcat 协议处理器换成基于虚拟线程的执行器,于是 controller 方法、filter、拦截器都跑在虚拟线程上。
要知道的副作用:
- 请求线程不再是固定大小的线程池,每个请求一条虚拟线程,「线程池大小 = 并发上限」这条传统经验失效。真正的并发上限转移到了数据库连接池、下游连接数等有限资源上。
- Tomcat 的
server.tomcat.threads.max在虚拟线程模式下不再约束请求并发(本机TomcatServerProperties$Threads仍有max/min-spare/max-queue-capacity,但它们服务于平台线程模型)。 ThreadLocal仍然可用,但数量级风险变了:请求量一大,每个请求一份ThreadLocal副本,内存占用要重新评估。
7.2.7 为什么不适合 CPU 密集任务
虚拟线程的收益来自「阻塞时卸载、把载体让出去」。CPU 密集任务几乎不阻塞,虚拟线程从头到尾占着载体,既得不到卸载红利,又比平台线程多一层调度开销。因此:
| 任务类型 | 虚拟线程是否合适 | 原因 |
|---|---|---|
| 阻塞 I/O(HTTP、JDBC、文件) | 合适 | 阻塞时可卸载,载体复用率高 |
| 大量并发短请求 | 合适 | 线程创建成本低,无需调池大小 |
| CPU 密集计算 | 不合适 | 不阻塞,无法卸载,只增调度开销 |
长时间持有 synchronized | 谨慎(JDK 21) | 会钉住载体,见 7.2.3 |
依赖 ThreadLocal 缓存的场景 | 谨慎 | 每请求一份副本,内存放大 |
判断方法:用 JFR 或线程转储看线程状态分布——如果大量时间在 RUNNABLE(而非 WAITING / TIMED_WAITING),说明是 CPU 密集,虚拟线程帮不上忙。本机没有 async-profiler,涉及它的输出一律视为「示例输出」。
7.2.8 与 @Async / TaskExecutor 的关系
@Async 方法最终由 AsyncExecutionInterceptor 提交到一个 AsyncTaskExecutor 上执行(本机 spring-aop-7.0.9.jar javap 核实)。它默认解析顺序是:先找方法/类上 @Async("xxx") 指定的限定符对应 bean,找不到就用默认执行器;Spring Boot 的默认执行器就是名为 applicationTaskExecutor 的 bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME,javap 核实其值为 applicationTaskExecutor)。
所以在虚拟线程模式下,「@Async 方法跑在哪种线程上」直接由 applicationTaskExecutor 决定:
@Service
public class OrderNotificationListener {
@Async // 使用 applicationTaskExecutor
public void notifyDownstream(Long orderId) {
// 若开启虚拟线程,这里跑在虚拟线程上
Thread t = Thread.currentThread();
log.info("notify order={} virtual={} name={}", orderId, t.isVirtual(), t.getName());
}
}
若想给通知单独用虚拟线程、不影响全局默认执行器,可以自己声明一个 AsyncTaskExecutor:
@Bean("notificationExecutor")
AsyncTaskExecutor notificationExecutor() {
return new VirtualThreadTaskExecutor("notify-");
}
org.springframework.core.task.VirtualThreadTaskExecutor 在 spring-core-7.0.9.jar(本机 javap 核实,构造器接受线程名前缀,实现 AsyncTaskExecutor)。随后用 @Async("notificationExecutor") 指定它。
注意:@Async 的代理与 @Transactional 的代理叠加时,二者都基于 AOP;调用顺序按 advisor 的 order 决定,通常 @Transactional 在外层。若 @Async 方法内部又要读事务,务必想清它是不是在事务边界之外执行。
7.2.9 迁移与验证清单
- 确认 JDK 21+,然后开
spring.threads.virtual.enabled=true。 - 删掉对
spring.task.execution.pool.*的调优——在虚拟线程模式下它们不生效,留着会误导。 - 审查
synchronized里的阻塞点,热点路径改用ReentrantLock(JDK 21 下仍会 pinning 时才需要)。 - 重新评估连接池上限:并发上限从线程池转移到连接池,
HikariCP的maximumPoolSize往往成为真正的瓶颈。 - 验证。 在 controller 与
@Async方法里各打一行Thread.currentThread().isVirtual(),本机实测主线程为false;开启虚拟线程后请求线程与@Async线程应打印true。启动日志里 Tomcat 的请求线程名也会从nio-8080-exec-*变为虚拟线程命名。
一段可直接跑的观察程序(本机 Temurin 21.0.12.1 实测):
public class VirtualProbe {
public static void main(String[] args) throws Exception {
System.out.println("main.isVirtual=" + Thread.currentThread().isVirtual());
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
var f = exec.submit(() -> Thread.currentThread().isVirtual());
System.out.println("task.isVirtual=" + f.get());
}
}
}
main.isVirtual=false
task.isVirtual=true
7.2.10 常见误区
- 「虚拟线程池」并不存在。
Executors.newVirtualThreadPerTaskExecutor()每次提交都新建线程、用完即弃,没有池化复用。想限流只能加信号量或并发上限,core/max/queue那套参数无从谈起。 - 开了开关不等于全站虚拟线程。 见 7.2.5 的「没有被改变的部分」,手写线程池与第三方线程不受影响。
- 虚拟线程不是性能银弹。 它提升的是「阻塞场景下的并发承载量」,不是单任务速度;CPU 密集场景切过去只会更慢。
小结
- 虚拟线程靠「阻塞时卸载、载体复用」获得吞吐,载体来自并行度等于核数的
ForkJoinPool。 - JDK 21 下
synchronized内阻塞会钉住载体(本机实测reason:MONITOR,耗时从 321 ms 劣化到 1239 ms);JDK 24 的 JEP 491 已修复这一情形,native 调用仍会钉住。 spring.threads.virtual.enabled=true会替换 Servlet 请求处理、applicationTaskExecutor、taskScheduler,并使spring.task.execution.pool.*失效;thread-name-prefix与TaskDecorator仍生效。- 虚拟线程适合阻塞 I/O,不适合 CPU 密集;
@Async跑在哪个执行器上,由applicationTaskExecutor或限定符指定的执行器决定。
下一节回到线程池本身:当不能或不想全量切到虚拟线程时,ThreadPoolTaskExecutor 的参数、队列策略与上下文传递才是要盯紧的地方。
阅读导航:上一节:7.1 事务同步与事件 · 下一节:7.3 并发编程与线程池模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。