Java——虚拟线程实战
虚拟线程(Virtual Threads)由 JEP 444 在 JDK 21 转正,它把「一个请求一个线程」写法的成本从操作系统线程降到了 JVM 对象级别。
它买来的是吞吐(scale),不是延迟(speed):把 CPU 密集任务丢进去只会得到负数收益。
按「什么时候有用、什么时候是负优化」这条线,依次是调度结构、池化禁忌、限流方式、固定(pinning)问题、ThreadLocal 放大和排障手段。
虚拟线程的两层结构
平台线程、虚拟线程与载体
平台线程是操作系统线程的薄包装,一个平台线程在其整个生命周期内独占一条 OS 线程,所以它的数量上限就是 OS 线程的上限。虚拟线程同样是 java.lang.Thread 的实例,但不绑定到某条固定的 OS 线程:它只在需要执行 CPU 指令时才占有一条 OS 线程,遇到阻塞操作就把 OS 线程让出去。
被让出去的这条 OS 线程叫载体线程(carrier)。虚拟线程的调度器是 JDK 自己实现的,它是一个工作在 FIFO 模式下的 work-stealing ForkJoinPool,把大量(M)虚拟线程调度到少量(N)载体线程上,也就是 M:N 调度。这个 ForkJoinPool 与并行流使用的 common pool 是两个不同的池,后者是 LIFO 模式。
载体对 Java 代码是不可见的,这是使用上很重要的一条边界:
- 虚拟线程里调用
Thread.currentThread()永远返回虚拟线程自身,拿不到载体; - 载体与虚拟线程的栈是分开的,虚拟线程抛出的异常里不会出现载体的栈帧;
- 载体的 ThreadLocal 对虚拟线程不可见,反之亦然。
也就是说,不要试图在业务代码里「感知载体」。要看载体,只能靠线程 dump 或 JFR。
创建虚拟线程的三种方式
Thread.Builder API 同时覆盖虚拟线程与平台线程两种构造器,Thread.ofVirtual() 返回 Thread.Builder.OfVirtual,Thread.ofPlatform() 返回 Thread.Builder.OfPlatform,两者共享 .name(...)、.daemon(...)、.unstarted(...)、.start(...)、.factory() 等方法,链式调用后返回类型由各自的子接口收窄。
1 | public class VirtualThreadBasics { |
这里有两组容易踩的行为差异。虚拟线程永远是守护线程,Thread.setDaemon(boolean) 对它无效;虚拟线程的优先级固定为 Thread.NORM_PRIORITY,Thread.setPriority(int) 同样无效。此外虚拟线程不是线程组的活跃成员,对它调用 Thread.getThreadGroup() 只会返回一个名为 VirtualThreads 的占位组。
如果不想显式管理 Thread 对象,用 Executors 工厂更贴合现有代码。Executors.newVirtualThreadPerTaskExecutor() 每提交一个任务就新建一个虚拟线程,是最常用的入口:
1 | import java.time.Duration; |
一万个任务各睡一秒,这段代码的耗时是一秒出头,而不是一万秒。如果换成 Executors.newCachedThreadPool(),它会真的去创建一万条平台线程,视机器配置可能直接把进程拖垮;换成 Executors.newFixedThreadPool(200) 则退化成 200 并发、总耗时约 50 秒。
第三种方式是拿 ThreadFactory 去适配那些只接受工厂而不接受执行器的框架 API:
1 | import java.util.concurrent.ExecutorService; |
Executors.newThreadPerTaskExecutor(ThreadFactory) 也是 JDK 21 引入的:它对每个任务新建一条线程,具体是虚拟线程还是平台线程完全由传入的工厂决定,所以同一个 API 既能跑虚拟线程也能跑平台线程做对照实验。
不要池化虚拟线程
池化解决的是什么问题
线程池存在的唯一理由是平台线程的创建与内存成本高昂,池化把这份成本摊薄到多次复用上。虚拟线程的成本结构完全不同:它的栈以堆上的 stack chunk 对象形式存在,按需扩容缩容,初始栈很浅,一个虚拟线程可能只执行一次 HTTP 调用或一次 JDBC 查询。让它跑完就死,比留下来复用更省。
JEP 444 的原文是:虚拟线程廉价且数量充足,永远不应该被池化,每个应用任务都应该创建一条新虚拟线程。
载体线程是共享的,虚拟线程是消耗品
直觉上的冲突在于:很多人把「线程数量」当成一种需要被保护的资源。在虚拟线程模型里,稀缺的资源是载体线程,而载体线程由调度器统一管理,不需要业务代码操心。newVirtualThreadPerTaskExecutor() 本身不含池,它不会拒绝任务、不会让任务排队,也就不会成为吞吐的瓶颈。
1 | import java.time.Duration; |
这个例子里任务是 sleep,属于「几乎不占 CPU、纯等待」的类型。如果把这 5000 个任务换成 5000 次排序,elapsed 不会比用固定大小平台线程池快,甚至更慢——虚拟线程不会让代码跑得更快,它只提供更大的并发规模。
调度器的规模由两个系统属性控制:jdk.virtualThreadScheduler.parallelism 决定可用于执行的载体线程数,默认等于 Runtime.getRuntime().availableProcessors();jdk.virtualThreadScheduler.maxPoolSize 决定载体线程数的上限,默认值是 max(parallelism, 256)。把它们当成「应用线程池大小」来调是在解决错误的问题,只有大量固定(下节)确实存在时才考虑抬高上限做临时缓解。
ExecutorService 的 AutoCloseable 语义
close() 与 shutdown() 的差别
ExecutorService 从 Java 19 起继承 AutoCloseable,这是 try-with-resources 能直接用在执行器上的前提。它的 close() 是一个 default 方法,语义是「有序关闭并等待终止」:
- 拒绝新提交的任务(等价于
shutdown()); - 等待已提交任务执行完毕、执行器进入 terminated 状态;
- 若等待过程中当前线程被中断,则退化为
shutdownNow(),随后继续等待任务结束。
与 shutdown() 的区别在于 shutdown() 不等待,它只发起关闭并立刻返回,需要自己再补 awaitTermination。close() 把这两步合并了,所以在大批量提交场景下,try-with-resources 的右花括号是一个真实的同步点。
1 | import java.util.concurrent.ExecutorService; |
关闭行为带来的两个实务约束
第一,不要在长生命周期对象里持有它。把 newVirtualThreadPerTaskExecutor() 放进 try 的括号里,意味着执行器的生命周期被限制在这段代码块内。反过来,如果把它作为单例字段长期持有,就等于造了一个没有上限的任务接受器,同时失去了「块结束即全部完成」这个同步保证。
第二,关闭期间不接受新任务。在一个提交任务的循环里,如果某次提交已经触发关闭(例如异常路径上提前退出 try 块),后续提交会抛 RejectedExecutionException。需要「部分失败不影响其余任务」时,要么在 try 块内部把任务全部提交完再退出,要么显式收集每个 Future 的异常而不是让第一个异常逃逸。
什么时候是正优化,什么时候是负优化
判断标准只有一条:任务在等待上花的时间占比。JEP 444 给出的两个条件是「并发任务数量高(数千以上)」且「工作负载不是 CPU 密集」。
适用:高并发阻塞式 IO
典型场景是每请求一线程的服务器端代码:一次 HTTP 调用、一次 JDBC 查询,线程在这期间几乎不消耗 CPU。JDBC 这类阻塞 API 正是虚拟线程的主场,因为绝大多数 JDBC 驱动在等待网络往返时会让出载体:
1 | import java.sql.Connection; |
把 UserQuery 放进 newVirtualThreadPerTaskExecutor() 里跑,瓶颈会干净地暴露在连接池上:DataSource 的 maximumPoolSize(HikariCP 默认 10)会立刻成为并发天花板,虚拟线程全部堵在获取连接上。这是下一节要展开的问题,也是迁移时最容易漏掉的一处。
不适用:CPU 密集型
CPU 密集型任务在虚拟线程上没有收益,因为此时稀缺资源是核心数而不是线程数。给计算任务套虚拟线程,只会额外付出挂载/卸载与调度的开销:
1 | import java.util.Arrays; |
这类任务应该继续用平台线程池(大小约等于核心数),或者干脆用并行流。虚拟线程还有一个与实际计算量相关的硬限制:它的栈以 stack chunk 对象存放在堆里,G1 不支持 humongous 对象,当虚拟线程的栈增长到区域大小的一半(可能低至 512KB)时,可能抛出 StackOverflowError。深递归的调用链不适合放在虚拟线程上。
限流要靠 Semaphore,不要靠线程池容量
传统代码常把线程池大小当限流开关用:把并发压到 20,服务就不会被打爆。改用虚拟线程后这个开关消失了,必须显式引入 Semaphore:
1 | import java.util.concurrent.ExecutorService; |
这里的关键是 Semaphore.acquire() 在虚拟线程上会优雅地 park:拿不到许可的虚拟线程会卸载、释放载体,不会占用任何平台线程。同样的道理适用于所有基于 LockSupport 的并发工具——ReentrantLock、Semaphore、BlockingQueue 在虚拟线程上都能正确让出载体。
所有等待者仍然会各自持有一份栈,一万个虚拟线程在信号量上排队,代价是一万份堆上的栈对象。限流是为了保护下游,不是限制内存占用;对内存敏感的场景,要在提交端做背压。
Pinning:从 JDK 21 到 JDK 24 的行为变化
固定(pinning)指虚拟线程在阻塞时无法卸载,从而把载体线程一并堵住。同一段 synchronized 代码在 JDK 21 与 JDK 24 上行为不同,迁移前先确认运行版本。
JDK 21 到 23:synchronized 与 Object.wait() 会固定
在 JDK 21~23 上,有两种固定场景:一是在 synchronized 块或方法内部阻塞,二是执行 native 方法或外部函数(FFM)。原因在 JVM 的实现细节里:JVM 记录监视器持有者时记录的是平台线程而不是虚拟线程,如果允许虚拟线程带着监视器卸载,载体被复用后新的虚拟线程会被 JVM 视为监视器持有者,互斥语义就丢了。所以 JVM 干脆不让它卸载。
1 | import java.io.IOException; |
Object.wait() 在 JDK 21~23 上也固定载体,因为它必须在持有监视器时调用,而重新获取监视器同样要通过 JVM:
1 | public class BoundedBuffer<T> { |
这段时间里的标准做法是把热点路径上的 synchronized 换成 ReentrantLock,或者用 Condition 替代 wait/notify:
1 | import java.io.IOException; |
ReentrantLock 基于 LockSupport.park,虚拟线程 park 时会释放载体,所以这套写法在 JDK 21~23 上是规避固定的正解。至于 synchronized 只保护内存操作的短临界区,本来就不需要改——固定只对「持锁期间做阻塞操作」的场景有害。
JDK 24 起(JEP 491):synchronized 不再固定
JEP 491《Synchronize Virtual Threads without Pinning》在 JDK 24 交付(该 JEP 的状态为 Closed / Delivered,Release 24)。它改造了 JVM 对监视器的记录方式,让虚拟线程可以独立于载体获取、持有、释放监视器:
- 在
synchronized方法或语句内部发生阻塞时,虚拟线程可以卸载,载体被释放; - 阻塞等待获取监视器时会卸载并释放载体,等监视器释放后再由调度器重新挂载(可能挂到另一条载体上);
Object.wait()及其带超时的变体同样会卸载,被notify()唤醒并重新获取监视器后再回到调度器。
结果是:上面 SynchronizedFetch 和 BoundedBuffer 里的固定问题在 JDK 24 及以上不再存在,迁移到 ReentrantLock 不再是必须做的事。JEP 491 也明确表示不必把已经改成 ReentrantLock 的代码回退成 synchronized,并且引用了《Java Concurrency in Practice》§13.4 的建议:新代码里能用 synchronized 就用,需要公平性、读写锁、可中断或超时获取锁时再用 java.util.concurrent.locks。
仍然会固定的场景
JEP 491 消除的是「几乎全部」由 synchronized 引起的固定,不是全部固定。以下场景在 JDK 24 及以上依旧会固定载体:
- 虚拟线程调用 native 方法(JNI)或通过 FFM 下行调用(downcall)进入本地代码,而该本地代码回调 Java 并发生阻塞或阻塞在监视器上。这类固定是必要的,因为本地代码与它所在的 OS 线程不可分割;
- 解析符号引用(JVMS §5.4.3)时因加载类而阻塞,栈上有 native 帧;
- 在类初始化器内部阻塞,同样是栈上 native 帧导致的固定;
- 等待另一个线程完成类初始化(JVMS §5.5),这是虚拟线程在 JVM 中阻塞的特殊情形。
这些情况通常不会成为瓶颈,但如果应用大量使用 JNI/FFM,或者启动期在某条虚拟线程上触发大量的类初始化,仍需要通过观测手段确认。
版本对照表
| 行为 | JDK 21 ~ 23 | JDK 24 及以上(JEP 491) |
|---|---|---|
synchronized 方法/语句内阻塞 |
固定载体 | 卸载,不固定 |
| 阻塞等待获取监视器 | 在 JVM 中阻塞,固定载体 | 卸载,不固定 |
Object.wait() |
固定载体,且载体会阻塞在 JVM 中 | 卸载,不固定 |
| native 方法 / FFM 下行调用 | 固定载体 | 仍固定(本地帧必须绑定 OS 线程) |
| 类加载、类初始化、等待类初始化 | 固定载体 | 仍固定 |
-Djdk.tracePinnedThreads=full|short |
生效,打印固定时的栈 | 移除,设置后无任何效果 |
| 观测固定 | JFR jdk.VirtualThreadPinned |
JFR jdk.VirtualThreadPinned(保留,并增强为携带固定原因与载体标识) |
ThreadLocal 与 ScopedValue
ThreadLocal 的内存放大
虚拟线程支持 ThreadLocal 和 InheritableThreadLocal,这是为了让既有库能不改代码就跑起来。但「支持」不等于「推荐」:线程局部变量的代价是与存活中的线程数成正比,虚拟线程的前提恰恰是并发数可以到百万级。
更糟的是从线程池迁移过来的老习惯——把昂贵资源(数据库连接、大缓冲区)放进 ThreadLocal,指望同一个线程上的多个任务复用它。虚拟线程每任务一线程,这个复用模型直接失效,等价于每个任务都创建一个昂贵资源:
1 | import java.util.concurrent.Executors; |
定位这类问题有一个专门的开关:-Djdk.traceVirtualThreadLocals=true 会在虚拟线程设置任何线程局部变量时打印栈,用来找出迁移过程中残留的 ThreadLocal 写入点。
ScopedValue 的状态与用法
ScopedValue 是 ThreadLocal 的替代方案:值是不可变的,绑定在有限的作用域上,作用域结束自动解除绑定,不存在忘记 remove() 导致的泄漏,也不需要为每个线程复制一份。
它的转正节奏:Scoped Values 以 JEP 429 在 JDK 20 孵化,随后经 JEP 446(JDK 21 首次预览)、JEP 464(JDK 22 第二次预览)、JEP 481(JDK 23 第三次预览)、JEP 487(JDK 24 第四次预览)。JEP 506 在 JDK 25 将其转正,从 JDK 25 起它是平台的一部分,不再需要 --enable-preview。
1 | public class RequestContext { |
几个使用要点:ScopedValue.where(key, value) 返回一个 Carrier,用 .run(Runnable) 执行无返回值的块、用 .call(Callable) 执行有返回值的块;get() 在未绑定的作用域内调用会抛异常,需要判空时用 USER.isBound() 或 USER.orElse(...)。JDK 25 转正时还做了一处清理:orElse 不再接受 null 作为参数。
由于绑定是「作用域」而不是「线程」的属性,它在结构化并发下会自然地向子任务传播——子线程能读到父作用域绑定的值,而父作用域结束后值即刻失效。
观测与排障
调度器参数
虚拟线程调度器的关键参数都是 JVM 系统属性:
1 | # 载体线程数,默认等于 Runtime.getRuntime().availableProcessors() |
这些参数调整的是调度器,不是应用线程池。调优顺序:先确认没有大量固定,再考虑抬高 maxPoolSize 作为过渡手段。
线程 dump
传统 jstack/jcmd Thread.print 给出的是平铺列表,适合几十到几百条平台线程,不适合成千上万条虚拟线程。JDK 21 起提供了新的 dump 命令,可以把平台线程与虚拟线程按「线程容器」(ThreadPerTaskExecutor、结构化并发作用域等)分组输出:
1 | jcmd <pid> Thread.dump_to_file -format=json threads.json |
新格式不包含对象地址、锁信息、JNI 统计和堆统计;生成过程不会暂停应用;虚拟线程的条目带有 virtual: true,并按容器分组,因此可以直接看出某个执行器下堆积了多少虚拟线程。另外 ThreadMXBean 只覆盖平台线程,findDeadlockedThreads() 找不到虚拟线程之间的死锁环,排查虚拟线程死锁要靠线程 dump 加人工分析。
JFR 事件
jdk.VirtualThreadPinned 在虚拟线程「阻塞且被固定」时记录,默认启用,阈值 20ms,因此生产环境可以直接用它来定位固定热点。JDK 24 起该事件被增强,会同时给出固定原因与载体线程标识,查看方式:
1 | jcmd <pid> JFR.start duration=200s filename=recording.jfr |
另外两个事件用于观测生命周期与提交失败:jdk.VirtualThreadStart 与 jdk.VirtualThreadEnd 默认关闭(百万级线程下全量记录不现实,需要时再单独打开),jdk.VirtualThreadSubmitFailed 默认启用,表示启动或唤醒虚拟线程失败(通常是资源问题)。
tracePinnedThreads 的现状
-Djdk.tracePinnedThreads 由 JEP 444 在 JDK 21 引入,用来在虚拟线程固定时打印栈(=full 打印完整栈并高亮 native 帧与持锁帧,=short 只打印问题帧)。它有两个已知缺陷:只在「阻塞于 synchronized 方法内部」时打印,不覆盖阻塞获取监视器与 Object.wait();而且它是在执行关键代码时同步打印栈,历史上导致过性能问题甚至卡死(JDK-8322846)。
JDK 24(JEP 491)移除了这个系统属性:在命令行上设置它将不会有任何效果。如果你的启动脚本、Dockerfile 或 CI 配置里还留着 -Djdk.tracePinnedThreads=full,应当直接删掉,改用 JFR 的 jdk.VirtualThreadPinned。在 JDK 21~23 上它仍然可用,作为过渡期的临时诊断手段是可以的,但不建议长期留在生产配置里。
框架集成:Spring Boot
开启虚拟线程
Spring Boot 从 3.2 起提供 spring.threads.virtual.enabled 开关,前置条件是运行在 Java 21 及以上(虚拟线程在 JDK 21 由 JEP 444 转正)。当前 Spring Boot 参考文档(含 4.x 系列)对此的描述没有变化,仍然是「使用 Java 21+ 且该属性为 true」时才生效:
1 | spring.threads.virtual.enabled=true |
1 | spring: |
打开之后受影响的组件不止 Web 容器:自动配置的 AsyncTaskExecutor 会从 ThreadPoolTaskExecutor 换成使用虚拟线程的 SimpleAsyncTaskExecutor,@EnableAsync 的异步任务、Spring MVC 的异步请求处理、Spring WebFlux 的阻塞执行支持、Spring WebSocket 的入站出站消息通道、JPA 的 bootstrap 执行器以及 ApplicationContext 的后台初始化都会跟着切换到虚拟线程。调度侧同理:开启后自动配置的调度器是使用虚拟线程的 SimpleAsyncTaskScheduler,且会忽略所有池化相关属性(spring.task.scheduling.pool.* 不再有意义)。
验证是否生效,最直接的办法是在处理请求的线程上打印 isVirtual():
1 | import org.springframework.web.bind.annotation.GetMapping; |
前置条件与边界
- 只写
spring.threads.virtual.enabled=true而没有嵌入式的 Tomcat/Jetty 上下文(比如纯批处理应用)时,影响范围仅限于任务执行器与调度器,别指望它改变 HTTP 层的并发模型。 - 数据库连接池不会被自动放大。Tomcat 的请求线程数不再是限制之后,
spring.datasource.hikari.maximum-pool-size会立刻成为新瓶颈,需要按下游数据库的实际吞吐重新评估。 - 一些依赖
ThreadLocal传递上下文的组件(安全上下文、MDC 日志上下文、事务同步)在虚拟线程下仍能工作,但每次请求线程都是新的,任何「线程复用」假设都不再成立;日志的 traceId 传递要确认 MDC 在任务边界上被正确复制。
迁移检查清单
从线程池迁到虚拟线程,按下面的顺序检查可以把大部分坑挡在前面:
- 执行器替换:
Executors.newFixedThreadPool(n)、newCachedThreadPool()换成Executors.newVirtualThreadPerTaskExecutor(),并让生命周期由 try-with-resources 管理。 - 限流补位:所有「靠池大小限流」的地方补上
Semaphore,并把许可数设置成下游能承受的并发,而不是原来线程池的大小。 - 连接池重估:数据库、HTTP 客户端、Redis 客户端的连接池上限,从「等于线程数」改为「按下游容量」,通常需要配套超时与熔断。
- ThreadLocal 清理:把缓存昂贵资源的
ThreadLocal改成显式的池或缓存;迁移期用-Djdk.traceVirtualThreadLocals=true定位残留。 - 固定排查:JDK 21~23 上把热点
synchronized换成ReentrantLock;JDK 24 及以上优先看 JFR 的jdk.VirtualThreadPinned,只处理真实存在的固定。 - 第三方库体检:JNI/FFM 密集的库、在锁内做网络 IO 的库、把连接缓存在 ThreadLocal 里的库,是最常见的三类问题源。
- CPU 密集任务隔离:计算密集的任务单独用平台线程池(大小约等于核心数),不要和 IO 任务共用虚拟线程执行器。
一个把上述几点合起来的批量导入例子:
1 | import java.time.Duration; |
这段代码同时体现了几条原则:任务数由数据量决定而不是由池大小决定,并发上限由信号量表达,执行器的作用域被限制在一个方法内,任务的异常与中断都在任务内部处理,不会让个别失败影响整批。
总结
- 虚拟线程是 M:N 调度的实现,JDK 自己的调度器(work-stealing
ForkJoinPool,FIFO 模式)把大量虚拟线程挂载到少量载体线程上;载体对 Java 代码不可见。 - 虚拟线程不该被池化,池化解决的是平台线程昂贵的问题,而虚拟线程的栈在堆上、按需伸缩,用完即弃更划算。要限流就用
Semaphore,要控制规模就靠下游容量而不是线程池大小。 ExecutorService自 Java 19 起是AutoCloseable,close()等于有序关闭并等待终止,因此 try-with-resources 的右花括号是一个真实的同步点,同时也意味着关闭后不再接受新任务。- 适用面是「高并发 + 阻塞等待」:HTTP 调用、JDBC、每请求一线程模型。CPU 密集任务不受益,深递归栈还有 humongous 对象的限制。
- 固定问题在 JDK 24 被 JEP 491 大幅消除,
synchronized与Object.wait()不再固定载体;native/FFM 回调与类加载相关场景仍然固定。JDK 21~23 上仍需用ReentrantLock规避热点固定。 ThreadLocal的代价随存活线程数线性增长,在虚拟线程下被放大;Scoped Values 由 JEP 506 在 JDK 25 转正,是作用域更清晰、生命周期更短的替代方案。- 观测手段:调度器参数(
jdk.virtualThreadScheduler.parallelism、maxPoolSize)、jcmd Thread.dump_to_file -format=json的新式线程 dump、JFR 的jdk.VirtualThreadPinned(默认启用、阈值 20ms)。-Djdk.tracePinnedThreads已在 JDK 24 随 JEP 491 移除,设置后无效果。 - Spring Boot 3.2 起用
spring.threads.virtual.enabled=true开启,前置条件是 Java 21+,影响 Web 容器、@Async、调度器等多个子系统,但不会自动放大数据库连接池。
参考资料
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- JEP 506: Scoped Values
- Java Core Libraries Developer Guide: Virtual Threads
- ExecutorService(Java SE 25 API)
- Spring Boot Reference: Task Execution and Scheduling
系列索引:Java 系列,语言特性与运行时的长文集






