虚拟线程(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.OfVirtualThread.ofPlatform() 返回 Thread.Builder.OfPlatform,两者共享 .name(...).daemon(...).unstarted(...).start(...).factory() 等方法,链式调用后返回类型由各自的子接口收窄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class VirtualThreadBasics {

public static void main(String[] args) throws InterruptedException {
Thread virtual = Thread.ofVirtual()
.name("vt-worker-", 0)
.start(() -> System.out.println("virtual=" + Thread.currentThread().isVirtual()));
virtual.join();

Thread platform = Thread.ofPlatform()
.name("pt-worker")
.daemon(true)
.start(() -> System.out.println("virtual=" + Thread.currentThread().isVirtual()));
platform.join();

// 最简形式:创建并立即启动
Thread.startVirtualThread(() -> System.out.println("fire and forget"));
}
}

这里有两组容易踩的行为差异。虚拟线程永远是守护线程Thread.setDaemon(boolean) 对它无效;虚拟线程的优先级固定为 Thread.NORM_PRIORITYThread.setPriority(int) 同样无效。此外虚拟线程不是线程组的活跃成员,对它调用 Thread.getThreadGroup() 只会返回一个名为 VirtualThreads 的占位组。

如果不想显式管理 Thread 对象,用 Executors 工厂更贴合现有代码。Executors.newVirtualThreadPerTaskExecutor() 每提交一个任务就新建一个虚拟线程,是最常用的入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;

public class VirtualThreadPerTask {

public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
}));
} // close() 在这里被隐式调用,并阻塞到全部任务结束
System.out.println("all done");
}
}

一万个任务各睡一秒,这段代码的耗时是一秒出头,而不是一万秒。如果换成 Executors.newCachedThreadPool(),它会真的去创建一万条平台线程,视机器配置可能直接把进程拖垮;换成 Executors.newFixedThreadPool(200) 则退化成 200 并发、总耗时约 50 秒。

第三种方式是拿 ThreadFactory 去适配那些只接受工厂而不接受执行器的框架 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;

public class ThreadFactoryDemo {

public static void main(String[] args) {
ThreadFactory factory = Thread.ofVirtual().name("order-", 0).factory();
try (ExecutorService executor = Executors.newThreadPerTaskExecutor(factory)) {
executor.submit(() -> System.out.println(Thread.currentThread().getName()));
executor.submit(() -> System.out.println(Thread.currentThread().getName()));
}
}
}

Executors.newThreadPerTaskExecutor(ThreadFactory) 也是 JDK 21 引入的:它对每个任务新建一条线程,具体是虚拟线程还是平台线程完全由传入的工厂决定,所以同一个 API 既能跑虚拟线程也能跑平台线程做对照实验。

不要池化虚拟线程

池化解决的是什么问题

线程池存在的唯一理由是平台线程的创建与内存成本高昂,池化把这份成本摊薄到多次复用上。虚拟线程的成本结构完全不同:它的栈以堆上的 stack chunk 对象形式存在,按需扩容缩容,初始栈很浅,一个虚拟线程可能只执行一次 HTTP 调用或一次 JDBC 查询。让它跑完就死,比留下来复用更省。

JEP 444 的原文是:虚拟线程廉价且数量充足,永远不应该被池化,每个应用任务都应该创建一条新虚拟线程。

载体线程是共享的,虚拟线程是消耗品

直觉上的冲突在于:很多人把「线程数量」当成一种需要被保护的资源。在虚拟线程模型里,稀缺的资源是载体线程,而载体线程由调度器统一管理,不需要业务代码操心。newVirtualThreadPerTaskExecutor() 本身不含池,它不会拒绝任务、不会让任务排队,也就不会成为吞吐的瓶颈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

public class ScaleNotSpeed {

public static void main(String[] args) {
AtomicInteger done = new AtomicInteger();
long start = System.nanoTime();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 5_000; i++) {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
done.incrementAndGet();
});
}
}
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
System.out.println("done=" + done.get() + ", elapsed=" + elapsedMs + "ms");
}
}

这个例子里任务是 sleep,属于「几乎不占 CPU、纯等待」的类型。如果把这 5000 个任务换成 5000 次排序,elapsed 不会比用固定大小平台线程池快,甚至更慢——虚拟线程不会让代码跑得更快,它只提供更大的并发规模。

调度器的规模由两个系统属性控制:jdk.virtualThreadScheduler.parallelism 决定可用于执行的载体线程数,默认等于 Runtime.getRuntime().availableProcessors()jdk.virtualThreadScheduler.maxPoolSize 决定载体线程数的上限,默认值是 max(parallelism, 256)。把它们当成「应用线程池大小」来调是在解决错误的问题,只有大量固定(下节)确实存在时才考虑抬高上限做临时缓解。

ExecutorService 的 AutoCloseable 语义

close() 与 shutdown() 的差别

ExecutorServiceJava 19 起继承 AutoCloseable,这是 try-with-resources 能直接用在执行器上的前提。它的 close() 是一个 default 方法,语义是「有序关闭并等待终止」:

  • 拒绝新提交的任务(等价于 shutdown());
  • 等待已提交任务执行完毕、执行器进入 terminated 状态;
  • 若等待过程中当前线程被中断,则退化为 shutdownNow(),随后继续等待任务结束。

shutdown() 的区别在于 shutdown() 不等待,它只发起关闭并立刻返回,需要自己再补 awaitTerminationclose() 把这两步合并了,所以在大批量提交场景下,try-with-resources 的右花括号是一个真实的同步点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;

public class CloseSemantics {

public static void main(String[] args) {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> System.out.println("task 1"));

executor.close(); // 等价于 shutdown() + awaitTermination(),返回时任务已结束

try {
executor.submit(() -> System.out.println("task 2"));
} catch (RejectedExecutionException e) {
System.out.println("closed executor rejects new tasks");
}
}
}

关闭行为带来的两个实务约束

第一,不要在长生命周期对象里持有它。把 newVirtualThreadPerTaskExecutor() 放进 try 的括号里,意味着执行器的生命周期被限制在这段代码块内。反过来,如果把它作为单例字段长期持有,就等于造了一个没有上限的任务接受器,同时失去了「块结束即全部完成」这个同步保证。

第二,关闭期间不接受新任务。在一个提交任务的循环里,如果某次提交已经触发关闭(例如异常路径上提前退出 try 块),后续提交会抛 RejectedExecutionException。需要「部分失败不影响其余任务」时,要么在 try 块内部把任务全部提交完再退出,要么显式收集每个 Future 的异常而不是让第一个异常逃逸。

什么时候是正优化,什么时候是负优化

判断标准只有一条:任务在等待上花的时间占比。JEP 444 给出的两个条件是「并发任务数量高(数千以上)」且「工作负载不是 CPU 密集」。

适用:高并发阻塞式 IO

典型场景是每请求一线程的服务器端代码:一次 HTTP 调用、一次 JDBC 查询,线程在这期间几乎不消耗 CPU。JDBC 这类阻塞 API 正是虚拟线程的主场,因为绝大多数 JDBC 驱动在等待网络往返时会让出载体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;

public final class UserQuery {

private final DataSource dataSource;

public UserQuery(DataSource dataSource) {
this.dataSource = dataSource;
}

public String findName(long id) throws SQLException {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement =
connection.prepareStatement("select name from users where id = ?")) {
statement.setLong(1, id);
try (ResultSet resultSet = statement.executeQuery()) {
return resultSet.next() ? resultSet.getString(1) : null;
}
}
}
}

UserQuery 放进 newVirtualThreadPerTaskExecutor() 里跑,瓶颈会干净地暴露在连接池上:DataSourcemaximumPoolSize(HikariCP 默认 10)会立刻成为并发天花板,虚拟线程全部堵在获取连接上。这是下一节要展开的问题,也是迁移时最容易漏掉的一处。

不适用:CPU 密集型

CPU 密集型任务在虚拟线程上没有收益,因为此时稀缺资源是核心数而不是线程数。给计算任务套虚拟线程,只会额外付出挂载/卸载与调度的开销:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;

public class CpuBoundTask implements Runnable {

private final int size;

public CpuBoundTask(int size) {
this.size = size;
}

@Override
public void run() {
int[] data = new int[size];
for (int i = 0; i < size; i++) {
data[i] = ThreadLocalRandom.current().nextInt();
}
Arrays.sort(data);
System.out.println(data[0]);
}
}

这类任务应该继续用平台线程池(大小约等于核心数),或者干脆用并行流。虚拟线程还有一个与实际计算量相关的硬限制:它的栈以 stack chunk 对象存放在堆里,G1 不支持 humongous 对象,当虚拟线程的栈增长到区域大小的一半(可能低至 512KB)时,可能抛出 StackOverflowError。深递归的调用链不适合放在虚拟线程上。

限流要靠 Semaphore,不要靠线程池容量

传统代码常把线程池大小当限流开关用:把并发压到 20,服务就不会被打爆。改用虚拟线程后这个开关消失了,必须显式引入 Semaphore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

public class RateLimitedClient {

private final Semaphore permits = new Semaphore(20);
private final ExecutorService executor;

public RateLimitedClient(ExecutorService executor) {
this.executor = executor;
}

public void fetch(String id) {
executor.submit(() -> {
try {
permits.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
callRemoteService(id);
} finally {
permits.release();
}
});
}

private void callRemoteService(String id) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("fetched " + id);
}

public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
RateLimitedClient client = new RateLimitedClient(executor);
for (int i = 0; i < 1_000; i++) {
client.fetch("id-" + i);
}
}
}
}

这里的关键是 Semaphore.acquire() 在虚拟线程上会优雅地 park:拿不到许可的虚拟线程会卸载、释放载体,不会占用任何平台线程。同样的道理适用于所有基于 LockSupport 的并发工具——ReentrantLockSemaphoreBlockingQueue 在虚拟线程上都能正确让出载体。

所有等待者仍然会各自持有一份栈,一万个虚拟线程在信号量上排队,代价是一万份堆上的栈对象。限流是为了保护下游,不是限制内存占用;对内存敏感的场景,要在提交端做背压。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;

public class SynchronizedFetch {

private final Object lock = new Object();

public String fetch(String endpoint) throws IOException {
synchronized (lock) { // JDK 21~23:进入监视器后发生阻塞 IO,会固定载体
try (InputStream in = URI.create(endpoint).toURL().openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}
}

Object.wait() 在 JDK 21~23 上也固定载体,因为它必须在持有监视器时调用,而重新获取监视器同样要通过 JVM:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class BoundedBuffer<T> {

private final Object[] items;
private int head;
private int tail;
private int count;

public BoundedBuffer(int capacity) {
this.items = new Object[capacity];
}

public synchronized void put(T item) throws InterruptedException {
while (count == items.length) {
wait(); // JDK 21~23:wait() 与重新获取监视器都会固定载体
}
items[tail] = item;
tail = (tail + 1) % items.length;
count++;
notifyAll();
}

@SuppressWarnings("unchecked")
public synchronized T take() throws InterruptedException {
while (count == 0) {
wait();
}
T item = (T) items[head];
items[head] = null;
head = (head + 1) % items.length;
count--;
notifyAll();
return item;
}
}

这段时间里的标准做法是把热点路径上的 synchronized 换成 ReentrantLock,或者用 Condition 替代 wait/notify

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.locks.ReentrantLock;

public class LockBasedFetch {

private final ReentrantLock lock = new ReentrantLock();

public String fetch(String endpoint) throws IOException {
lock.lock();
try {
try (InputStream in = URI.create(endpoint).toURL().openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
} finally {
lock.unlock();
}
}
}

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() 唤醒并重新获取监视器后再回到调度器。

结果是:上面 SynchronizedFetchBoundedBuffer 里的固定问题在 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 的内存放大

虚拟线程支持 ThreadLocalInheritableThreadLocal,这是为了让既有库能不改代码就跑起来。但「支持」不等于「推荐」:线程局部变量的代价是与存活中的线程数成正比,虚拟线程的前提恰恰是并发数可以到百万级。

更糟的是从线程池迁移过来的老习惯——把昂贵资源(数据库连接、大缓冲区)放进 ThreadLocal,指望同一个线程上的多个任务复用它。虚拟线程每任务一线程,这个复用模型直接失效,等价于每个任务都创建一个昂贵资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.concurrent.Executors;

public class ThreadLocalCost {

private static final ThreadLocal<byte[]> BUFFER =
ThreadLocal.withInitial(() -> new byte[64 * 1024]);

public static void main(String[] args) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> BUFFER.get()[0] = 1);
}
}
// 10 万个虚拟线程同时存活时,每个各持一份 64KB 缓冲区,合计约 6.4GB
}
}

定位这类问题有一个专门的开关:-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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RequestContext {

private static final ScopedValue<String> USER = ScopedValue.newInstance();

public static void handle(String userId) {
ScopedValue.where(USER, userId).run(() -> {
System.out.println("user=" + USER.get());
serviceLayer();
});
}

private static void serviceLayer() {
System.out.println("still " + USER.get());
}

public static void main(String[] args) {
handle("u-42");
}
}

几个使用要点:ScopedValue.where(key, value) 返回一个 Carrier,用 .run(Runnable) 执行无返回值的块、用 .call(Callable) 执行有返回值的块;get() 在未绑定的作用域内调用会抛异常,需要判空时用 USER.isBound()USER.orElse(...)。JDK 25 转正时还做了一处清理:orElse 不再接受 null 作为参数。

由于绑定是「作用域」而不是「线程」的属性,它在结构化并发下会自然地向子任务传播——子线程能读到父作用域绑定的值,而父作用域结束后值即刻失效。

观测与排障

调度器参数

虚拟线程调度器的关键参数都是 JVM 系统属性:

1
2
3
4
5
6
7
8
9
10
11
# 载体线程数,默认等于 Runtime.getRuntime().availableProcessors()
-Djdk.virtualThreadScheduler.parallelism=8

# 载体线程数上限,默认 max(parallelism, 256)
-Djdk.virtualThreadScheduler.maxPoolSize=256

# 虚拟线程设置线程局部变量时打印栈,用于清理遗留 ThreadLocal
-Djdk.traceVirtualThreadLocals=true

# 设为 false 时,Thread.Builder 创建的虚拟线程不再总是被跟踪,可能不出现在新式线程 dump 中
-Djdk.trackAllThreads=false

这些参数调整的是调度器,不是应用线程池。调优顺序:先确认没有大量固定,再考虑抬高 maxPoolSize 作为过渡手段。

线程 dump

传统 jstack/jcmd Thread.print 给出的是平铺列表,适合几十到几百条平台线程,不适合成千上万条虚拟线程。JDK 21 起提供了新的 dump 命令,可以把平台线程与虚拟线程按「线程容器」(ThreadPerTaskExecutor、结构化并发作用域等)分组输出:

1
2
jcmd <pid> Thread.dump_to_file -format=json threads.json
jcmd <pid> Thread.dump_to_file -format=json -overwrite=true threads.json

新格式不包含对象地址、锁信息、JNI 统计和堆统计;生成过程不会暂停应用;虚拟线程的条目带有 virtual: true,并按容器分组,因此可以直接看出某个执行器下堆积了多少虚拟线程。另外 ThreadMXBean 只覆盖平台线程,findDeadlockedThreads() 找不到虚拟线程之间的死锁环,排查虚拟线程死锁要靠线程 dump 加人工分析。

JFR 事件

jdk.VirtualThreadPinned 在虚拟线程「阻塞且被固定」时记录,默认启用,阈值 20ms,因此生产环境可以直接用它来定位固定热点。JDK 24 起该事件被增强,会同时给出固定原因与载体线程标识,查看方式:

1
2
jcmd <pid> JFR.start duration=200s filename=recording.jfr
jfr print --events jdk.VirtualThreadPinned recording.jfr

另外两个事件用于观测生命周期与提交失败:jdk.VirtualThreadStartjdk.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
2
3
4
spring:
threads:
virtual:
enabled: true

打开之后受影响的组件不止 Web 容器:自动配置的 AsyncTaskExecutor 会从 ThreadPoolTaskExecutor 换成使用虚拟线程的 SimpleAsyncTaskExecutor@EnableAsync 的异步任务、Spring MVC 的异步请求处理、Spring WebFlux 的阻塞执行支持、Spring WebSocket 的入站出站消息通道、JPA 的 bootstrap 执行器以及 ApplicationContext 的后台初始化都会跟着切换到虚拟线程。调度侧同理:开启后自动配置的调度器是使用虚拟线程的 SimpleAsyncTaskScheduler,且会忽略所有池化相关属性spring.task.scheduling.pool.* 不再有意义)。

验证是否生效,最直接的办法是在处理请求的线程上打印 isVirtual()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ProbeController {

@GetMapping("/probe")
public String probe() {
Thread current = Thread.currentThread();
return "name=" + current.getName()
+ ", virtual=" + current.isVirtual()
+ ", threadId=" + current.threadId();
}
}

前置条件与边界

  • 只写 spring.threads.virtual.enabled=true 而没有嵌入式的 Tomcat/Jetty 上下文(比如纯批处理应用)时,影响范围仅限于任务执行器与调度器,别指望它改变 HTTP 层的并发模型。
  • 数据库连接池不会被自动放大。Tomcat 的请求线程数不再是限制之后,spring.datasource.hikari.maximum-pool-size 会立刻成为新瓶颈,需要按下游数据库的实际吞吐重新评估。
  • 一些依赖 ThreadLocal 传递上下文的组件(安全上下文、MDC 日志上下文、事务同步)在虚拟线程下仍能工作,但每次请求线程都是新的,任何「线程复用」假设都不再成立;日志的 traceId 传递要确认 MDC 在任务边界上被正确复制。

迁移检查清单

从线程池迁到虚拟线程,按下面的顺序检查可以把大部分坑挡在前面:

  1. 执行器替换Executors.newFixedThreadPool(n)newCachedThreadPool() 换成 Executors.newVirtualThreadPerTaskExecutor(),并让生命周期由 try-with-resources 管理。
  2. 限流补位:所有「靠池大小限流」的地方补上 Semaphore,并把许可数设置成下游能承受的并发,而不是原来线程池的大小。
  3. 连接池重估:数据库、HTTP 客户端、Redis 客户端的连接池上限,从「等于线程数」改为「按下游容量」,通常需要配套超时与熔断。
  4. ThreadLocal 清理:把缓存昂贵资源的 ThreadLocal 改成显式的池或缓存;迁移期用 -Djdk.traceVirtualThreadLocals=true 定位残留。
  5. 固定排查:JDK 21~23 上把热点 synchronized 换成 ReentrantLock;JDK 24 及以上优先看 JFR 的 jdk.VirtualThreadPinned,只处理真实存在的固定。
  6. 第三方库体检:JNI/FFM 密集的库、在锁内做网络 IO 的库、把连接缓存在 ThreadLocal 里的库,是最常见的三类问题源。
  7. CPU 密集任务隔离:计算密集的任务单独用平台线程池(大小约等于核心数),不要和 IO 任务共用虚拟线程执行器。

一个把上述几点合起来的批量导入例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

public class BatchImporter {

private final Semaphore downstreamLimit = new Semaphore(32);

public int importAll(List<String> ids) {
AtomicInteger ok = new AtomicInteger();
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (String id : ids) {
executor.submit(() -> {
try {
downstreamLimit.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
try {
if (importOne(id)) {
ok.incrementAndGet();
}
} finally {
downstreamLimit.release();
}
});
}
}
return ok.get();
}

private boolean importOne(String id) {
try {
Thread.sleep(Duration.ofMillis(10));
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
}

这段代码同时体现了几条原则:任务数由数据量决定而不是由池大小决定,并发上限由信号量表达,执行器的作用域被限制在一个方法内,任务的异常与中断都在任务内部处理,不会让个别失败影响整批。

总结

  • 虚拟线程是 M:N 调度的实现,JDK 自己的调度器(work-stealing ForkJoinPool,FIFO 模式)把大量虚拟线程挂载到少量载体线程上;载体对 Java 代码不可见。
  • 虚拟线程不该被池化,池化解决的是平台线程昂贵的问题,而虚拟线程的栈在堆上、按需伸缩,用完即弃更划算。要限流就用 Semaphore,要控制规模就靠下游容量而不是线程池大小。
  • ExecutorService 自 Java 19 起是 AutoCloseableclose() 等于有序关闭并等待终止,因此 try-with-resources 的右花括号是一个真实的同步点,同时也意味着关闭后不再接受新任务。
  • 适用面是「高并发 + 阻塞等待」:HTTP 调用、JDBC、每请求一线程模型。CPU 密集任务不受益,深递归栈还有 humongous 对象的限制。
  • 固定问题在 JDK 24 被 JEP 491 大幅消除,synchronizedObject.wait() 不再固定载体;native/FFM 回调与类加载相关场景仍然固定。JDK 21~23 上仍需用 ReentrantLock 规避热点固定。
  • ThreadLocal 的代价随存活线程数线性增长,在虚拟线程下被放大;Scoped Values 由 JEP 506 在 JDK 25 转正,是作用域更清晰、生命周期更短的替代方案。
  • 观测手段:调度器参数(jdk.virtualThreadScheduler.parallelismmaxPoolSize)、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、调度器等多个子系统,但不会自动放大数据库连接池。

参考资料

系列索引:Java 系列,语言特性与运行时的长文集