Skip to content
not500
Go back

Java 21 虚拟线程

On this page

虚拟线程最容易被误解成“更便宜的线程池”。这个说法只碰到了表面。它真正改变的是:并发任务的数量不再需要和操作系统线程的数量绑定

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

虚拟线程运行时会 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 可管理的堆结构中。

parkunpark 与 Continuation 的分工

这三个概念很容易混在一起,但处于不同抽象层。

换句话说,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

唤醒来源取决于虚拟线程为何等待。

网络 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 上恢复执行。

因此,选择虚拟线程时最值得先问的不是“能不能创建更多线程”,而是:我的任务是不是大部分时间都在等待,而系统真正的容量边界又在哪里? 前者决定虚拟线程能否释放并发潜力,后者决定还需要在哪些资源上做限流和保护。


Share this post:

Previous Post
SSE 技术详解:从长 HTTP 响应到重连、心跳与消息恢复
Next Post
LeetCode 80 删除有序数组中的重复项 II:按段压缩的双指针题解