线程是并发编程最基础的资源,而传统线程的昂贵性一直是高并发服务的瓶颈。虚拟线程(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 + Future | StructuredTaskScope |
一句话记住:虚拟线程让「一个请求一个线程」回归常态,结构化并发让并发任务的生命周期可见可控——两者结合,是 JDK 21 之后服务端并发编程的新默认范式。理解载体线程与让出机制,迁移时就不会踩 synchronized 与 ThreadLocal 的坑。
延伸阅读
- Java 并发编程与 JUC 包完全指南 — 锁、Future、CompletableFuture 与虚拟线程的互补
- Java 网络编程与 NIO 实战 — NIO 多路复用与虚拟线程时代的 IO 模型选型
- Java 性能优化:从代码到 JVM 的全链路调优 — 虚拟线程场景下的吞吐与 GC 调优
- Java 17+ 核心语法深度指南 — 记录、密封类等现代语法与虚拟线程的配合
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。