《Spring Boot 高级》7.2 虚拟线程下的 Spring

从载体线程与挂载/卸载讲起,用本机 JDK 21.0.12.1 实测复现 synchronized 造成的 pinning(含 tracePinnedThreads 输出),再逐项列出 spring.threads.virtual.enabled 打开了哪些开关、为什么虚拟线程救不了 CPU 密集任务、以及它与 @Async 的接合点。

本节目标:把「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-*)虚拟线程
applicationTaskExecutorThreadPoolTaskExecutor配置了虚拟线程的 SimpleAsyncTaskExecutor
taskSchedulerThreadPoolTaskScheduler配置了虚拟线程的 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 迁移与验证清单

  1. 确认 JDK 21+,然后开 spring.threads.virtual.enabled=true。
  2. 删掉对 spring.task.execution.pool.* 的调优——在虚拟线程模式下它们不生效,留着会误导。
  3. 审查 synchronized 里的阻塞点,热点路径改用 ReentrantLock(JDK 21 下仍会 pinning 时才需要)。
  4. 重新评估连接池上限:并发上限从线程池转移到连接池,HikariCP 的 maximumPoolSize 往往成为真正的瓶颈。
  5. 验证。 在 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 并发编程与线程池模型 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计