JDK 21 上 synchronized 里做阻塞会把载体线程钉住,JDK 24 起(JEP 491)这个问题基本没了。ThreadLocal 当缓存用会失效,传上下文更推荐 Java 25 正式版的 ScopedValue。Spring Boot 4 里开虚拟线程只要一行配置,下面每个结论都用同一个 Spring Boot 4.1.1 项目在 JDK 21 和 JDK 25 上各跑了一遍。

实验环境

项目是 Spring Boot 4.1.1,Web 部分只依赖 spring-boot-starter-webmvc。JDK 用了 21.0.12.1(Debian OpenJDK)和 25.0.4.1(Temurin),启动参数加 -Djdk.virtualThreadScheduler.parallelism=2,把载体线程压到 2 个,现象更明显。

配置只有一行:

spring.threads.virtual.enabled=true

打开后 Tomcat 处理请求用虚拟线程,注入的 applicationTaskExecutor 也变成基于虚拟线程的 SimpleAsyncTaskExecutor。写个接口打印当前线程:

@RestController
class HelloController {
    @GetMapping("/thread")
    String thread() {
        return Thread.currentThread().toString();
    }
}
$ curl localhost:18080/thread
VirtualThread[#1079,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1

启动日志里任务执行器同样是虚拟线程:

java.version = 25.0.4.1
executor = SimpleAsyncTaskExecutor
task thread = VirtualThread[#44,task-1]/runnable@ForkJoinPool-1-worker-1

1. synchronized 还会钉住载体线程吗

先看虚拟线程的 pinning(钉住)。以前的常见说法是「synchronized 里别做阻塞 IO,换 ReentrantLock」,这个结论已经随 JDK 版本变了。

JDK 21 里虚拟线程在 synchronized 块中阻塞时没法从载体线程上卸下来,载体被占住,其他虚拟线程只能排队。JDK 24 的 JEP 491 让 synchronized 也能卸载,所以 JDK 25 LTS 上这条限制基本不存在了。

验证代码:20 个任务,每个拿自己的锁,在锁里 sleep 100ms。

@Component
@Order(1)
class PinningDemo implements CommandLineRunner {

    private final AsyncTaskExecutor executor;

    PinningDemo(AsyncTaskExecutor applicationTaskExecutor) {
        this.executor = applicationTaskExecutor;
    }

    @Override
    public void run(String... args) throws Exception {
        long start = System.currentTimeMillis();
        List<Future<?>> futures = new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            Object lock = new Object();
            futures.add(executor.submit(() -> {
                synchronized (lock) {
                    try {
                        Thread.sleep(100); // 模拟在锁里做一次阻塞 IO
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }
            }));
        }
        for (Future<?> f : futures) {
            f.get();
        }
        System.out.println("20 tasks (synchronized + sleep 100ms) took "
                + (System.currentTimeMillis() - start) + " ms");
    }
}

JDK 21,加 -Djdk.tracePinnedThreads=short:

VirtualThread[#42,task-3]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR
    com.example.vtdemo.PinningDemo.lambda$run$1(PinningDemo.java:35) <== monitors:1
20 tasks (synchronized + sleep 100ms) took 1005 ms

JDK 25,同一份代码:

20 tasks (synchronized + sleep 100ms) took 108 ms

2 个载体、20 个任务、每个 100ms,JDK 21 正好是 20 ÷ 2 × 100 ≈ 1 秒,说明锁里的 sleep 一直占着载体。JDK 25 上 20 个任务几乎同时睡完。

还有几个相关细节:

  • JDK 25 上也不是完全不会 pin。JEP 491 里写明,调用本地方法(native)或外部函数时阻塞、在类初始化里阻塞,仍然会钉住。
  • jdk.tracePinnedThreads 参数在 JDK 24 已经去掉,排查改用 JFR 的 jdk.VirtualThreadPinned 事件。
  • 还在 JDK 21 上的项目,做法照旧是热点路径里的锁换 ReentrantLock,或者把阻塞调用挪到锁外面,早先那篇 Java 21 虚拟线程的锁 分析过。

2. ThreadLocal 在虚拟线程下还好用吗

虚拟线程是「一个任务一个线程、用完就扔」,这对 ThreadLocal 的两种用法影响不一样。

ThreadLocal 能用,功能没变。问题出在拿它做「每线程缓存」:平台线程池里线程会复用,缓存能命中;虚拟线程不池化,每个任务都是新线程,缓存每次都重新建,等于没缓存,还多占内存。

static final AtomicInteger CREATED = new AtomicInteger();
static final ThreadLocal<SimpleDateFormat> FORMAT = ThreadLocal.withInitial(() -> {
    CREATED.incrementAndGet();
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
});

// 分别在 8 线程的固定线程池和 applicationTaskExecutor 上提交 1000 个任务
futures.add(submit.apply(() -> FORMAT.get().format(new java.util.Date())));

两个 JDK 上结果一致:

platform pool(8): SimpleDateFormat created 8 times
virtual threads : SimpleDateFormat created 1000 times

还有几个相关细节:

  • 给虚拟线程建池复用是常见误区。虚拟线程本来就便宜,池化反而丢掉了它的好处。缓存类对象换成线程安全的实现,比如这里直接用 DateTimeFormatter。
  • 需要限流时,用信号量或执行器自带的并发上限控制同时访问下游的数量,而不是限制线程数。之前写过 Java 21 虚拟线程如何限流控制吞吐量。

3. 请求上下文怎么传:ThreadLocal 还是 ScopedValue

Java 25 里 ScopedValue 已经转正(JEP 506),下面对比它和 ThreadLocal 的区别,以及能不能直接替代。

ScopedValue 不可变,只在 where(...).run(...) 这段作用域里有值,出了作用域自动失效,不存在忘记 remove 的问题。ThreadLocal 可以随时 set,要靠人记得清理。

下面这段只能在 JDK 25 上编译(项目里用 Maven profile 单独加了一个源码目录):

static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
static final ThreadLocal<String> TRACE_ID_TL = new ThreadLocal<>();

@Override
public void run(String... args) throws Exception {
    ScopedValue.where(TRACE_ID, "req-42").run(() -> {
        System.out.println("in scope        : " + TRACE_ID.get());
        try {
            executor.submit(() -> System.out.println("executor task   : bound=" + TRACE_ID.isBound())).get();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    });
    System.out.println("after scope     : bound=" + TRACE_ID.isBound());

    TRACE_ID_TL.set("req-43");
    // 忘了 remove(),这个值会一直挂在当前线程上
    System.out.println("ThreadLocal left: " + TRACE_ID_TL.get());
}
in scope        : req-42
executor task   : bound=false
after scope     : bound=false
ThreadLocal left: req-43

还有几个相关细节:

  • 注意第二行。丢给 applicationTaskExecutor 的任务拿不到 ScopedValue,它不会自动穿过普通执行器。能自动继承的只有结构化并发 StructuredTaskScope fork 出来的子任务,而结构化并发在 JDK 25 里还是预览特性。所以「换成 ScopedValue 就能跨线程传 traceId」这个说法不成立。
  • Spring 里的 MDC、事务、SecurityContext 内部仍然基于 ThreadLocal,现阶段不用自己去替换。异步任务要带上下文,Spring 的做法是给执行器配 TaskDecorator 做复制。
  • 结构化并发在 JDK 27 里的后续变化,之前那篇 Java 27 面试怎么答:G1 全面默认、Compact Object Headers、结构化并发 里讲过。

4. 开了虚拟线程,数据库连接池要不要调大

开了虚拟线程之后瓶颈会转移。线程不再稀缺之后,真正有限的是下游资源。

不用跟着调大,通常也调不大。以前 200 个 Tomcat 线程天然限住了并发,现在请求都能进来,大家一起去抢 HikariCP 的那 10 个连接,表现为获取连接超时,而不是线程打满。要做的是确认连接池大小匹配数据库承受能力,并在入口或调用下游处加并发上限。驱动层面的坑在 MySQL驱动扯后腿?Spring Boot用虚拟线程可能比用物理线程还差 里讲过,那篇的场景是旧版驱动里大量 synchronized 加上 JDK 21 的 pinning,换到 JDK 25 后重点就变成了连接数本身。

虚拟线程也不能让 CPU 密集型接口变快。它解决的是阻塞等待时占着线程的问题,计算量不会因此变少。

参考资料:JEP 444: Virtual Threads、JEP 491: Synchronize Virtual Threads without Pinning、JEP 506: Scoped Values、Spring Boot 文档:Virtual Threads。

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。