Java 基础体系 · 第 14/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 25 虚拟线程与结构化并发:调度、Pinning、取消和容量
Java 25 LTS 中,**虚拟线程(virtual thread)**已经是正式特性;**结构化并发(structured concurrency)**仍属于预览特性。二者都试图解决同一个问题:让大量并发任务能够以接近同步代码的方式表达,同时避免传统平台线程和无边界线程池带来的资源浪费、生命周期失控与取消困难。
但虚拟线程并不等于“线程无限”,结构化并发也不等于“自动管理所有并发问题”。要正确使用它们,必须分别理解:
- 虚拟线程如何被调度到载体线程(carrier thread)上运行;
- 阻塞时为什么通常不会占用载体线程;
- 什么是 Pinning,以及 Java 25 中它与旧版本的差异;
interrupt、Future.cancel、作用域关闭和任务失败之间的关系;- 任务数量、CPU、连接池和下游服务之间的容量约束;
- 结构化并发如何用词法作用域表达父子任务关系。
一、先区分三种“线程”
1. 平台线程、虚拟线程和载体线程
Java 线程 API 中的 Thread 可以代表两类不同的执行资源:
-
平台线程(platform thread)
通常映射到操作系统线程。创建、调度和栈内存成本较高,数量通常受到操作系统线程和进程内存的限制。 -
虚拟线程(virtual thread)
由 JVM 管理,不直接一一对应操作系统线程。虚拟线程执行 Java 代码时,需要暂时挂载(mount)到某个平台线程上;这个平台线程称为它的载体线程(carrier thread)。 -
载体线程
负责实际执行某个已挂载虚拟线程的 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 为什么影响容量
设载体线程数量为 ,每个 Pinning 任务平均占用载体线程 秒,系统中同时存在 个这样的任务。
如果:
那么至少有一部分任务无法获得载体线程。若这些任务又持有锁、等待 I/O 或等待其他任务,就可能形成级联阻塞。
一个典型的危险结构是:
synchronized (lock) {
remoteClient.call(); // 远程等待时间不可控
}
问题有两层:
- 远程调用可能很慢;
- 调用发生在互斥区内,其他任务无法进入临界区。
在 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());
}
}
}
运行逻辑是:
- 创建一个虚拟线程执行器;
submit为任务创建虚拟线程;- 任务睡眠期间,虚拟线程通常可以脱离载体线程;
future.get()等待任务完成;- 离开
try块时关闭执行器; - 执行器不再接受新任务,并等待已提交任务完成或根据关闭语义结束。
需要导入:
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. 结构化并发的核心不变量
结构化并发要求子任务具有明确的父子关系,并满足类似以下不变量:
- 子任务在父作用域内创建;
- 父作用域不会在子任务结束前正常完成;
- 子任务失败可以传播到父任务;
- 取消父任务可以传播到子任务;
- 作用域退出时,不再允许子任务逃逸到后台继续运行。
可以表示为:
父任务开始
├── 子任务 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 定律为:
其中:
- :系统中平均存在的任务数;
- :平均吞吐率;
- :任务在系统中的平均停留时间。
例如:
- 每秒进入 2,000 个请求;
- 每个请求平均耗时 0.5 秒;
则平均并发请求数约为:
虚拟线程可以较低成本地承载这 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();
}
这里的失败路径是明确的:
- 500 毫秒内获得许可:执行查询;
- 没有获得许可:快速失败;
- 查询抛异常:
finally释放许可; - 线程被中断:
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 或结构化作用域中的子任务 |
| 请求超时后取消整棵任务树 | 结构化作用域加中断/超时 |
| 共享可变状态 | synchronized、Lock、Atomic 或 VarHandle |
| native/foreign 调用 | 额外评估 Pinning、超时和底层取消能力 |
一个合理的请求处理流程通常是:
请求进入
↓
在结构化作用域中 fork 子任务
↓
每个子任务使用虚拟线程执行阻塞 I/O
↓
访问数据库前取得有限许可
↓
任一失败或请求取消时传播中断
↓
join 等待并聚合结果
↓
退出作用域,确认没有子任务逃逸
最终应验证的不是“虚拟线程数量”,而是:
- 请求延迟分布;
- CPU 利用率;
- 载体线程是否饱和;
- 数据库和 HTTP 连接池使用率;
- 许可等待时间;
- 取消后的任务残留;
- 超时和失败是否释放所有资源;
- 下游服务是否出现并发尖峰。
虚拟线程让“一个任务一个线程”的编程模型重新变得可行;结构化并发则让这些任务的父子关系、失败传播和取消边界变得明确。前者降低线程承载成本,后者约束任务生命周期,但 CPU、连接、锁、内存和下游服务容量仍然需要单独设计。只有把调度、Pinning、取消和资源容量放在同一个因果链中,Java 25 的并发模型才不会从“线程池排队”简单演变成“虚拟线程无边界堆积”。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 线程与 Executor:生命周期、线程池、队列、Future 和取消
- 下一篇:Java 内存模型与同步:happens-before、锁、volatile、Atomic 和 VarHandle
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论