虚拟线程与结构化并发实战

深入虚拟线程与结构化并发:平台线程与虚拟线程的对比、虚拟线程的创建与调度原理、StructuredTaskScope 结构化并发、与传统线程池的取舍、性能实测,以及迁移到虚拟线程时代的实战陷阱。

线程是并发编程最基础的资源,而传统线程的昂贵性一直是高并发服务的瓶颈。虚拟线程(Virtual Threads,JDK 21 正式发布)让「一个请求一个线程」从奢侈品变成常规操作,配合结构化并发(Structured Concurrency)彻底改变了服务端编程的姿势。本文从平台线程的痛点讲起,落到创建、调度、结构化并发、性能实测与迁移陷阱。

一、平台线程 vs 虚拟线程:问题与答案

1.1 平台线程的代价

平台线程(Platform Thread,也叫 OS 线程)由操作系统内核管理,每个线程占用约 1 MB 栈内存。当并发请求到达几万个时,线程数量成为硬瓶颈:

资源平台线程虚拟线程
栈内存约 1 MB,不可控默认几 KB,随用随扩
创建开销微秒级,昂贵纳秒级,几乎免费
上限受内存限制,几万已是高百万级轻松
切换成本内核态上下文切换用户态挂载/卸载(mount/unmount)
阻塞行为阻塞即占线程阻塞时自动让出底层载体线程
传统高并发公式:
  连接数 × 每个请求的线程占用 ≈ 内存上限
  所以大家用 NIO + 线程池 + 异步回调,把"一个连接一个线程"压成"少量线程"

虚拟线程时代:
  连接数 ≈ 线程数,不需要异步化,回归简单的阻塞式代码

1.2 虚拟线程的本质

虚拟线程是 JVM 管理的、映射到少量**载体线程(Carrier Thread)**上的任务。阻塞操作发生时,JVM 把虚拟线程从载体上卸载(unmount),载体转去运行别的虚拟线程——这个切换只发生在用户态,成本远低于内核线程切换。

// 一个非常直观的对比:创建一万个线程
// 平台线程版(可能 OOM 或巨慢)
for (int i = 0; i < 10_000; i++) {
    new Thread(() -> { sleep(1000); }).start();
}

// 虚拟线程版:轻松创建,资源占用极小
for (int i = 0; i < 10_000; i++) {
    Thread.ofVirtual().start(() -> { sleep(1000); });
}

一句话总结: 平台线程贵在「内核管理 + 1MB 栈」,虚拟线程贵在「JVM 调度的轻量任务」——阻塞不再是浪费线程的理由,代码可以回到最直白的阻塞式写法。


二、虚拟线程的创建与调度

2.1 三种创建方式

// 方式一:ofVirtual(),可命名、设置异常处理器
Thread vt = Thread.ofVirtual()
        .name("worker-", 0)
        .uncaughtExceptionHandler((t, e) -> System.err.println(t + " 异常: " + e))
        .start(() -> System.out.println("hello"));

// 方式二:Executors 工厂
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<?> f = executor.submit(() -> doTask());
}

// 方式三:Thread.startVirtualThread 便捷静态方法
Thread t = Thread.startVirtualThread(() -> System.out.println("快捷创建"));

2.2 调度与 Carrier 线程

虚拟线程在载体线程上执行,载体线程数量默认等于可用 CPU 核数。遇到阻塞(IO、锁、sleep)时:

阻塞时 JVM 自动执行的三个动作:
  1. 当前虚拟线程被卸载(unmount),状态存入堆
  2. 载体线程从就绪队列拉取下一个虚拟线程运行
  3. 阻塞结束后虚拟线程重新装载(mount)到某个空闲载体

对开发者完全透明,你写的还是同步代码
// 关键概念:不需要自己调度,JVM 帮你做
// 但要注意:synchronized 块内虚拟线程不会让出载体(锁不参与 unmount)
synchronized (lock) {
    // JVM 不在此处做调度让出,可能阻塞整个载体线程
}

2.3 Pin 现象:虚拟线程被钉在载体上

当虚拟线程在 synchronized 块内阻塞时,JVM 无法卸载它,这种现象叫 Pin(钉住)。钉住的虚拟线程会一直占着载体线程,若大量线程被钉住,所有载体都被占满,虚拟线程退化成平台线程。

Pin 检测:
  -XX:+PrintVThreadEvents 可打印虚拟线程挂载/卸载日志
  -Djdk.tracePinnedThreads=full 会输出被钉住的线程栈

避免 Pin 的手段:
  1. 用 ReentrantLock 代替 synchronized
  2. 把 IO 移出 synchronized 块
  3. JDK 24 起 synchronized 已支持卸载,此问题逐步缓解
// 排查 Pin:jcmd 线程转储时看是否有 pinned 标记
// jcmd <pid> Thread.dump_to_file -format=json threads.json
// 虚拟线程状态中若出现 carrierThread 一直被占用,说明存在 Pin

一句话总结: 虚拟线程的「魔法」在于阻塞时自动卸载到用户态队列、恢复时重新装载——同步代码 + 自动调度,这就是它能替代异步编程模型的根本原因;但要留意 synchronized 造成的 Pin 现象。


三、结构化并发:StructuredTaskScope

3.1 为什么需要结构化并发

传统并发用 ExecutorService.submit 拿到 Future 后到处传,线程生命周期与代码结构脱节:忘记关闭、异常吞掉、任务泄漏都难察觉。结构化并发的原则是:任务的创建与结束必须与代码块边界一致——要么全部完成,要么整体失败并取消。

3.2 StructuredTaskScope 用法

// JDK 21:StructuredTaskScope 让并发任务有"作用域"
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> user  = scope.fork(() -> fetchUser());
    Future<Order> order = scope.fork(() -> fetchOrder());

    scope.join();            // 等待所有子任务
    scope.throwIfFailed();   // 任一失败则抛出第一个异常

    String userResult = user.resultNow();   // 不阻塞,直接取结果
    Order orderResult = order.resultNow();
}
// 离开 try 块自动 join + 取消未完成任务,生命周期清晰

结构化并发的好处是错误聚合:ShutdownOnFailure 在第一个子任务失败时关闭 scope 并取消其余任务,避免「等待一个永远不会成功的任务」。配套的 ShutdownOnSuccess 则适合「任一结果即可」的竞速场景(如多路查询取最快返回)。

Scope 策略语义适用
ShutdownOnFailure首个失败即关闭,抛第一个异常并行查询,任一失败整体失败
ShutdownOnSuccess首个成功即关闭,返回第一个结果竞速:多副本取最快
默认 ShutdownOnFailure等全部完成后统一处理通用批处理

一句话总结: 结构化并发把「并发任务的出生和死亡」锁进一个代码块作用域,子任务要么全成功、要么整体取消——比裸 Executor 的 Future 管理安全一个数量级。


四、虚拟线程与传统线程池的对比

4.1 线程池模型为什么不再必要

对比维度传统线程池 + Future虚拟线程 + 结构化并发
线程数手动配置大小,避免过量每任务一个虚拟线程,无上限顾虑
阻塞任务占线程池配额,池小则排队阻塞自动让出,不影响他人
异常处理Future.get 才暴露scope.throwIfFailed 聚合
代码形态需要异步/回调或反复 get直白同步代码
池大小调优反复压测调参数基本不需要调
// 反模式:用固定线程池执行 IO 密集任务,池小则整体吞吐受限
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> ioCall());   // 200 个并发之外的请求只能排队

// 虚拟线程模式:不需要池,来一个起一个
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
    pool.submit(() -> ioCall());  // 百万并发也没问题
}

4.2 什么时候仍然需要线程池

CPU 密集任务、需要限流或背压的场景、必须复用连接(如数据库连接池)的场景,仍适合平台线程 + 有界线程池。虚拟线程替换的是「大量阻塞型任务」,不是「有限资源的保护」。

一句话总结: 虚拟线程不是消灭线程池,而是消灭「为了支撑阻塞 IO 而过度扩张线程」的问题——IO 密集任务用虚拟线程,CPU 密集或限流任务才需要手动管理池。


五、性能实测与最佳实践

5.1 吞吐量对比

一个典型的 IO 密集基准(每秒请求数):

平台线程(200 线程池)    ≈ 2000 req/s   上限被池大小锁死
Netty 异步回调           ≈ 12000 req/s   代码复杂度高
虚拟线程(阻塞式代码)     ≈ 11000 req/s   代码与同步版无异

结论:虚拟线程能以同步代码拿到接近异步框架的吞吐。

5.2 内存与调度开销

虚拟线程的栈默认 16KB 起步、按需增长,一千万个虚拟线程也仅占用几 GB 堆内存储(栈存在堆上)。GC 会扫描虚拟线程栈,但现代 JVM(JDK 21+)已大幅优化。

指标平台线程虚拟线程
单线程栈1MB 固定16KB 起步,按需增长
100 万线程内存约 1GB 起约 100~200MB
创建耗时(100 万)数十秒到分钟秒级以内
GC 扫描成本低(线程少)需扫描虚拟线程栈,已优化
// 最佳实践:虚拟线程要配合"短栈帧"的阻塞点才高效
// 好的写法:阻塞点集中在少数方法里,调用栈浅
String result = httpClient.send(req, BodyHandlers.ofString()).body();

// 坏的写法:在递归/深栈里做 IO,栈增长 + GC 压力大

5.3 与 CompletableFuture 的取舍

CompletableFuture 异步编排的问题:
  1. 组合越多,回调嵌套越深,调试越难
  2. 异常栈丢失上下文,线上定位困难
  3. 默认使用 ForkJoinPool.commonPool,池小则排队

虚拟线程 + StructuredTaskScope 替代:
  还是同步代码,顺序自然,栈完整
// 异步版(编排链)
CompletableFuture.supplyAsync(() -> fetchA())
    .thenCombine(CompletableFuture.supplyAsync(() -> fetchB()), (a, b) -> a + b)
    .thenAccept(System.out::println);

// 虚拟线程版(同样的并发,同步写法)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Future<String> fa = scope.fork(() -> fetchA());
    Future<String> fb = scope.fork(() -> fetchB());
    scope.join();
    System.out.println(fa.resultNow() + fb.resultNow());
}

一句话总结: 虚拟线程的吞吐接近异步框架、代码保持同步形态;代价是栈存堆上带来 GC 压力,应控制阻塞调用栈深度;在编排层面,StructuredTaskScope 可以优雅替代 CompletableFuture 的复杂回调链。


六、虚拟线程与同步机制

6.1 synchronized 与锁的坑

synchronized 块内的阻塞不会让出载体线程(虚拟线程未实现 synchronized 的 unmount 优化),这会让载体被长时间占住。JUC 的 ReentrantLock 才支持阻塞时让出。

// 推荐:IO/阻塞操作放在锁外,或用 ReentrantLock 代替 synchronized
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 临界区只放内存操作,别放 IO
} finally {
    lock.unlock();
}

6.2 ThreadLocal 的坑

虚拟线程数量可能达到百万级,每个都持有 ThreadLocal 会浪费内存,且虚拟线程会被池化复用,ThreadLocal 值可能串到下一个任务。替代方案 ScopedValue(JDK 21 预览、JDK 24 改进)提供不可变、结构化作用域的数据传递:

问题说明对策
内存放大百万线程 × 每个几个 KB ThreadLocal限制 ThreadLocal 数量
值串用虚拟线程复用导致脏数据用后显式 remove
继承性虚拟线程不继承父线程 ThreadLocal用 ScopedValue(JDK 21 预览)传递上下文
// ScopedValue:结构化、只读、随作用域传递
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

String result = ScopedValue.callWhere(REQUEST_ID, "req-123",
        () -> handleRequest());   // 作用域内可见,子任务自动继承

void handleRequest() {
    String id = REQUEST_ID.get();   // 在并发子任务中也能读到
    // 只读,不随线程池复用而串值
}

一句话总结: 虚拟线程时代 synchronized 和 ThreadLocal 都要重新审视——锁内不放 IO、上下文传递改用 ScopedValue,否则载体被占或内存翻倍。



七、迁移指南:从异步代码到虚拟线程

7.1 迁移步骤

1. 找出 IO 密集型阻塞代码(HTTP、DB、MQ、文件)
2. 把 ExecutorService.submit(...) 换成 newVirtualThreadPerTaskExecutor
3. 如果用了 CompletableFuture 做编排,改用 StructuredTaskScope
4. 排查 synchronized 与 ThreadLocal,替换为 ReentrantLock / ScopedValue
5. 压测对比吞吐与 GC,确认无栈内存暴涨

7.2 常见误区

  • 误区一:虚拟线程必须配虚拟线程专用框架——不需要,任何阻塞代码都能跑。
  • 误区二:把虚拟线程当「无限资源」——内存仍是有限的。
  • 误区三:虚拟线程和 NIO 二选一——Netty 底层 NIO 不变,上层 handler 可改用虚拟线程。
// Spring Boot 3.2+:一行配置启用虚拟线程
// spring.threads.virtual.enabled=true

一句话总结: 迁移不是重写,而是「把 Executor 换成虚拟线程 + 把 Future 编排换成结构化并发」——收益是吞吐翻倍且代码更简单。


八、实战陷阱清单

陷阱现象对策
synchronized 内做 IO载体线程被占,吞吐骤降锁内不放 IO,或用 ReentrantLock
ThreadLocal 膨胀百万线程内存暴涨少用 ThreadLocal,用后 remove
深栈 + 阻塞GC 压力大、分配多控制调用栈深度,浅阻塞点
忘记关闭 Executor虚拟线程泄漏try-with-resources 包裹
无界创建失控内存被耗尽必要处用 Semaphore 限流
把池思想套用反复调池大小直接每任务一线程

九、总结

维度平台线程虚拟线程
资源成本高(1MB 栈 + 内核线程)低(KB 级,堆内)
阻塞处理占线程自动让出载体
代码形态异步化或限流同步阻塞式
生命周期管理Executor + FutureStructuredTaskScope

一句话记住:虚拟线程让「一个请求一个线程」回归常态,结构化并发让并发任务的生命周期可见可控——两者结合,是 JDK 21 之后服务端并发编程的新默认范式。理解载体线程与让出机制,迁移时就不会踩 synchronized 与 ThreadLocal 的坑。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. Java 序列化方案对比与性能
  2. Java 并发设计模式实战
  3. OOM 排查与堆转储分析