Java 虚拟线程深度工程实战:从 Continuation 栈切换、Carrier 调度到 Pinning 陷阱的结构化并发全解
执行摘要:Java 21 把 Project Loom 的虚拟线程(Virtual Thread)正式转正,但它不是"更轻的线程池",而是一个能被 JVM 挂起和恢复的调用栈。理解这一点,你才会明白为什么"把Executors.newFixedThreadPool换成newVirtualThreadPerTaskExecutor就能提升十倍吞吐"这句话在某些系统里成立、在另一些系统里却会让 P99 直接翻倍。本文拆开虚拟线程的三层机制——Continuation 的栈帧拷贝、ForkJoinPool carrier 的 mount/unmount、synchronized 的 pinning 代价——并给出 Spring Boot 3.x 上的开启方式、连接池悖论的解法、JFR 观测手段,以及一份可直接对照检查的生产坑位清单。
一、心智模型:虚拟线程不是线程,是"可暂停的调用栈"
平台线程(Platform Thread)是操作系统线程的薄封装,一个 Java 线程 = 一个内核 task_struct。栈大小默认 1MB(-Xss),创建成本在微秒级,阻塞时会让出 CPU 但线程对象本身连同 1MB 栈一起占着内存。所以"一请求一线程"模型在 5000 并发时就撑不住了——不是 CPU 不够,是内存和上下文切换先崩。
虚拟线程换了个思路:它不由操作系统调度,而是由 JVM 调度。虚拟线程跑在"载体线程"(carrier thread)之上,遇到阻塞式 I/O 或 LockSupport.park() 时,JVM 把它的调用栈从 carrier 上"卸下来"(unmount),腾出 carrier 去跑别的虚拟线程;等 I/O 就绪,再把栈"装回去"(mount)继续执行。
| 维度 | 平台线程 | 虚拟线程 |
|---|---|---|
| 调度者 | 操作系统内核 | JVM(ForkJoinPool carrier) |
| 1:N 关系 | 1:1(一个 Java 线程一个内核线程) | M:N(百万虚拟线程复用少量 carrier) |
| 栈 | 固定 1MB 连续内存 | 堆上的 stackChunk 链表,按需增长 |
| 阻塞代价 | 内核线程挂起,资源占用不释放 | unmount,carrier 立即复用 |
| 线程局部变量 | ThreadLocal 可用 | 可用但强烈不推荐(百万份副本) |
| 适用场景 | CPU 密集、有 pinning 的同步块 | I/O 密集、高并发阻塞式代码 |
一句话:虚拟线程让"阻塞式写法"重获"异步性能"。你不用把代码改写成 CompletableFuture 链式回调或 Reactor 的 Mono/Flux,就能拿到接近 Netty 的并发度。这才是它真正的价值——不是性能提升,是代码可读性与性能的兼得。
二、Continuation:虚拟线程的物理内核
虚拟线程底层是 JDK 内部的 jdk.internal.vm.Continuation。它本质是一个可被显式 yield 和 resume 的调用栈。概念上等价于下面这段伪代码:
// 概念示意:Continuation 是 JDK 内部 API,业务代码不直接使用
ContinuationScope SCOPE = new ContinuationScope("vt");
Runnable body = () -> {
System.out.println("A");
Continuation.yield(SCOPE); // 挂起:栈帧被"冻存"
System.out.println("C"); // 恢复后从这里继续
};
Continuation c = new Continuation(SCOPE, body);
c.run(); // 打印 A,然后 yield 返回
c.run(); // 打印 C,从 yield 点恢复
关键工程点有三个:
- 栈帧存在堆上,不是连续内存。虚拟线程的栈是一串
stackChunk对象(默认 128 帧一块),挂在堆里。这意味着虚拟线程可以被 GC 移动和回收,也意味着它在 unmount 状态下几乎不占用操作系统资源——只有堆上的对象。 - mount/unmount 是拷贝,不是零成本。unmount 时 JVM 把 carrier 栈上属于虚拟线程的帧复制到堆;mount 时反向复制。热点路径上频繁 mount/unmount(比如一个虚拟线程里做几百次微秒级 I/O)会让拷贝开销盖过 I/O 本身。这也是"虚拟线程不适合 CPU 密集任务"的根本原因——CPU 密集任务既不会 unmount,又白白背了一层间接性。
- 深层调用栈会放大成本。一个 200 层深的调用栈,每次 mount/unmount 要搬运的帧更多。如果热路径上递归很深,虚拟线程反而比平台线程慢。
三、调度器:ForkJoinPool 的 carrier 与三个可调参数
虚拟线程默认跑在一个特殊的 ForkJoinPool 上,它以 FIFO 模式工作(不是常规的 LIFO work-stealing),carrier 数量默认等于 Runtime.availableProcessors()。三个启动参数值得记住:
# carrier 线程数(默认 = CPU 核数)
-Djdk.virtualThreadScheduler.parallelism=16
# carrier 最大池化数量(默认 256)
-Djdk.virtualThreadScheduler.maxPoolSize=256
# 调度器追踪(调试用,别开在生产)
-Djdk.virtualThreadScheduler.trace=1
注意这里有个常见误解:carrier 数量不等于并发上限。100 万个虚拟线程可以只跑在 16 个 carrier 上——只要它们大部分时间在等 I/O。carrier 只在虚拟线程真正执行 CPU 指令时才被占用。
反过来说,如果所有虚拟线程都在做 CPU 计算,它们会排在同一个 FIFO 队列里饿死彼此,吞吐不会比 16 个平台线程更高。
四、Pinning:最大的生产陷阱
pinning(钉住)指虚拟线程在执行某段代码时无法被 unmount,carrier 被它独占。JDK 21 里有两个来源:
synchronized块/方法内部的阻塞(JEP 444 时代的已知限制);- native 方法或 foreign function 调用期间。
在 JDK 21~23 上,这段代码的后果是灾难性的:
// JDK 21/22/23:危险!carrier 被钉死
synchronized (lock) {
// 阻塞式 I/O 在同步块内 → virtual thread 无法 unmount
// carrier 线程被占满 → 整个应用的虚拟线程全部停摆
byte[] data = httpClient.send(request, BodyHandlers.ofByteArray()).body();
cache.put(key, data);
}
如果这样的代码被 16 个虚拟线程同时执行,而你只有 16 个 carrier——整个应用的所有虚拟线程都会停摆,包括那些跟这个锁毫无关系的请求。这是最隐蔽也最致命的生产事故形态。
JEP 491(JDK 24) 通过重新实现 synchronized 的监视器,使其与虚拟线程兼容,基本消除了 pinning 问题。但在 JDK 21 LTS 上,你必须自己改:
// 正解一:换成 ReentrantLock(Loom 感知,不会 pin)
private final ReentrantLock lock = new ReentrantLock();
public void load(String key) throws Exception {
lock.lock();
try {
var data = httpClient.send(request, BodyHandlers.ofByteArray()).body();
cache.put(key, data);
} finally {
lock.unlock();
}
}
// 正解二(更好):把阻塞 I/O 移出临界区
public void loadV2(String key) throws Exception {
var data = httpClient.send(request, BodyHandlers.ofByteArray()).body(); // 锁外 I/O
lock.lock();
try { cache.put(key, data); } finally { lock.unlock(); }
}
检测和定位 pinning 的两个手段:
# 启动时追踪(short=摘要,full=完整栈),仅用于排查
-Djdk.tracePinnedThreads=full
# 生产推荐:JFR 事件,开销极低可常开
jcmd <pid> JFR.start name=vt settings=profile
# 事件名:jdk.VirtualThreadPinned(duration 字段即钉住时长)
工程建议:升级到 JDK 24+ 是最省事的解法;如果卡在 21 LTS,就在 CI 里跑一遍 -Djdk.tracePinnedThreads=short,把输出当成错误处理。
五、Structured Concurrency 与 Scoped Values:并发的正确组织方式
虚拟线程解决了"能开多少个"的问题,Structured Concurrency 解决"这么多并发怎么管"的问题。JDK 21 引入预览 API(JDK 25 已第五轮预览,API 持续演进):
// 结构化并发:子任务生命周期被限制在作用域内
try (var scope = StructuredTaskScope.open(Joiner.<Response>allSuccessfulOrThrow())) {
Subtask<Response> user = scope.fork(() -> userService.load(uid));
Subtask<Response> coupon = scope.fork(() -> couponService.load(uid));
Subtask<Response> address = scope.fork(() -> addressService.load(uid));
scope.join(); // 等待全部完成,或任一失败则取消其余
return assemble(user.get(), coupon.get(), address.get());
}
// 作用域关闭时,所有子任务保证已结束 —— 不会有"逃逸的后台线程"
它的价值在于错误传播和取消是结构化的:一个子任务失败,Joiner 会取消其余仍在跑的子任务,而不是让它们变成野线程继续消耗资源。这是 CompletableFuture.allOf() 做不到的——后者失败后其他 future 照样在跑。
配套的 ScopedValue(JDK 21 预览,JDK 25 正式)用来替代 ThreadLocal:
private static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
void handle(Request req) {
ScopedValue.where(CTX, new RequestContext(req.traceId()))
.run(() -> process()); // process() 及其调用链内可读,退出自动失效
}
void process() {
log.info("trace={}", CTX.get().traceId()); // 无需层层透传参数
}
对比:ThreadLocal 在百万虚拟线程下意味着百万份 map 副本,且必须手动 remove() 否则内存泄漏;ScopedValue 是不可变的、有明确作用域边界的、退出即失效的。在虚拟线程世界里,ThreadLocal 应被视为遗留设施。
六、Spring Boot 实战:开启、连接池悖论与限流
Spring Boot 3.2+ 一行开启:
# application.yml
spring:
threads:
virtual:
enabled: true # Tomcat/Jetty 请求线程 + @Async 全部切到虚拟线程
或者手动声明 executor(推荐,避免影响不该切的组件):
@Bean
TaskExecutor virtualExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
@Bean
Executor httpClientExecutor() {
return Executors.newVirtualThreadPerTaskExecutor(); // 每次提交开一个新虚拟线程
}
连接池悖论是落地时最容易踩的坑:
虚拟线程让你能开 10 万并发,但你的 HikariCP 只有 20 个连接、下游服务只扛得住 500 QPS。并发度被瞬间放大后,瓶颈从 Tomcat 线程池转移到了数据库和下游——而且因为没有排队,表现为"所有请求一起超时"而不是"部分请求排队后成功"。
解法是用信号量显式限流,把并发压回下游能承受的水平:
// 虚拟线程负责吞吐,信号量负责保护下游
private final Semaphore dbPermits = new Semaphore(50); // = HikariCP 池大小
public Order loadOrder(long id) throws Exception {
dbPermits.acquire(); // 虚拟线程在此 park,不占用 carrier
try {
return jdbcTemplate.queryForObject(SQL, rowMapper, id);
} finally {
dbPermits.release();
}
}
这段代码里,Semaphore.acquire() 阻塞的是虚拟线程,不是 carrier——所以 10 万个请求可以整齐地"堆"在信号量上等待,只占堆内存不占操作系统线程。这正是虚拟线程最理想的使用形态。
三条落地铁律:
- 不要池化虚拟线程。
newVirtualThreadPerTaskExecutor()每次提交都新建一个,创建成本约 1 微秒,池化只会引入额外开销和语义混乱。 - 不要给 CPU 密集任务用虚拟线程。用
newFixedThreadPool(CPU 核数)隔离出来。 - 同步块里不要做 I/O(JDK 21/23 上),或干脆升级到 JDK 24+。
七、观测:虚拟线程时代怎么调
传统线程 dump 在百万虚拟线程下无法阅读。正确姿势:
# JSON 格式 dump,可程序化分析(JDK 21+)
jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json
# 关键 JFR 事件(建议常开,开销 < 1%)
# jdk.VirtualThreadStart / jdk.VirtualThreadEnd
# jdk.VirtualThreadPinned ← pinning 时长,重点监控
# jdk.VirtualThreadSubmitFailed ← carrier 池耗尽
监控面板上应该盯的三个指标:carrier 线程池的利用率(持续 100% 说明有 pinning 或 CPU 密集任务混进来了)、VirtualThreadPinned 的 p99 时长、以及 JVM 堆里 stackChunk 对象的总量(反映存活虚拟线程数)。
八、生产坑位清单
| 坑 | 现象 | 解法 |
|---|---|---|
| synchronized 内阻塞 I/O | JDK 21/23 上 carrier 全体停摆,P99 暴涨 | 升级 JDK 24+ 或改用 ReentrantLock |
| 无限制并发打爆下游 | 开虚拟线程后数据库/下游全线超时 | Semaphore 按下游容量限流 |
ThreadLocal 泛滥 | 堆内存随虚拟线程数线性增长 | 迁移到 ScopedValue |
| 池化虚拟线程 | 性能不升反降,语义混乱 | 用 newVirtualThreadPerTaskExecutor,不要池化 |
| CPU 密集任务混用 | FIFO 队列饿死,吞吐不如平台线程 | 独立固定线程池隔离 |
| 旧版监控 Agent | 基于 JVMTI 线程事件的 Agent 误判或开销爆炸 | 升级 Agent 或改用 JFR |
| 深递归 + 高频 I/O | mount/unmount 拷贝开销盖过业务逻辑 | 降低调用栈深度或批量化 I/O |
九、结论:四条工程判断
- 虚拟线程买的是"写法自由",不是"性能"。同样的 I/O 吞吐,Netty 也能做到;虚拟线程的价值在于你可以用同步阻塞的写法拿到它,代码可维护性高一个量级。
- 瓶颈守恒,只是搬家。取消线程池上限后,压力会精确转移到最脆弱的下游。上线前必须重新审视每个依赖的容量,并显式限流。
- pinning 是 JDK 21 LTS 上唯一的硬伤,且症状极具迷惑性(表现为全局卡顿而非局部失败)。要么升 JDK 24+,要么用
jdk.tracePinnedThreads在 CI 里卡住。 - Structured Concurrency 才是虚拟线程的正确组织形态。取消、超时、错误传播都应该在作用域层面完成,而不是靠散落的
ExecutorService和手写Future。
落到一句工程直觉:虚拟线程把"并发度"从一种稀缺资源变成了近乎免费的资源,于是"如何限制并发"第一次成了应用开发者必须主动设计的课题——这跟过去二十年"如何榨取更多并发"的经验恰好相反,也是很多团队迁移后翻车的真正原因。

发表评论 取消回复