Blog

Java 的虚拟线程、作用域值与结构化并发

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:joininterruptgetState

我们还用 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)。下面是一个简化的示意,有两个载体线程和四个虚拟线程:

载体 1 载体 2 空闲 空闲 等待运行 栈已保存,等待 IO VT4 VT3 VT2 VT1 四个虚拟线程等待运行,两个载体都空闲 VT1 和 VT2 挂载:各自在一个载体线程上运行 VT1 阻塞在 IO 上:它卸载,栈暂存在堆上 载体 1 空了出来,VT3 挂载上去 VT1 的 IO 完成:它又可以运行,等一个空闲载体 VT2 结束,VT1 重新挂载到载体 2,不是它最初那个

简化示意:两个载体线程由四个虚拟线程共用。阻塞的虚拟线程会交还它的载体,它的栈在堆上等待。等它能再次运行时,就挂载到当时空闲的任意一个载体上。真实的调度器通常每个 CPU 核心配一个载体线程,虚拟线程则多得多。

如果动画没有播放,下面用文字把这几步说一遍:

  1. 四个虚拟线程 VT1 到 VT4 等待运行。载体 1 和载体 2 都空闲。
  2. VT1 挂载到载体 1,VT2 挂载到载体 2,两者都在运行 Java 代码。VT3 和 VT4 在等待。
  3. VT1 发起一次需要等待的网络读取。它卸载:栈帧保存到堆上,载体 1 空了出来。
  4. 载体 1 不会闲着。VT3 挂载上去运行。VT4 继续等待。
  5. VT1 的读取完成了。VT1 又可以运行,于是排队等一个载体。
  6. 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 表达式运行期间绑定 USERaudit 在往下三层调用的地方读到了它,而没有人把它传下去。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 StructuredTaskScopeJava 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

StructuredTaskScopetry-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()FAILEDopen 的第二个参数用来配置作用域。用上 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

写简单的阻塞代码,给每个任务一个自己的虚拟线程。

这篇文章对你有帮助吗?

点一颗爱心来评分!

平均评分 0 / 5. 投票总数: 0

还没有人投票。来做第一个评分的人吧。