Java 基础体系 · 第 14/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 25 虚拟线程与结构化并发:调度、Pinning、取消和容量

Java 25 LTS 中,**虚拟线程(virtual thread)**已经是正式特性;**结构化并发(structured concurrency)**仍属于预览特性。二者都试图解决同一个问题:让大量并发任务能够以接近同步代码的方式表达,同时避免传统平台线程和无边界线程池带来的资源浪费、生命周期失控与取消困难。

但虚拟线程并不等于“线程无限”,结构化并发也不等于“自动管理所有并发问题”。要正确使用它们,必须分别理解:

  • 虚拟线程如何被调度到载体线程(carrier thread)上运行;
  • 阻塞时为什么通常不会占用载体线程;
  • 什么是 Pinning,以及 Java 25 中它与旧版本的差异;
  • interruptFuture.cancel、作用域关闭和任务失败之间的关系;
  • 任务数量、CPU、连接池和下游服务之间的容量约束;
  • 结构化并发如何用词法作用域表达父子任务关系。

一、先区分三种“线程”

1. 平台线程、虚拟线程和载体线程

Java 线程 API 中的 Thread 可以代表两类不同的执行资源:

  1. 平台线程(platform thread)
    通常映射到操作系统线程。创建、调度和栈内存成本较高,数量通常受到操作系统线程和进程内存的限制。

  2. 虚拟线程(virtual thread)
    由 JVM 管理,不直接一一对应操作系统线程。虚拟线程执行 Java 代码时,需要暂时挂载(mount)到某个平台线程上;这个平台线程称为它的载体线程(carrier thread)

  3. 载体线程
    负责实际执行某个已挂载虚拟线程的 Java 代码。载体线程属于 JVM 使用的调度器线程,通常是一个内部的 ForkJoinPool

因此,虚拟线程不是“比平台线程更快的操作系统线程”,而是一个由 JVM 保存执行状态、由少量平台线程承载运行的用户态任务执行单元。

可以把关系抽象为:

虚拟线程 V1 ─┐
虚拟线程 V2 ─┼─> 挂载到载体线程 C1 执行
虚拟线程 V3 ─┘

虚拟线程 V4 ─┐
虚拟线程 V5 ─┼─> 挂载到载体线程 C2 执行
虚拟线程 V6 ─┘

同一个虚拟线程在不同时间可能挂载到不同载体线程上;应用代码不应假设“某个虚拟线程永远由某个特定平台线程执行”。

2. 虚拟线程保存什么

虚拟线程的核心状态包括:

  • 程序计数器;
  • Java 调用栈的逻辑状态;
  • 局部变量;
  • 线程中断状态;
  • ThreadLocal 等线程上下文。

当虚拟线程执行到某些可挂起的阻塞操作时,JVM 可以保存其执行状态,使载体线程去执行其他虚拟线程。恢复时,它可以重新挂载到任意可用载体线程。

这与传统线程池中的“任务等待工作线程”不同:

平台线程模型:

任务 A 阻塞数据库
平台线程 P 也被占用
任务 B 必须等待其他平台线程

虚拟线程模型:

虚拟线程 V_A 阻塞数据库
V_A 从载体线程 C 脱离
C 继续执行虚拟线程 V_B

这里的关键不是“阻塞消失了”,而是阻塞等待期间通常不再占用载体线程


二、虚拟线程的调度:并发数量不等于并行数量

1. 并发与并行

两个概念必须分开:

  • 并发(concurrency):多个任务在时间上交错推进;
  • 并行(parallelism):多个任务在同一时刻使用多个 CPU 核心执行。

假设机器有 8 个可用处理器:

可以同时运行的 CPU 密集型 Java 代码,大致受 8 个核心限制;
可以处于等待状态的虚拟线程,可能远多于 8 个。

虚拟线程主要解决的是高并发等待场景,例如:

  • HTTP 请求等待远端响应;
  • 数据库查询等待网络返回;
  • 文件或 socket 操作等待内核;
  • 定时等待;
  • RPC 调用等待结果。

它不能把 8 个 CPU 核心变成 8000 个 CPU 核心。

2. 调度过程

以一个执行数据库查询的虚拟线程为例:

1. 创建虚拟线程 V
2. V 被调度器挂载到载体线程 C
3. V 执行 Java 代码,发起数据库操作
4. V 进入可挂起的阻塞点
5. V 从 C 脱离,保存 continuation 状态
6. C 执行其他虚拟线程
7. 数据库结果到达
8. V 被重新调度并挂载到某个载体线程
9. V 从阻塞点之后继续执行

这里的 continuation 可以理解为“可暂停、可恢复的 Java 执行状态”。虚拟线程的栈不是简单地永久占用一个操作系统线程栈,而是由 JVM 管理的可增长栈结构。

3. 调度器的规范保证与实现细节

Java 规范主要保证虚拟线程的语义,不承诺某个具体调度算法、载体线程数量或公平性。

在 Java 25 的常见实现中,虚拟线程调度器基于 ForkJoinPool。以下内容属于实现或配置层面的事实,不应写进业务逻辑假设:

  • 调度器的并行度通常与可用处理器数量相关;
  • 可以通过 JVM 系统属性调整虚拟线程调度器的并行度、最小并行度和最大池大小;
  • 调度器实现可能发生变化;
  • 不应依赖某个虚拟线程被某个固定载体线程执行;
  • 不应依赖严格的 FIFO、严格公平或特定任务抢占行为。

虚拟线程不是抢占式实时线程。长时间执行纯 Java CPU 计算的虚拟线程可能持续占用载体线程,直到:

  • 它主动阻塞或等待;
  • 它执行到调度器能够处理的挂起点;
  • 操作系统或 JVM 的调度行为使其他任务获得运行机会。

因此,CPU 密集型任务应使用受控的固定大小执行器,而不是无边界地创建虚拟线程。

try (var executor = Executors.newFixedThreadPool(
        Runtime.getRuntime().availableProcessors())) {
    // CPU 密集型任务
}

而大量 I/O 等待型任务可以使用:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // 每个任务使用一个虚拟线程
}

两者的差异不是“虚拟线程更快”,而是资源模型不同:

固定平台线程池:
任务数量受工作线程数限制,任务可能排队。

虚拟线程执行器:
任务可以快速创建,但实际运行仍受 CPU 和外部资源限制。

三、阻塞、挂起与 Pinning

1. 什么是 Pinning

Pinning 指虚拟线程在本应阻塞或等待时,仍然占用并绑定(pin)着载体线程,导致载体线程无法去执行其他虚拟线程。

正常情况下:

虚拟线程 V 阻塞
V 脱离载体线程 C
C 执行其他虚拟线程

发生 Pinning 时:

虚拟线程 V 阻塞
V 仍绑定载体线程 C
C 不能有效执行其他任务

如果大量虚拟线程同时 Pinning,虚拟线程的高并发优势会被削弱,甚至表现得像一个过小的平台线程池。

2. Java 25 中 synchronized 的版本差异

在早期虚拟线程实现中,虚拟线程如果在 synchronized 监视器内部执行阻塞操作,可能被 Pin 到载体线程:

synchronized (lock) {
    socket.read(); // 早期 JDK 中可能导致 pinning
}

JDK 24 通过 JEP 491 改进了这一点,使 JVM 能够更好地处理 synchronized 监视器中的阻塞。对于 Java 25,应明确区分:

  • 不能继续简单套用“虚拟线程绝不能进入 synchronized”这一旧结论;
  • Java 25 中,普通 Java 监视器导致 Pinning 的问题已大幅改善;
  • 本地方法(JNI)和外部函数调用(Foreign Function & Memory API)仍可能使虚拟线程无法安全脱离载体线程
  • 某些 JVM 内部或底层运行时路径也可能存在实现相关的 Pinning。

因此,Pinning 不是“使用 synchronized 就一定发生”,也不是“Java 25 以后完全不存在”。

3. Pinning 为什么影响容量

设载体线程数量为 CC,每个 Pinning 任务平均占用载体线程 pp 秒,系统中同时存在 NN 个这样的任务。

如果:

N>CN > C

那么至少有一部分任务无法获得载体线程。若这些任务又持有锁、等待 I/O 或等待其他任务,就可能形成级联阻塞。

一个典型的危险结构是:

synchronized (lock) {
    remoteClient.call(); // 远程等待时间不可控
}

问题有两层:

  1. 远程调用可能很慢;
  2. 调用发生在互斥区内,其他任务无法进入临界区。

在 Java 25 中,第一层仍然是业务容量问题;第二层不一定再造成传统意义上的 monitor Pinning,但它仍然会造成锁竞争和吞吐下降。

更稳妥的结构是缩小临界区:

Request request;

synchronized (lock) {
    request = buildRequestFromSharedState();
}

Response response = remoteClient.call(request);

synchronized (lock) {
    updateSharedState(response);
}

这里的前提是:远程调用不依赖必须持续持有的锁。如果业务要求调用期间保持一致性,应改用事务、版本号、状态机或其他明确的并发协议,而不是把不可控 I/O 放在大锁中。

4. 如何诊断 Pinning

诊断时不要仅凭“虚拟线程很多”推断 Pinning。应观察:

  • 载体线程是否长期忙碌;
  • 虚拟线程是否大量停留在底层调用;
  • 是否存在 native 或 foreign function 调用;
  • 是否存在长时间 CPU 计算;
  • 是否存在锁竞争;
  • JFR 中的虚拟线程相关事件;
  • 线程 dump 中虚拟线程、载体线程和阻塞栈的关系。

较早版本中常见的 jdk.tracePinnedThreads 诊断方式主要用于早期虚拟线程实现;在 Java 25 上不应把它当作唯一或普遍有效的诊断手段。应优先结合 Java Flight Recorder、线程转储和应用级延迟指标验证。


四、从 Executor 到虚拟线程:生命周期仍然存在

虚拟线程便宜,不代表不需要管理生命周期。

1. newVirtualThreadPerTaskExecutor

下面是一个完整的执行示例:

import java.util.concurrent.*;

public class VirtualThreadLifecycle {
    public static void main(String[] args) throws Exception {
        try (ExecutorService executor =
                     Executors.newVirtualThreadPerTaskExecutor()) {

            Future<String> future = executor.submit(() -> {
                Thread.sleep(Duration.ofMillis(100));
                return "done";
            });

            System.out.println(future.get());
        }
    }
}

运行逻辑是:

  1. 创建一个虚拟线程执行器;
  2. submit 为任务创建虚拟线程;
  3. 任务睡眠期间,虚拟线程通常可以脱离载体线程;
  4. future.get() 等待任务完成;
  5. 离开 try 块时关闭执行器;
  6. 执行器不再接受新任务,并等待已提交任务完成或根据关闭语义结束。

需要导入:

import java.time.Duration;

如果任务内部抛出异常,future.get() 会以 ExecutionException 报告失败原因:

try {
    future.get();
} catch (ExecutionException e) {
    Throwable cause = e.getCause();
    // 这里处理任务本身的异常
}

2. 为什么不能“池化虚拟线程”

平台线程池通常用于复用昂贵的工作线程。虚拟线程的设计目标则是“每个任务一个虚拟线程”,因此下面这种做法通常没有意义:

Executors.newFixedThreadPool(100,
        Thread.ofVirtual().factory());

它限制的是虚拟线程数量,而不是直接限制数据库连接、远程服务并发或 CPU 消耗。若真正的约束是数据库连接数,应限制数据库操作并发,而不是任意限制所有虚拟线程。


五、取消:interrupt 是协作式机制

1. 取消不是强制杀死线程

Java 没有安全的“立即杀死任意线程”机制。取消通常通过中断实现:

Future<?> future = executor.submit(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        doOneUnitOfWork();
    }
});

future.cancel(true);

cancel(true) 的语义是:如果任务尚未完成,尝试中断执行它的线程。它不保证任务立刻停止,也不保证任务一定响应中断。

任务必须主动配合:

try {
    while (true) {
        doOneUnitOfWork();
        Thread.sleep(100);
    }
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    cleanup();
}

捕获 InterruptedException 后恢复中断状态很重要。因为很多阻塞方法在抛出 InterruptedException 时会清除中断标志;如果既不继续抛出,也不恢复状态,上层就无法知道取消已经发生。

2. 不可中断任务的失败表现

下面的任务不会正确响应取消:

executor.submit(() -> {
    while (true) {
        calculate();
        // 不检查中断状态
    }
});

即使调用:

future.cancel(true);

任务也可能继续运行,因为:

  • 纯 CPU 循环没有调用可中断阻塞方法;
  • 代码没有检查 Thread.interrupted()
  • 底层库忽略或延迟处理中断;
  • native 调用可能有自己的取消机制。

正确形式通常是把工作拆成有限步骤,并在步骤之间检查取消状态:

while (!Thread.currentThread().isInterrupted()) {
    processOneBatch();
}

但这仍然要求 processOneBatch() 本身不会无限阻塞。如果它调用一个不响应中断的外部 API,还需要使用该 API 自己的超时或取消接口。

3. 中断与内存可见性

中断不仅是一个控制信号,也涉及线程间状态观察。程序不应使用普通共享布尔变量替代中断:

boolean stopped; // 多线程下可能不可见

可以使用:

volatile boolean stopped;

或者:

AtomicBoolean stopped = new AtomicBoolean();

不过,如果任务本来就是通过 Future.cancel(true) 或结构化作用域关闭来取消,优先遵循中断协议,不要同时维护多个相互独立的取消状态。


六、结构化并发:把任务生命周期放进词法作用域

1. 非结构化并发的问题

传统代码可以启动任务后直接返回:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

Future<String> future = executor.submit(this::loadData);
return "request accepted";

此时任务与调用者之间的关系不清晰:

  • 调用者返回后,任务是否继续运行;
  • 请求超时后,任务是否应该取消;
  • 子任务失败后,其他子任务是否停止;
  • 执行器何时关闭;
  • 异常由谁接收;
  • 是否会留下后台任务。

这就是非结构化并发:任务的生命周期脱离了启动它的代码块。

2. 结构化并发的核心不变量

结构化并发要求子任务具有明确的父子关系,并满足类似以下不变量:

  1. 子任务在父作用域内创建;
  2. 父作用域不会在子任务结束前正常完成;
  3. 子任务失败可以传播到父任务;
  4. 取消父任务可以传播到子任务;
  5. 作用域退出时,不再允许子任务逃逸到后台继续运行。

可以表示为:

父任务开始
    ├── 子任务 A
    ├── 子任务 B
    └── 子任务 C
父任务等待子任务完成或取消
父任务结束

而不是:

父任务开始
    ├── 启动子任务 A
    ├── 启动子任务 B
父任务结束

子任务 A、B 仍在后台运行

3. Java 25 的 API 状态

Java 25 中的结构化并发 API 仍是 预览特性。使用时需要:

javac --enable-preview --release 25 Example.java
java --enable-preview Example

API 或方法签名可能在后续 Java 版本中调整,因此生产采用前应结合目标 JDK 的正式文档和兼容性策略验证。

一个表达“并行加载两个结果,全部成功才返回”的示意代码如下:

import java.util.concurrent.StructuredTaskScope;

public class StructuredExample {

    static String loadUser() {
        return "user";
    }

    static String loadOrders() {
        return "orders";
    }

    public static String loadPage() throws InterruptedException {
        try (var scope = StructuredTaskScope.open()) {
            var user = scope.fork(StructuredExample::loadUser);
            var orders = scope.fork(StructuredExample::loadOrders);

            scope.join();

            // join 返回后,子任务已经结束、失败或被取消。
            // 读取结果时,失败需要明确处理。
            return user.get() + ":" + orders.get();
        }
    }
}

不同预览版本可能提供不同的结果聚合方式,例如通过 Joiner 表达“全部成功”或“任一成功”。因此,使用具体聚合 API 时必须以 Java 25 的实际 API 文档为准,不能把其他预览版本的示例直接复制过来。

这段代码的生命周期是:

进入 try
  ↓
创建结构化作用域
  ↓
fork 两个子任务
  ↓
join 等待它们结束
  ↓
读取结果或处理失败
  ↓
退出 try,关闭作用域

4. join、失败和关闭之间的区别

它们不是同一个动作:

  • fork:提交一个子任务;
  • join:等待子任务状态发生变化或全部完成,通常可被中断;
  • shutdown:请求停止作用域中的子任务,通常通过中断传播;
  • close:关闭作用域,防止任务逃逸,并检查是否仍有未完成任务;
  • 结果读取:取得成功值,或报告子任务失败。

一个子任务失败,并不自动意味着所有兄弟任务都已停止,除非使用的作用域策略明确规定了这种行为,或者代码主动调用关闭/取消逻辑。

因此,业务代码需要明确选择失败策略:

全部成功:
A 或 B 失败 → 取消另一方 → 父任务失败

任一成功:
A 成功 → 取消 B → 返回 A
A、B 都失败 → 父任务失败

允许部分成功:
A 失败 → 记录失败
B 成功 → 返回降级结果

“部分成功”不能仅靠等待 API 自动推断,必须在结果协议中表达。


七、结构化并发中的取消传播

考虑一个 HTTP 请求同时查询用户、订单和推荐:

请求 R
 ├── 用户服务 U
 ├── 订单服务 O
 └── 推荐服务 P

如果客户端在 200 毫秒后断开连接,理想的取消路径是:

客户端断开
  ↓
请求处理线程检测取消
  ↓
关闭结构化作用域
  ↓
中断 U、O、P
  ↓
各子任务终止远程等待、释放连接
  ↓
请求资源回收

如果代码使用的是“启动后台任务后直接返回”,则客户端断开后可能发生:

客户端断开
  ↓
请求对象消失
  ↓
U、O、P 仍继续执行
  ↓
继续消耗数据库连接和远程服务配额

这不仅是线程泄漏,也是下游容量泄漏。

结构化并发的价值不只是语法更短,而是将取消传播和生命周期约束变成代码结构的一部分。


八、容量:虚拟线程多,不代表下游能处理更多请求

1. 虚拟线程解决的是线程资源,不是所有容量问题

一个请求可能依次或并行消耗:

  • CPU 时间;
  • 数据库连接;
  • HTTP 连接;
  • 文件描述符;
  • 堆内存;
  • 下游服务的并发配额;
  • 限流器令牌;
  • 锁和事务槽位。

虚拟线程降低了“等待一个平台线程”的成本,但不会自动增加这些资源的容量。

例如,数据库连接池大小为 100:

虚拟线程数量:10,000
数据库连接数量:100

最多只有约 100 个任务能够同时占用数据库连接。其余任务应等待、超时或被拒绝,而不是无限积压。

2. 用 Little 定律估算等待中的任务

在稳定系统中,Little 定律为:

L=λWL = \lambda W

其中:

  • LL:系统中平均存在的任务数;
  • λ\lambda:平均吞吐率;
  • WW:任务在系统中的平均停留时间。

例如:

  • 每秒进入 2,000 个请求;
  • 每个请求平均耗时 0.5 秒;

则平均并发请求数约为:

L=2000×0.5=1000L = 2000 \times 0.5 = 1000

虚拟线程可以较低成本地承载这 1,000 个等待中的请求,但如果每个请求都需要一个数据库连接,那么数据库连接池必须能支持相应并发,或者请求必须在数据库操作前排队。

3. 用 Semaphore 限制真正稀缺的资源

下面的代码限制数据库操作最多并发 100 个:

import java.util.concurrent.Semaphore;

final class DatabaseGate {
    private final Semaphore permits = new Semaphore(100);

    <T> T execute(DatabaseOperation<T> operation)
            throws Exception {
        permits.acquire();
        try {
            return operation.run();
        } finally {
            permits.release();
        }
    }

    interface DatabaseOperation<T> {
        T run() throws Exception;
    }
}

虚拟线程在 permits.acquire() 处等待时,通常可以脱离载体线程。关键是 finally 必须释放许可,否则一次异常就可能永久减少容量。

调用示例:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<String> future = executor.submit(() ->
        gate.execute(() -> queryDatabase())
    );

    System.out.println(future.get());
}

这里有两个不同的容量边界:

虚拟线程数量:可以很多
数据库并发:由 Semaphore 和连接池共同限制

若连接池本身只有 50 个连接,Semaphore(100) 并不会创造 100 个数据库连接;它只允许最多 100 个任务进入连接池等待或操作。更准确的设计通常是让许可数不超过真正可用的下游并发。

4. 超时比无限等待更接近容量控制

仅限制并发数仍可能产生无限等待。可以结合超时:

if (!permits.tryAcquire(500, TimeUnit.MILLISECONDS)) {
    throw new RejectedExecutionException(
            "database concurrency limit exceeded");
}

try {
    return queryDatabase();
} finally {
    permits.release();
}

这里的失败路径是明确的:

  1. 500 毫秒内获得许可:执行查询;
  2. 没有获得许可:快速失败;
  3. 查询抛异常:finally 释放许可;
  4. 线程被中断:tryAcquire 抛出 InterruptedException,不会错误持有许可。

生产系统还应为下游调用设置连接超时、读取超时和总超时,否则一个“可挂起”的等待仍然会长期占用:

  • 虚拟线程;
  • 请求上下文;
  • 业务对象;
  • 连接池或信号量许可;
  • 下游服务配额。

九、锁、内存模型与虚拟线程不是同一个问题

虚拟线程改变的是线程承载和阻塞成本,不改变 Java 内存模型。

1. 共享变量仍然需要同步

以下代码仍然有数据竞争:

class Counter {
    private int value;

    void increment() {
        value++;
    }
}

value++ 包含读取、加一、写回三个步骤。多个虚拟线程并发执行时,仍可能丢失更新。

可以使用:

import java.util.concurrent.atomic.AtomicInteger;

class Counter {
    private final AtomicInteger value = new AtomicInteger();

    void increment() {
        value.incrementAndGet();
    }
}

或者:

synchronized void increment() {
    value++;
}

虚拟线程不会自动提供 happens-before 关系,也不会自动消除锁竞争。

2. volatile 只解决可见性和部分有序性

取消标志可以写成:

private volatile boolean cancelled;

但下面的复合操作仍不是原子的:

if (!cancelled) {
    count++;
}

需要根据目标选择:

  • volatile:状态发布和读取;
  • synchronized:互斥和临界区;
  • Atomic*:无锁原子更新;
  • VarHandle:更细粒度的内存访问模式;
  • Lock:可中断、可限时或条件等待的锁。

虚拟线程能够高效地等待,并不意味着可以忽略 happens-before、原子性和可见性。


十、常见误解与失败表现

误解一:虚拟线程数量可以无限增加

虚拟线程的创建成本低,但每个任务仍可能持有堆内存、请求对象、缓冲区、连接和上下文。无限创建任务可能导致:

  • 堆内存增长;
  • GC 压力增加;
  • 下游连接耗尽;
  • 大量任务排队;
  • 超时同时发生,形成故障尖峰。

应限制真正的资源,而不是机械地限制虚拟线程总数。

误解二:虚拟线程适合所有 CPU 密集型任务

CPU 密集任务的并行度受处理器数量限制。大量虚拟线程执行纯计算只会增加调度和上下文管理压力,不能提高理论 CPU 吞吐。

应将 CPU 密集阶段交给有界执行器,并监控队列长度、执行时间和拒绝情况。

误解三:cancel(true) 会立即停止代码

取消依赖协作。忽略中断的循环、不可中断的第三方库和底层 native 调用都可能继续运行。

应为任务定义明确的取消协议:

收到中断
  ↓
停止接受新工作
  ↓
结束当前可取消操作
  ↓
释放锁、连接、许可和临时文件
  ↓
恢复中断状态或继续向上抛出

误解四:结构化作用域会自动替业务决定失败策略

结构化并发提供生命周期和取消传播机制,但“一个子任务失败是否取消其他任务”属于策略问题。用户详情、库存和推荐可能需要不同策略:

  • 用户详情失败:整个页面失败;
  • 推荐失败:使用空推荐;
  • 库存失败:禁止下单;
  • 价格服务失败:使用缓存或拒绝交易。

这些都必须由业务明确表达。

误解五:在锁中发起远程调用只是性能问题

锁中调用远程服务还会改变并发协议:

synchronized (state) {
    updateLocalState();
    remoteCall();
}

远程服务超时会让锁长期持有,继而导致其他任务阻塞。即便 Java 25 改善了 synchronized 引起的虚拟线程 Pinning,这种设计仍可能造成锁竞争、请求堆积和级联超时。


十一、选择模型:何时使用哪种工具

可以按任务性质做出区分:

场景 适合的工具
大量独立的 I/O 等待型任务 虚拟线程
需要等待多个子任务并统一取消 结构化并发
CPU 密集型计算 有界平台线程池或受控执行器
限制数据库、HTTP 或文件并发 Semaphore、连接池自身容量和限流器
获取单个异步结果 Future 或结构化作用域中的子任务
请求超时后取消整棵任务树 结构化作用域加中断/超时
共享可变状态 synchronizedLock、Atomic 或 VarHandle
native/foreign 调用 额外评估 Pinning、超时和底层取消能力

一个合理的请求处理流程通常是:

请求进入
  ↓
在结构化作用域中 fork 子任务
  ↓
每个子任务使用虚拟线程执行阻塞 I/O
  ↓
访问数据库前取得有限许可
  ↓
任一失败或请求取消时传播中断
  ↓
join 等待并聚合结果
  ↓
退出作用域,确认没有子任务逃逸

最终应验证的不是“虚拟线程数量”,而是:

  • 请求延迟分布;
  • CPU 利用率;
  • 载体线程是否饱和;
  • 数据库和 HTTP 连接池使用率;
  • 许可等待时间;
  • 取消后的任务残留;
  • 超时和失败是否释放所有资源;
  • 下游服务是否出现并发尖峰。

虚拟线程让“一个任务一个线程”的编程模型重新变得可行;结构化并发则让这些任务的父子关系、失败传播和取消边界变得明确。前者降低线程承载成本,后者约束任务生命周期,但 CPU、连接、锁、内存和下游服务容量仍然需要单独设计。只有把调度、Pinning、取消和资源容量放在同一个因果链中,Java 25 的并发模型才不会从“线程池排队”简单演变成“虚拟线程无边界堆积”。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。