Java 能在少量操作系统线程上跑十万个阻塞任务。看懂虚拟线程怎样挂载和卸载、什么情况下仍会被固定、ScopedValue 为什么能取代 ThreadLocal,以及 StructuredTaskScope 怎样取消失败的工作。
虚拟线程是一个 java.lang.Thread,开销和一个普通对象差不多。你可以启动十万个,让每个都阻塞在一次慢调用上,JVM 只用几个操作系统线程就能把它们全部跑起来。Java 21 让它成为正式特性,它也改变了服务器代码的写法:每个请求用一个普通的、阻塞的线程,又行得通了。
本文先讲虚拟线程,讲它怎样挂载到载体线程上、什么时候有用、什么情况下仍会被固定。然后讲 ScopedValue,它能取代 ThreadLocal 的大多数用法;再讲 StructuredTaskScope,它在 Java 25 中仍是预览特性。下面每个程序都在 Java 25 上运行过,输出直接从运行结果复制而来。想自己运行,就把代码保存为 Main.java,然后执行 java Main.java。
平台线程很贵
经典的 Java Thread 是平台线程:包在操作系统线程外面的一层薄薄的 Java 外壳。操作系统为每个平台线程预留一块栈(JVM 在 Linux x64 上的默认值是 1 MB,我们用 -XX:+PrintFlagsFinal 查过),创建一个线程还要做一次系统调用。所以一台服务器负担得起几千个平台线程,负担不起几百万个。
虚拟线程也是 Thread,方法完全一样。讲线程的那一部分里用过的构建器,都有对应的虚拟版本:
void main() throws InterruptedException {
Thread platform = Thread.ofPlatform().start(() -> IO.println("hello from a platform thread"));
platform.join();
Thread first = Thread.ofVirtual().start(() -> IO.println("hello from a virtual thread"));
first.join();
Thread second = Thread.startVirtualThread(() -> IO.println("and from another one"));
second.join();
IO.println("platform.isVirtual() = " + platform.isVirtual());
IO.println("first.isVirtual() = " + first.isVirtual());
IO.println("second.isVirtual() = " + second.isVirtual());
IO.println("main is virtual: " + Thread.currentThread().isVirtual());
}
输出:
hello from a platform thread
hello from a virtual thread
and from another one
platform.isVirtual() = false
first.isVirtual() = true
second.isVirtual() = true
main is virtual: false
Thread.ofVirtual().start(...) 和 Thread.startVirtualThread(...) 做的是同一件事。isVirtual() 告诉你当前是哪种线程,而 main 总是运行在平台线程上。其余的都是你已经熟悉的 Thread API:join、interrupt、getState。
我们还用 javac --release 20 编译了这个程序,它报错 ofVirtual() is a preview API and is disabled by default。换成 --release 21 就能编译,所以虚拟线程从 Java 21 起是正式特性。
十万个阻塞任务
大多数代码不会一个一个地启动线程。Executors.newVirtualThreadPerTaskExecutor() 会为你提交的每个任务启动一个新的虚拟线程。下面有 100,000 个任务,每个都睡眠一秒,模拟一次慢速网络调用:
void main() {
var completed = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // stands in for a slow network call
completed.incrementAndGet();
return null;
});
}
} // close() waits for every task to finish
IO.println("tasks completed: " + completed.get());
}
输出:
tasks completed: 100000
执行器的 close() 会等待所有已提交的任务,所以计数要等 100,000 个任务全部跑完才会被读取。lambda 表达式返回 null,这样它就是一个 Callable,而 Callable 允许抛出 sleep 声明的 InterruptedException。
花了多长时间?我们计了时。这些耗时每次运行、每台机器都不一样,只当作一个大致的参考:
$ time java Main.java
tasks completed: 100000
real 0m3.823s
这里面包括编译文件的时间。然后我们改了一行,让每个任务启动一个平台线程:Executors.newThreadPerTaskExecutor(Thread.ofPlatform().factory())。它同样输出 100000,却花了 54 秒。创建 100,000 个操作系统线程很慢。而虚拟线程版本的大部分时间里,100,000 个任务都在同时睡眠。
挂载与卸载
虚拟线程只在运行 Java 代码时才需要一个操作系统线程,一旦阻塞,就把这个线程还回去。运行虚拟线程的操作系统线程叫作载体线程(carrier thread)。下面是一个简化的示意,有两个载体线程和四个虚拟线程:
简化示意:两个载体线程由四个虚拟线程共用。阻塞的虚拟线程会交还它的载体,它的栈在堆上等待。等它能再次运行时,就挂载到当时空闲的任意一个载体上。真实的调度器通常每个 CPU 核心配一个载体线程,虚拟线程则多得多。
如果动画没有播放,下面用文字把这几步说一遍:
- 四个虚拟线程 VT1 到 VT4 等待运行。载体 1 和载体 2 都空闲。
- VT1 挂载到载体 1,VT2 挂载到载体 2,两者都在运行 Java 代码。VT3 和 VT4 在等待。
- VT1 发起一次需要等待的网络读取。它卸载:栈帧保存到堆上,载体 1 空了出来。
- 载体 1 不会闲着。VT3 挂载上去运行。VT4 继续等待。
- VT1 的读取完成了。VT1 又可以运行,于是排队等一个载体。
- VT2 结束,载体 2 空了出来。VT1 挂载到载体 2 上,从停下的地方接着运行,但换了一个载体,不是它最初所在的那个。
用十岁孩子能懂的话说
一个小镇有几辆大货车,它们就是平台线程。每辆货车都很贵,所以小镇只养得起几辆。
虚拟线程是几千个骑自行车的快递员。只有包裹真正在路上跑的时候,快递员才跳上货车。快递员要在门口等人签收时,就跳下车,让另一个快递员上车。等门开了,第一个快递员再跳上下一辆开过来的货车,哪辆都行。
所以只要大部分工作都是在门口等,几辆货车就能让几千个快递员一直忙着。
准确的说法
虚拟线程是一个 Thread 对象,它的栈不是一块固定的操作系统内存。JVM 把它挂载到载体线程上来运行它,而载体线程是 JDK 自己持有的一个 ForkJoinPool 里的平台线程。默认情况下,每个可用处理器对应一个载体线程。
当虚拟线程在 JDK 内部阻塞时,比如在 Thread.sleep、socket 读取、CountDownLatch.await 或者锁上阻塞,JDK 就把它卸载。JDK 把线程的栈帧复制到堆上的对象里,然后释放载体。等它在等的事情发生了,线程会被交还给调度器,挂载到任意一个空闲的载体上。你的代码对这一切毫无察觉,调用只是晚一点返回。这就是 100,000 个睡眠任务开销很小的原因:每个任务只是堆上的一个小对象,而不是一个停着的操作系统线程。
这个比喻的局限:快递员是自己决定跳下车的,虚拟线程不是。是 JDK 把它卸载,而且只在 JDK 认得的阻塞点上卸载。如果栈没法搬走,线程等待时就一直留在车上。这叫作固定(pinning),下面有专门一节讲它。另外,一辆货车能装很多包裹,但一个载体线程同一时刻只运行一个虚拟线程。
虚拟线程什么时候有用,什么时候没用
虚拟线程对大部分时间都在等待的代码有用。典型的例子是 Web 服务器:处理每个请求时,先调数据库,再调另外两个服务,一个阻塞调用接一个阻塞调用。你可以用简单的每请求一线程风格来写,同时仍然能处理数量非常大的并发请求,因为正在等待的请求不占用操作系统线程。
虚拟线程不会让计算变快。一个一直在做加法的任务从不阻塞,所以从不卸载,而载体线程的数量仍然等于核心数。一万个 CPU 密集型虚拟线程拿到的 CPU,并不比一个和你机器核心数一样大的平台线程池多。
平台线程时代的两个习惯,放到虚拟线程上是错的:
- 不要池化虚拟线程。线程池是为了复用昂贵的东西。虚拟线程很便宜,所以每个任务创建一个,用完就让它结束。
- 不要用小线程池限制并发。如果下游服务一次只能承受 10 个调用,就用
Semaphore来限制调用次数。
void main() {
var permits = new Semaphore(10);
var running = new AtomicInteger();
var peak = new AtomicInteger();
var completed = new AtomicInteger();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000; i++) {
executor.submit(() -> {
permits.acquire(); // waits here while 10 tasks hold a permit
try {
peak.accumulateAndGet(running.incrementAndGet(), Math::max);
Thread.sleep(Duration.ofMillis(10)); // the call to the limited service
completed.incrementAndGet();
} finally {
running.decrementAndGet();
permits.release();
}
return null;
});
}
}
IO.println("tasks completed: " + completed.get());
IO.println("never more than 10 at once: " + (peak.get() <= 10));
}
输出:
tasks completed: 1000
never more than 10 at once: true
1,000 个任务都立刻拿到了虚拟线程,但 acquire() 每次只放 10 个过去。另外 990 个以卸载的状态等待,开销很小。release() 放在 finally 里,所以调用失败也不会漏掉许可。讲 java.util.concurrent 的那一部分会介绍 Semaphore 和执行器。
固定:虚拟线程放不开载体的时候
虚拟线程阻塞了却没法卸载,这时它就被固定(pinned)了,在等待的整段时间里一直占着载体。被固定的线程一多,所有载体都会卡住,别的什么都跑不了。
在 Java 21 中,在 synchronized 里阻塞会固定线程。Java 24 改变了这一点(JEP 491),所以在 Java 25 中,持有监视器时等待的虚拟线程会像其他线程一样卸载。但有些情况仍然会固定。下面的程序测试了其中两种。它用系统属性 jdk.virtualThreadScheduler.parallelism 要求只使用一个载体线程,效果和在命令行上传 -D 一样。只有一个载体时,被固定的线程会挡住其他所有虚拟线程:
static final CountDownLatch configReleased = new CountDownLatch(1);
static class Config {
static final String NAME = load();
static String load() {
awaitQuietly(configReleased); // blocks inside a static initializer
return "loaded";
}
}
final Object lock = new Object();
void main() throws InterruptedException {
// One carrier thread for every virtual thread. Set it before the first one starts.
System.setProperty("jdk.virtualThreadScheduler.parallelism", "1");
var lockReleased = new CountDownLatch(1);
Thread inLock = Thread.ofVirtual().start(() -> {
synchronized (lock) {
awaitQuietly(lockReleased); // blocks while holding a monitor
}
});
IO.println("blocked in synchronized, others run: " + othersRun(lockReleased));
inLock.join();
Thread inInit = Thread.ofVirtual().start(() -> Config.NAME.length());
IO.println("blocked in a static initializer, others run: " + othersRun(configReleased));
inInit.join();
}
// Starts a second virtual thread that releases the first. Reports whether it got to run.
boolean othersRun(CountDownLatch release) throws InterruptedException {
Thread.sleep(Duration.ofMillis(200)); // give the first thread time to block
Thread other = Thread.ofVirtual().start(release::countDown);
boolean ran = other.join(Duration.ofSeconds(1));
release.countDown(); // if it couldn't run, release the first thread from here
other.join();
return ran;
}
static void awaitQuietly(CountDownLatch latch) {
try {
latch.await();
} catch (InterruptedException e) {
throw new IllegalStateException(e);
}
}
输出:
blocked in synchronized, others run: true
blocked in a static initializer, others run: false
第一个测试里,虚拟线程在持有 lock 时等待。它卸载了,唯一的载体去运行第二个线程,第二个线程再把第一个放行。第二个测试里,线程在 Config 的静态初始化器里等待。它一直被固定,第二个线程始终拿不到载体,join 等了一秒后放弃。我们运行了 20 次,每次都得到同样的两行。
想在真实程序里看到固定,就用 Java Flight Recorder 录下来。JDK 为此提供了 jdk.VirtualThreadPinned 事件。我们带着录制运行了同一个程序,并打印出事件(有删节;持续时间每次不同):
$ java -XX:StartFlightRecording:filename=pinned.jfr Main.java
$ jfr print --events jdk.VirtualThreadPinned pinned.jfr
jdk.VirtualThreadPinned {
duration = 1.20 s
blockingOperation = "LockSupport.park"
pinnedReason = "VM call to Main$Config.<clinit> on stack"
...
}
事件正好只有一个,来自静态初始化器。synchronized 里的等待没有留下事件。我们还试了一次本地调用:通过外部函数 API 调用 C 函数 qsort,传给它的比较器会回调 Java 并睡眠。这也会固定,原因是 "Native or VM frame on stack"。
有一点出乎意料:老文章会让你带上 -Djdk.tracePinnedThreads=full 运行。在 Java 25 上它什么都不做。我们把它传给同一个程序,什么额外输出都没有,连被固定的线程也没有。请改用 JFR 事件。
ThreadLocal 及其问题
ThreadLocal 让每个线程拥有一个变量的独立副本。框架很早就用它在整个请求中传递当前用户之类的信息,不必把它传给每个方法。它有三个问题,而虚拟线程让这三个问题都更严重。
它是可变的,线程上的任何代码都能调用 set,在别人不知情时改掉这个值。它和线程活得一样久,除非有人记得调用 remove()。而且每个线程各有一份副本,一百万个虚拟线程就意味着一百万份副本。生命周期问题最容易演示:
static final ThreadLocal<String> USER = new ThreadLocal<>();
void main() throws Exception {
try (ExecutorService pool = Executors.newFixedThreadPool(1)) {
pool.submit(() -> {
USER.set("ana");
IO.println("request 1 runs as " + USER.get());
// forgot USER.remove()
}).get();
pool.submit(() -> IO.println("request 2 runs as " + USER.get())).get();
}
}
输出:
request 1 runs as ana
request 2 runs as ana
两个请求都在线程池唯一的那个线程上运行。第一个请求设置了用户,却没有移除,于是第二个请求也以 Ana 的身份运行。放在真实的服务器里,这就是一个用户看到了另一个用户的数据。
ScopedValue:只在一次调用期间有效的值
ScopedValue 在一次调用期间绑定到一个值,这次调用触达的每个方法都能读取它。调用返回后,绑定就消失了。它在 Java 25 中成为正式特性。我们查过:javac --release 24 会把它当作预览 API 拒绝。
static final ScopedValue<String> USER = ScopedValue.newInstance();
void main() {
ScopedValue.where(USER, "ana").run(() -> handleRequest());
IO.println("after run, bound: " + USER.isBound());
ScopedValue.where(USER, "bo").run(() -> {
audit("outer");
ScopedValue.where(USER, "admin").run(() -> audit("inner"));
audit("outer again");
});
}
void handleRequest() {
IO.println("handling, bound: " + USER.isBound());
loadOrders();
}
void loadOrders() {
audit("loading orders"); // three calls deep, no parameter passed
}
void audit(String action) {
IO.println(USER.get() + ": " + action);
}
输出:
handling, bound: true
ana: loading orders
after run, bound: false
bo: outer
admin: inner
bo: outer again
ScopedValue.where(USER, "ana").run(...) 在 lambda 表达式运行期间绑定 USER。audit 在往下三层调用的地方读到了它,而没有人把它传下去。run 返回后,isBound() 是 false。
它没有 set 方法。想改变这个值,唯一的办法是为一个更小的调用重新绑定,就像 "admin" 那次绑定一样。内层调用返回后,旧值 "bo" 又回来了。所以绑定的值不会泄漏到之后的请求里,你调用的代码也没法在你不知情时改掉它。
读取一个没有绑定的作用域值会抛出异常:
static final ScopedValue<String> USER = ScopedValue.newInstance();
void main() {
IO.println("bound: " + USER.isBound());
IO.println("with a fallback: " + USER.orElse("guest"));
IO.println("user: " + USER.get());
}
输出后停止:
bound: false
with a fallback: guest
Exception in thread "main" java.util.NoSuchElementException: ScopedValue not bound
先用 isBound() 检查,或者在有合理默认值时用 orElse。
结构化并发(预览)
结构化并发的意思是:一起启动的任务一起结束。如果你把一个请求拆成几个子任务,没有哪个子任务会比请求活得更久,其中一个失败,其他的就会停下。Java 为此提供的 API StructuredTaskScope 在 Java 25 中是预览特性。它在正式发布前还可能变化,而且已经变过了。很多文章展示的是 new StructuredTaskScope.ShutdownOnFailure()。我们用 javac --release 查过:这个类在 Java 21 和 24 中存在,到了 25,同样的代码会报 cannot find symbol。不加参数这套 API 编译不了,所以要用 java --enable-preview Main.java 运行这些程序。
非结构化分发的问题
ExecutorService 能让你并行发起两个调用,但没有任何东西把它们绑在一起。下面一个调用很慢,另一个失败了:
void main() throws InterruptedException {
var neverOpens = new CountDownLatch(1);
var executor = Executors.newVirtualThreadPerTaskExecutor();
Future<String> user = executor.submit(() -> {
neverOpens.await(); // a slow call that is still going
return "ana";
});
Future<Integer> orders = executor.submit(() -> {
throw new IllegalStateException("orders service is down");
});
try {
int count = orders.get(); // ask for the failing one first
IO.println(user.get() + " has " + count + " orders");
} catch (ExecutionException e) {
IO.println("request failed: " + e.getCause().getMessage());
}
IO.println("user task still running: " + !user.isDone());
executor.shutdownNow(); // interrupts it; close() here would wait forever
IO.println("stopped after shutdownNow: " + executor.awaitTermination(1, TimeUnit.SECONDS));
}
输出:
request failed: orders service is down
user task still running: true
stopped after shutdownNow: true
请求失败了,但用户任务还在运行。没有人通知它停下,所以它一直泄漏着,直到我们手动关闭执行器。也请留意 orders.get() 上的注释。我们的第一版先调用了 user.get(),结果卡住了:main 在等那个慢调用,一直不知道另一个调用早就失败了。
StructuredTaskScope:先 fork,再 join
StructuredTaskScope 在 try-with-resources 块中打开,在其中 fork 出的每个子任务都必须在块结束前完成:
import java.util.concurrent.StructuredTaskScope.Subtask;
record Page(String user, int orders) {}
void main() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser(42));
Subtask<Integer> orders = scope.fork(() -> countOrders(42));
scope.join(); // waits for both
IO.println(new Page(user.get(), orders.get()));
}
}
String findUser(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(100));
return "ana";
}
int countOrders(int id) throws InterruptedException {
Thread.sleep(Duration.ofMillis(50));
return 3;
}
输出:
Page[user=ana, orders=3]
这些是 Java 25 中的调用:
StructuredTaskScope.open()打开一个作用域。每次fork都在一个新的虚拟线程中启动子任务。scope.join()等待子任务。Subtask.get()返回子任务的结果。只有在join之后才允许调用:提前调用会抛出IllegalStateException: join not called。
规则很严格。我们试过关闭一个 fork 了子任务却从没 join 的作用域,它抛出了 IllegalStateException: Owner did not join after forking。
Subtask 是嵌套类型,所以程序要导入它。紧凑源文件的自动导入包含 java.util.concurrent 的顶层类型,但不包含嵌套类型。
一个失败,其余取消
用不带参数的 open() 时,作用域会等所有子任务都成功。只要有一个失败,它就取消其他子任务。下面的程序把顺序固定下来:失败的子任务要等另一个子任务启动后才失败,而另一个子任务在等一个永远不会打开的闭锁。
import java.util.concurrent.StructuredTaskScope.FailedException;
void main() throws InterruptedException {
var userStarted = new CountDownLatch(1);
var neverOpens = new CountDownLatch(1);
var userSaw = new AtomicReference<String>("nothing");
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> {
userStarted.countDown();
try {
neverOpens.await(); // a slow call that would never finish
userSaw.set("finished");
} catch (InterruptedException e) {
userSaw.set("interrupted");
}
});
scope.fork(() -> {
userStarted.await(); // fail only once the other subtask is waiting
throw new IllegalStateException("orders service is down");
});
scope.join();
IO.println("both succeeded");
} catch (FailedException e) {
IO.println("request failed: " + e.getCause().getMessage());
}
IO.println("the user subtask saw: " + userSaw.get());
}
输出:
request failed: orders service is down
the user subtask saw: interrupted
第二个子任务抛出异常时,作用域中断了第一个。join() 抛出 StructuredTaskScope.FailedException,它的 cause 就是原始异常。等 try 块结束时,作用域已经等被中断的子任务结束了,所以 main 读取 userSaw 时它已经被设置好。对比一下执行器版本,那里的慢任务一直在运行。
其他 joiner
joiner(汇合器)决定 join() 等待什么、返回什么。你把它传给 open:
import java.util.concurrent.StructuredTaskScope.Joiner;
import java.util.concurrent.StructuredTaskScope.Subtask;
void main() throws InterruptedException {
var slowWasCancelled = new AtomicBoolean();
try (var scope = StructuredTaskScope.open(Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> {
throw new IllegalStateException("mirror A is down");
});
scope.fork(() -> {
try {
new CountDownLatch(1).await(); // mirror C never answers
return "mirror C";
} catch (InterruptedException e) {
slowWasCancelled.set(true);
throw e;
}
});
scope.fork(() -> "mirror B");
String first = scope.join();
IO.println("first good answer: " + first);
}
IO.println("slow mirror cancelled: " + slowWasCancelled.get());
try (var scope = StructuredTaskScope.open(Joiner.<Integer>allSuccessfulOrThrow())) {
for (int n = 1; n <= 4; n++) {
int x = n;
scope.fork(() -> x * x);
}
List<Integer> squares = scope.join().map(Subtask::get).toList();
IO.println("squares: " + squares);
}
}
输出:
first good answer: mirror B
slow mirror cancelled: true
squares: [1, 4, 9, 16]
只要还有别的子任务可能成功,anySuccessfulResultOrThrow() 就忽略失败。一旦有一个返回,join() 就返回这个结果,其余的被取消。allSuccessfulOrThrow() 让 join() 返回一个由子任务组成的 Stream,顺序和你 fork 它们的顺序一致,所以平方数是按顺序输出的。
Java 25 还有 awaitAll(),它等待全部子任务,从不抛出异常;以及 awaitAllSuccessfulOrThrow(),它的行为和不带参数的 open() 相同。用 awaitAll() 时,我们的一个子任务失败了,join() 正常返回,那个子任务的 state() 是 FAILED。open 的第二个参数用来配置作用域。用上 cf -> cf.withTimeout(Duration.ofMillis(100)) 后,一个慢子任务让 join() 抛出了 StructuredTaskScope.TimeoutException。
作用域值会流入子任务
在作用域中 fork 出的子任务,能看到打开作用域时已经绑定的作用域值:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void main() {
ScopedValue.where(REQUEST_ID, "req-7").run(() -> {
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> log("find user"));
var orders = scope.fork(() -> log("count orders"));
scope.join();
IO.println(user.get());
IO.println(orders.get());
String[] fromPlain = new String[1];
Thread.ofVirtual().start(() -> fromPlain[0] = log("plain thread")).join();
IO.println(fromPlain[0]);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
String log(String what) {
String id = REQUEST_ID.isBound() ? REQUEST_ID.get() : "no request id";
return "[" + id + "] " + what;
}
输出:
[req-7] find user
[req-7] count orders
[no request id] plain thread
两个子任务都在各自的线程上读到了 req-7。在同一个地方启动的普通虚拟线程却看不到它。只有作用域会把作用域值传下去,因为作用域保证它的子任务在绑定结束之前结束。不需要复制,也不需要清理。
调试虚拟线程
线程转储会列出每个线程以及它在等什么,但经典的 jcmd <pid> Thread.print 不包含虚拟线程。我们查过,我们的虚拟线程一个都没出现在里面。请改用 Thread.dump_to_file。我们运行了一个 fork 出两个子任务的程序,每个子任务睡眠 15 秒,在它等待时做了转储(大幅删节):
$ java --enable-preview Main.java &
$ jcmd <pid> Thread.dump_to_file -format=json threads.json
$ cat threads.json
...
"container": "java.util.concurrent.StructuredTaskScopeImpl@bef2d72",
"parent": "<root>",
"owner": "3",
"threads": [
{
"tid": "26",
"virtual": true,
"state": "TIMED_WAITING",
"stack": [
...
"java.base\/java.lang.Thread.sleep(Thread.java:601)",
"Main.fetch(Main.java:11)",
"Main.lambda$main$0(Main.java:3)",
...
线程按容器(container)分组。两个子任务位于作用域之内,作用域的 owner 是线程 3,也就是 main。所以转储展示了你代码的结构:哪个线程打开了作用域,哪些子任务属于它。如果用普通执行器,虚拟线程会归到执行器名下,没有 owner。
要点
- 虚拟线程是一种廉价的
Thread,只在运行时才挂载到载体线程上。用Executors.newVirtualThreadPerTaskExecutor()为每个任务创建一个。它们从 Java 21 起是正式特性。 - 虚拟线程对阻塞的、IO 密集型的代码有用。CPU 密集型的工作从中得不到任何好处。
- 不要池化虚拟线程。用
Semaphore限制对稀缺资源的访问。 - 从 Java 24 起,
synchronized在大多数情况下不再固定线程。阻塞在静态初始化器里或本地栈帧之下的线程仍会被固定,JFR 事件jdk.VirtualThreadPinned能告诉你在哪里。 ScopedValue在 Java 25 中是正式特性,它在一次调用期间绑定一个不可变的值。和ThreadLocal不同,你调用的代码改不了它,它也不会泄漏到下一个任务里。StructuredTaskScope在 Java 25 中是预览特性。在作用域中 fork 出的子任务会在作用域关闭前结束,一个失败会取消其他子任务,作用域值也会流入子任务。- 用
jcmd <pid> Thread.dump_to_file -format=json找虚拟线程,不要用Thread.print。
写简单的阻塞代码,给每个任务一个自己的虚拟线程。