虚拟线程最容易被误解成“更便宜的线程池”。这个说法只碰到了表面。它真正改变的是:并发任务的数量不再需要和操作系统线程的数量绑定。
Java 21 通过 JEP 444 将虚拟线程正式引入 Java。它的目标不是让一段代码算得更快,而是让大量以等待为主的任务,能够继续用同步、顺序的代码写法运行,而不必让每一个等待者都长期占住一个 OS 线程。
下面从一个订单详情接口开始,逐步看到它适合什么、内部有哪些组件,以及一次阻塞如何从 park 走到恢复执行。
一个订单接口,CPU 其实没有那么忙
假设订单详情需要依次完成三次 I/O:查询订单、库存和物流。
收到请求
→ 查询订单数据库,等待约 100 ms
→ 调用库存服务,等待约 150 ms
→ 调用物流服务,等待约 200 ms
→ 组装响应,CPU 计算约 2 ms
一次请求大约花 452 ms,但真正使用 CPU 的时间可能只有 2 ms。其余时间,任务只是等数据库或网络返回。
传统“一个请求一个平台线程”的模型在这里会遇到矛盾:线程虽然没有消耗 CPU,却仍然占着 OS 线程。如果用 200 个平台线程保护系统,同时涌入 10,000 个请求,那么前 200 个请求大多在等待,剩余请求只能在队列中等待“有线程可用”。CPU 可能还很空闲,线程池却已经满了。
平台线程之所以不能无限增加,不只是 Java 对象数量的问题。一个平台线程长期对应一个 OS 线程,并关联原生栈、内核调度状态和上下文切换成本。线程数高到一定程度,内存、内核调度和缓存扰动都会成为负担。
虚拟线程处理的正是这类“等待者很多、实际计算很少”的场景:Web 请求、HTTP/RPC 调用、数据库访问、消息消费和网络文件 I/O。
编程模型:仍然是同步代码,但不再以线程池作为并发闸门
创建虚拟线程可以直接使用 Thread.startVirtualThread:
Thread.startVirtualThread(() -> {
Order order = queryOrderDatabase(orderId);
Stock stock = queryStockService(orderId);
Logistics logistics = queryLogisticsService(orderId);
sendResponse(assemble(order, stock, logistics));
});
更常见的是为每个任务分配一个虚拟线程:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> callRemoteService());
}
}
这里不应再把虚拟线程池理解为传统固定线程池。平台线程池的大小通常是在限制昂贵的 OS 线程;虚拟线程足够轻量,推荐模型是“一任务一虚拟线程”。
但这不等于可以无约束地压垮下游。数据库连接数、HTTP 连接、第三方 API 配额仍然是有限资源,需要在资源边界处限流:
var permits = new Semaphore(100);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
permits.acquire();
try {
return callLimitedService();
} finally {
permits.release();
}
});
}
虚拟线程消除了“等待时没有平台线程”的瓶颈,不能消除数据库或下游服务本身的容量上限。
运行时的几个角色
理解虚拟线程,关键是区分任务、虚拟线程、载体线程和 OS 线程。
很多 VirtualThread
↓ JVM 调度
少量 Carrier Thread(平台线程)
↓ OS 调度
少量 OS Thread
↓
CPU
- VirtualThread:业务看到的逻辑线程,拥有自己的
Thread语义、状态和执行现场。 - Scheduler:负责把可运行的虚拟线程交给可用的载体线程。默认实现基于
ForkJoinPool,其并行度通常与处理器数量相关,而非虚拟线程数量。 - Carrier Thread:实际承载虚拟线程执行的平台线程。CPU 和操作系统只直接调度它。
- Continuation:可暂停、可从原处恢复的一段计算,是保存调用栈并交还控制权的底层能力。
- StackChunk:虚拟线程暂停时,HotSpot 用于保存其栈帧的堆中结构。
- I/O Poller:等待大量网络 I/O 就绪事件的组件;在 Linux 上可借助
epoll一类机制,在其他系统上使用相应的事件通知设施。
虚拟线程运行时会 mount 到某个 Carrier;当它可以被卸载时,又会 unmount。恢复时不要求回到原 Carrier:在 C1 上等待的虚拟线程,完全可能在 C3 上继续执行。
为什么等待时不用占着原生栈
平台线程的执行状态长期绑定在 OS 线程及其原生栈上。若它在 socket.read() 阻塞,OS 线程和原生栈都还在,只是暂时不运行。
虚拟线程不同。它真正运行时也确实使用 Carrier 的执行栈;但是遇到 JDK 已适配的阻塞点时,JVM 可以暂停当前的 Continuation,将 Java 栈帧冻结到堆中的 StackChunk,再把虚拟线程从 Carrier 卸载。此时 Carrier 可以立即去运行另一个已就绪的虚拟线程。
V1 在 C1 上执行 socket.read()
↓ 数据尚未到达
冻结调用栈(freeze)并卸载 V1
↓
V1:等待 I/O,栈状态保存在堆中
C1:返回调度器,开始执行 V2
当 I/O 完成,V1 的状态重新变为可运行。调度器把它交给某个 Carrier,HotSpot 再将保存的栈帧恢复为可执行状态(thaw),业务代码便从 read() 之后继续。对应用代码而言,仍然只是一次普通的同步阻塞调用。
这也是“轻”的具体含义:不是没有栈,也不是堆内存天然更便宜,而是等待中的虚拟线程不再永久独占一个 OS 调度实体和一块原生线程栈;它的执行现场按实际使用量保存在 JVM 可管理的堆结构中。
park、unpark 与 Continuation 的分工
这三个概念很容易混在一起,但处于不同抽象层。
LockSupport.park()是线程同步语义:当前线程因条件不满足而不能继续,需要等待许可、超时、中断或其他事件。LockSupport.unpark(thread)是唤醒语义:向目标线程发一个最多积累一份的 permit,使它可以继续推进。Continuation.yield()是执行机制:冻结当前这段可恢复计算,把控制权返回给外层调度器。
换句话说,park 说明“为什么不能继续”,Continuation.yield 说明“怎样在不阻塞 Carrier 的前提下暂停整段调用栈”。它不是公开 API 中的 Thread.yield();后者表示线程仍然可运行,只是主动让出一次执行机会。
一次正常的 park 路径可以简化为:
LockSupport.park()
→ VirtualThread 检查并消费 parkPermit
→ 没有 permit 时,状态变为 PARKING
→ Continuation.yield() 冻结调用栈
→ V1 从 Carrier 卸载,状态变为 PARKED
→ Carrier 返回调度器,执行其他任务
unpark 并不是“让目标虚拟线程马上抢到 CPU”。它先设置 permit;若目标已经处于 PARKED,再将其转为可调度状态并提交给 Scheduler。之后哪个 Carrier 何时取到它,仍由 JVM 调度决定。
外部事件
→ unpark(V1),写入 permit
→ PARKED → UNPARKED / RUNNABLE
→ 提交 Scheduler
→ 某个 Carrier 取到 V1
→ mount + thaw
→ 从 park() 或 I/O 调用之后继续
permit 的作用是避免丢失唤醒。即使 unpark(V1) 发生在 V1 调用 park() 之前,permit 也会留下;V1 随后调用 park() 时消费 permit 并直接返回,而不会错误地睡下去。
谁会触发 unpark
唤醒来源取决于虚拟线程为何等待。
- 另一个线程直接调用
LockSupport.unpark。 Semaphore、ReentrantLock、CountDownLatch、阻塞队列等同步器在条件满足时唤醒等待线程。- 定时等待到期时,定时任务触发唤醒。
- 网络 I/O 就绪时,Poller 找到登记在文件描述符上的虚拟线程并唤醒它。
- 中断也会使等待中的虚拟线程重新得到调度机会;上层 API 决定它是返回还是抛出
InterruptedException。
网络 I/O 的路径尤其能体现虚拟线程的价值:当前 socket 不可读时,JDK 注册兴趣事件并 park 虚拟线程,而不是让一个 Carrier 永久阻塞在每个 socket 上。Poller 接到“可读”通知后,再 unpark 对应的虚拟线程。因此少量 OS 级等待机制可以服务大量连接,业务层却不必改写成回调链或 Future 链。
边界:并非所有阻塞都能神奇地卸载
虚拟线程的收益来自“阻塞时可以卸载 Carrier”,所以它有清晰的边界。
首先,CPU 密集型任务并不会更快。如果任务一直在压缩、加密、图像处理或大规模计算,Carrier 始终被占用,并行度仍由 CPU 核心数决定。此时应控制计算任务的并行度,而不是创建几十万个虚拟线程。
其次,JDK 是否能卸载取决于阻塞点是否被适配。对于不可协作的 native 调用等场景,Carrier 仍可能被阻塞。
最后,Java 21 要特别关注 pinning。虚拟线程在某些情况下不能从 Carrier 卸载,例如在 synchronized 临界区内进行长时间阻塞 I/O,或执行某些 native/foreign 调用。此时它被“固定”在 Carrier 上,等待时 Carrier 也无法去服务其他虚拟线程,扩展性会下降。
synchronized (lock) {
// Java 21 中,避免在这里做长时间阻塞 I/O
return socket.read();
}
这不意味着程序一定出错,而是高并发场景下会损失虚拟线程原本的吞吐能力。可以借助 JFR 事件检查 pinning,并将长 I/O 移出频繁执行的临界区。需要注意的是,后续 JDK 的 JEP 491 已着手移除 synchronized 相关限制;本文讨论的是 Java 21 的运行特征。
此外,ThreadLocal 仍按逻辑线程保存。大量虚拟线程各自携带庞大的 ThreadLocal 数据,同样会吞掉内存优势;将大对象放入每线程缓存前仍需要评估数量级。
最终把它看成什么
虚拟线程不是异步 I/O 的替代品,也不是 CPU 加速器。它是 JVM 把“很多等待中的任务”从“很多等待中的 OS 线程”里解耦出来的一种执行模型。
对于订单接口这样的 I/O 密集型任务,完整链路可以压缩成一句话:
虚拟线程在 Carrier 上运行;遇到可卸载的等待时,借助 Continuation 保存调用栈并
park自己;外部事件通过unpark让它重新进入调度器;随后它在任意可用 Carrier 上恢复执行。
因此,选择虚拟线程时最值得先问的不是“能不能创建更多线程”,而是:我的任务是不是大部分时间都在等待,而系统真正的容量边界又在哪里? 前者决定虚拟线程能否释放并发潜力,后者决定还需要在哪些资源上做限流和保护。