Java 技术
#Java#虚拟线程#Project Loom#并发#调度#阻塞

虚拟线程调度模型与协程化阻塞实践

深入剖析 OpenJDK 虚拟线程的调度机制,包括载体线程挂载与卸载、ForkJoinPool 工作窃取,以及同步代码协程化的工程方法,并讨论大规模虚拟线程下的公平性与适用边界。

想象一个繁忙的餐厅厨房:订单如潮水般涌入,每道菜品的加工流程都包含切菜、烹炒、摆盘等步骤。传统上,我们为每个订单安排一名厨师从头跟到尾。这种方法直观简单,但当订单量大时,厨师数量会成为瓶颈——雇太多厨师成本高昂,且厨房空间有限。Java 平台曾经面临类似的困境:每个并发任务(如处理一个 HTTP 请求)都由一个操作系统线程承载。OS 线程的创建与切换开销大、内存占用高,导致服务器在处理数千乃至数万个并发连接时,线程数先于 CPU 或网络资源耗尽,吞吐量触顶。

Project Loom 引入的虚拟线程(Virtual Thread)改变了这一模式。它允许我们将每个任务映射到一个轻量的虚拟线程,而调度器在少量 OS 线程(称为载体线程,Carrier Thread)上挂载(mount)和卸载(unmount)这些虚拟线程,从而以接近硬件极限的并发度运行大量看起来很“同步”的代码。本文将解析这一调度模型的核心机制,演示如何将传统线程池阻塞代码改造为虚拟线程风格,并回答一个关键问题:当虚拟线程数量极大时,调度公平性如何保证?

平台线程的枷锁:为何我们需要虚拟线程

Java 诞生之初,线程就被设计为与操作系统线程一一对应,即平台线程(Platform Thread)。这种实现让线程拥有了完整的栈用于局部变量、方法调用和异常传播,调试器可以跟随线程单步执行,分析器可以按线程统计 CPU 时间——这套模型直观且功能强大。然而,其代价是巨大的:每个平台线程都持有一个操作系统原生线程,初始化时需分配约 1MB 的栈内存(典型值),线程间上下文切换会陷入内核,耗时可达微秒级。当线程数达到数以万计时,内存占用和调度开销已不可接受,而实际应用的并发需求(如 Web 服务器同时服务 10 万个长连接)常常远超这个规模。

为突破这一限制,开发者不得不转向异步编程风格:将一个请求的处理逻辑拆分成多个阶段,通过回调或 CompletableFuture 组合起来,每个阶段执行在某个线程池中的线程上,等待 I/O 时就释放该线程。这种方法虽然提升了吞吐,却牺牲了代码的简单性:简单的顺序逻辑被切散为嵌套的 Lambda,异常处理变得复杂,调试栈帧出现撕裂。Loom 团队在 JEP 444 中明确指出:“异步编程风格要求开发者放弃语言基本的顺序组合算子(如循环和 try/catch)”,维护和理解成本陡增。

虚拟线程的出现试图“鱼与熊掌兼得”:保持线程的编程模型,但去除它与 OS 线程的耦合。它并非平台线程的替代品,而是一种补充,目标是用 thread-per-request 的直观风格达到高并发下的高硬件利用率。

挂载与卸载:载体线程驱动虚拟线程的工作循环

虚拟线程的实现依赖于一个关键洞察:大多数并发任务在其生命周期内大部分时间都在等待 I/O、锁或定时,而非消耗 CPU。传统平台线程在阻塞时会进入内核等待,线程本身虽不活跃,但仍占用 OS 线程资源。虚拟线程则在阻塞时主动卸载载体线程,将自身的栈帧保存到堆内存中,让出载体线程给其他虚拟线程运行。等阻塞事件就绪后,虚拟线程再被调度器挂载到一个空闲的载体线程上继续执行。

这个机制的底层核心是 JDK 内部使用的 延续(Continuation)结构。当虚拟线程执行一个阻塞操作(如 thread.sleep()socket.read())时,JVM 运行时会将当前方法的栈帧从载体线程的栈上移出,序列化到堆中的 Continuation 对象里,然后让载体线程返回调度循环取下一个任务。这一过程称为 yield(让出)或卸载。当阻塞条件消除(如数据到达),对应的 Continuation 会被放入调度队列,等待某个载体线程来承载它继续执行。

载体线程本身是常规的平台线程,它们通常隶属于一个 ForkJoinPool,这是虚拟线程的默认调度器。ForkJoinPool 采用工作窃取算法:每个载体线程维护一个任务队列,空转线程会从其他队列尾部“窃取”任务。这种设计天然适合大量短小任务的调度,因为它能在线程间自动平衡负载,且避免了全局队列的激烈竞争。

下图展示了一个虚拟线程的生命周期和它如何与载体线程交互:

flowchart TD
    create[创建虚拟线程] --> schedule[调度器分配载体线程]
    schedule --> mounted[挂载到载体线程运行]
    mounted --> block_detected[遭遇阻塞操作<br/>如 Socket.read]
    block_detected --> unmount[卸载虚拟线程<br/>栈帧保存到堆]
    unmount --> carrier_free[载体线程被释放<br/>去执行其他任务]
    carrier_free --> park[虚拟线程处于阻塞态<br/>等待I/O完成]
    park --> unblock[阻塞条件满足<br/>I/O就绪]
    unblock --> schedule
    mounted --> complete[任务完成<br/>虚拟线程终结]
    complete --> carrier_free

值得注意的是,由于载体线程数量远小于虚拟线程数量,一个虚拟线程可能在多次挂载/卸载周期中运行在不同的载体线程上。这对编程模型来说是透明的:所有线程局部变更、异常堆栈等信息在移回载体线程时都能完整恢复,调试器看到的始终是一个逻辑上连续的线程。

协程化阻塞:同步代码背后的非阻塞机制

虚拟线程的另一块基石是 JDK 核心库的协同改造:许多原本会导致平台线程阻塞的 I/O 操作,在虚拟线程运行时会被自动替换为非阻塞 I/O 调用。以 java.net.Socketread() 为例,当运行在虚拟线程中时,JVM 实际上会将其委托给 NIO 的 SocketChannel 的非阻塞读操作。如果数据未就绪,虚拟线程会被卸载,载体线程并不阻塞。同样,Thread.sleep() 会被转化为一个带有定时唤醒任务的调度。

这种转换对应用开发者完全透明——你依然可以写外观同步的代码,而实际执行却具备异步吞吐优势。JEP 444 强调:“虚拟线程阻塞的代价很小——因此可以采用同步风格!”这意味着绝大多数使用 synchronized 关键字、 Object.wait() 以及标准 I/O 的旧有代码,只需将线程工厂改为虚拟线程,就能享受到高并发收益。但必须注意一个关键问题:如果阻塞发生在无法卸载的场景,载体线程会被固定(Pinned)。主要的固定情形包括:

  • 执行 synchronized 块或方法时(因为是语言级内置锁,JVM 无法在持有锁时安全地移走栈帧)。
  • 调用本地方法(JNI)时。

一旦载体线程被固定,它将在该虚拟线程阻塞期间一直被占用,无法处理其他任务。频繁的固定会退化到平台线程的行为,削弱系统吞吐。因此,工程设计上宜将 synchronized 替换为 java.util.concurrent.locks.Lock,并避免在虚拟线程中执行长时间运行且不可卸载的代码。

实战改造:将线程池阻塞代码迁移到虚拟线程

下面通过一个典型的微服务场景演示如何将传统线程池阻塞风格切换为虚拟线程。假设我们实现一个 HTTP 服务器,每秒需处理大量请求,每个请求处理链包括:解析参数 → 调用远程用户服务 → 调用订单服务 → 组装响应。

传统线程池方式

ExecutorService pool = Executors.newFixedThreadPool(200);
for (int i = 0; i < 100_000; i++) {
    pool.submit(() -> {
        // 解析请求
        String userId = parseRequest();
        // 调用远程用户服务(同步 HTTP)
        User user = userService.getUser(userId);
        // 调用远程订单服务
        List<Order> orders = orderService.getOrders(userId);
        // 返回响应
        writeResponse(user, orders);
    });
}

当并发超过 200 时,新任务必须排队等待线程释放。而线程在等待远程服务响应时完全闲置,白白浪费资源。若为支撑更高并发而扩大线程池,内存和上下文切换开销将迅速恶化。

改用虚拟线程

// 使用虚拟线程执行器(每个任务创造一个虚拟线程)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 1_000_000; i++) {
    executor.submit(() -> {
        String userId = parseRequest();
        // 阻塞调用,实际会卸载载体线程
        User user = userService.getUser(userId);
        List<Order> orders = orderService.getOrders(userId);
        writeResponse(user, orders);
    });
}
// 安全关闭

执行时,userService.getUser() 内部若使用 HttpClient.send() 同步方法,虚拟线程在等待网络响应时会自动卸载,载体线程转而去处理另一个虚拟线程的 parseRequest()getOrders()。即使有百万个并发任务,也只需与 CPU 核心数相当的载体线程(默认等于 CPU 核心数)即可维持全部计算轮转。整个过程保持了顺序代码的清晰结构,无需任何 thenAccept() 链。

对比总结

特性平台线程 + 固定池虚拟线程
线程创建成本高(需要 OS 资源)极低(纯 Java 对象)
典型栈内存消耗~1 MB按需动态增减,初始极小
阻塞操作影响占用 OS 线程直至恢复卸载载体线程,释放资源
可创建数量上限数千到数万(受系统限制)数百万
是否需要池化必须池化绝不池化(每个任务新建)
编程模型同步、顺序同步、顺序
公平性保证OS 调度器管理ForkJoinPool(协作式)

公平性探秘:海量虚拟线程下的调度竞争

当系统存在数百万虚拟线程时,一个常见的疑问是:它们会否公平地共享载体线程?答案取决于任务的行为模式。虚拟线程的调度依赖 ForkJoinPool 的工作窃取,加之原生即设计为非抢占式,故公平性并非其内置目标

ForkJoinPool 为吞吐量优化:载体线程优先执行自己队列头部的任务(即刚刚提交或唤醒的虚拟线程),空载时从其他队列尾部窃取。如果某个虚拟线程执行纯 CPU 密集计算且不进行任何阻塞操作(如无限循环运算),它一旦挂载到载体线程上,就会持续占据该线程,直到任务完成。其他虚拟线程即使早就绪也无法运行,除非使用显式的 Thread.yield() 请求让出。这便是饥饿风险——协作式调度的固有弱点。

但在实际 I/O 密集型场景下,公平性问题很大程度上得到自然缓解:每当虚拟线程进行 I/O 或 sleep,它都会卸载,相当于主动放行。例如上例中的远端服务调用,虚拟线程在发出网络请求后旋即阻塞,载体线程立刻得到释放,取出下一个就绪的虚拟线程来执行。这样的“频繁阻塞”行为使得每个虚拟线程对 CPU 的占用都是碎片化的,每片时长通常很短(几次方法调用),因此整体上 CPU 时间被所有虚拟线程粗略均分。许多工程师报告称,即使虚拟线程数远大于 CPU 核心数,只要 I/O 潜伏期适当,所有请求的延迟分布仍然相当均匀。

极端情况下,若明知某个任务会长时间无阻塞占用 CPU,则需人工干预,常见做法包括:

  • 将 CPU 密集计算拆分,中间插入一个非阻塞 Thread.sleep(0)Thread.yield() 以触发重新调度。
  • 将重量级计算任务提交给另一个专用的平台线程池,从而不挤占虚拟线程的载体线程。

另外,JEP 444 明确表示,虚拟线程目前不支持时间片抢占。因此不能依赖操作系统来强制切换一个陷入死循环的虚拟线程。开发者应确保虚拟线程内的代码能够在合适时机交出控制权。

局限性、调试与最佳实践

虚拟线程虽极大降低了并发编程复杂度,但并非银弹。其设计目标场景是高并发、I/O 密集或阻塞频繁的任务。以下是其主要局限性及应对策略:

  • 固定(Pinning):如前所述,synchronized 和 native 调用会引发载体线程固定。若固定发生频繁,会造成可用载体线程减少,吞吐下降。解决方案是将 synchronized 替换为 java.util.concurrent.locks.ReentrantLock。注意,Java 19/20 预览中曾试图引入无固定 synchronized 的方案,但在 JDK 21 定稿版中因兼容性问题并未实现(JEP 491 后来作为孵化器独立推进)。
  • 线程局部变量:虚拟线程天然支持 ThreadLocal,但创建海量虚拟线程又长期存活时,ThreadLocal 内的对象可能累积大量内存。应谨慎使用,或在任务完成后主动清理。
  • CPU 密集型任务:若任务几乎没有等待,虚拟线程只是增加了调度的开销。这类任务更适合直接使用平台线程池或 ForkJoinPool 进行并行执行。
  • 调试与监控:虚拟线程在现有的调试器、JFR、线程转储(thread dump)中均可显示。JDK 21 引入了一种新的线程转储格式,能够将虚拟线程按照“虚拟线程 -> 载体线程”的关系分组展示。jcmd 命令或 jstack 工具都能提供此视图。这为诊断虚拟线程饥饿、固定等问题提供了有力手段。

在工程迁移中,一个常见的陷阱是仍把虚拟线程放入池中重用。虚拟线程的成本极低,创建开销可忽略不计,池化反而带来额外的同步开销并限制并发度。如 State of Loom 所强调:“创建虚拟线程很廉价——拥有数百万,不要池化它们!” 正确做法是每个任务创建一个新虚拟线程,任其运行结束后由 GC 回收。

总结

虚拟线程通过载体线程挂载/卸载的调度模型,让 Java 程序员得以用熟悉的同步风格构建出能够支撑数百万并发任务的服务。其底层的 ForkJoinPool 调度器以工作窃取方式分配计算资源,而阻塞操作触发虚拟线程卸载,从而自动放大吞吐。尽管协作式调度在公平性上有所妥协,但在 I/O 密集场景下,任务的自我让出使得饿死很难发生。

本文基于 OpenJDK 官方 JEP 444 和 Loom 设计文档中的公开信息撰写,所描述的实现细节对应 JDK 21 定稿行为。虚拟线程仍处于活跃演进中,未来可能在固定同步块、资源公平性等方面继续优化。对于现阶段开发者而言,理解其调度原理与限制,就能安全地在合适的场景中将它转化为真实的生产力。

资料来源

  1. JEP 444: Virtual Threads
  2. Virtual Threads: A Primer
  3. Continuations in Project Loom
  4. State of Loom