Skip to content

虚拟线程模型与 Pinning(JDK 21) ​

如果一条线程可以像一个对象那样廉价,过去十年围绕线程池积累的工程直觉,还剩下多少是对的?

Java 21 把虚拟线程从预览特性升级为 GA。它不是一种新语言语法,也不是异步框架,而是对 java.lang.Thread 的实现层重写:同样的类、API 和编程风格,但一台 JVM 可以同时承载远多于平台线程数量的虚拟线程。实际规模仍受内存、载体线程、阻塞点和 JVM 参数约束。

本章讨论:这次改动改到了哪里,改到了什么程度,改动之外还剩下什么。

1. 平台线程走到尽头的原因 ​

1.1 一条平台线程的成本清单 ​

在 JDK 21 之前,new Thread() 得到的每一条 Java 线程背后都对应一条 OS 线程(HotSpot 的 1:1 模型,见第 2 章)。这条 OS 线程要付出的固定成本:

项目量级示意说明
栈内存常见配置约 1 MB依 -Xss 与平台而变;预留虚拟地址空间不等于全部提交
内核态数据结构数 KBtask_struct、内核栈、调度器条目
上下文切换依负载与平台而变保存/恢复寄存器、切换 TLB、可能刷新缓存
创建/销毁系统调用与分配相关依 OS、安全配置和当前负载而变

上表只能用于建立数量级直觉,不能据此给固定线程上限。平台线程容量还受 -Xss、内核栈、进程线程数限制、内存映射数量和实际驻留页影响,必须结合目标操作系统与 JVM 压测。线程过多时,调度和上下文切换成本通常会比单条线程的固定开销更早成为瓶颈。

1.2 高并发场景下的两难 ​

下面用一个假设的后端接口说明等待时间与并发数的关系:

txt
        接收请求
             │
             ▼
     ┌────────────┐    RT 里 95% 时间在这
     │  下游 IO   │    数据库、Redis、下游服务
     └────────────┘
             │
             ▼
        组装返回

以下用一个假设场景估算平均并发数:QPS = 10 000,平均 RT = 200 ms。按 Little 定律得到:

txt
N = QPS × RT = 10 000 × 0.2s = 2 000

这表示系统中平均有 2 000 个请求处于处理中。如果请求有 95% 的时间用于等待 I/O,那么在简化假设下,约有 1 900 个请求处于等待阶段。这个结果是平均并发量,不是线程池容量建议;实际需要多少并发额度,还取决于请求分布、下游处理能力和系统的排队策略。

过去应对这个矛盾有两条路:

  • 限并发:Tomcat 的 maxThreads=200,多余请求排队 —— 用户在门口等
  • 改异步:Netty、Reactor、CompletableFuture 链 —— 一条线程处理成千上万条连接

1.3 Reactor 路径的隐藏成本 ​

异步方案不是没有代价。写过 WebFlux 或者 Netty 应用的人知道下面这几件事:

java
// ❌ Reactor 式代码:调用栈被切碎
Mono<Order> loadOrder(String id) {
    return orderRepo.findById(id)
        .flatMap(order -> userRepo.findById(order.userId())
            .flatMap(user -> itemRepo.findAll(order.itemIds())
                .collectList()
                .map(items -> assemble(order, user, items))));
}
java
// ✅ 同步代码:直读直写
Order loadOrder(String id) {
    Order order = orderRepo.findById(id);
    User user = userRepo.findById(order.userId());
    List<Item> items = itemRepo.findAll(order.itemIds());
    return assemble(order, user, items);
}

响应式方案换来的是高并发 I/O 场景下更好的资源利用率,付出的代价是业务执行链跨越多个异步边界:

  • 异常栈:异步执行边界使普通调用栈无法自然呈现整个业务调用过程,onError 拿到的 stack trace 通常停在框架内部;Reactor 提供 assembly tracing 等调试支持,但有额外开销
  • ThreadLocal:依附于当前线程,跨线程切换时不会自动携带原值;MDC、事务、租户上下文需要额外的上下文传播机制才能跨算子传递
  • 调试:异步链上的执行时机和线程切换增加单步跟踪难度,断点命中业务代码变得不那么直观
  • 心智负担:flatMap / zipWith / switchIfEmpty 的语义精确性要求高

1.4 虚拟线程要解决的问题 ​

虚拟线程给出的答案是:同步代码风格 + 异步执行效率。让开发者继续用 Thread / ExecutorService / try-catch / ThreadLocal,同时把 IO 阻塞时的"线程占坑"问题在 JVM 层面消掉。

2. 虚拟线程:M:N 调度与 continuation ​

2.1 虚拟线程与平台线程的对照 ​

  • 虚拟线程(Virtual Thread, VT):java.lang.Thread 的子类实例,栈保存在堆上,个数可达百万级
  • 载体线程(Carrier Thread):真正的平台线程,是 VT 运行时实际占用的 CPU 执行流;VT 只在 Carrier 上"临时挂载"
  • 调度器(Scheduler):默认是一个专用的 ForkJoinPool,决定哪个 VT 挂到哪个 Carrier 上运行

关键设计:当 VT 阻塞在 JDK 阻塞点(如 Socket.read、Thread.sleep、LockSupport.park)时,JVM 会把 VT 从 Carrier 上卸载,Carrier 立即去执行别的 VT。等阻塞条件满足,VT 被重新挂到某条 Carrier 上继续跑。

2.2 continuation:可挂起可恢复的执行片段 ​

虚拟线程的挂起/恢复能力,来自一个更底层的机制——Continuation(jdk.internal.vm.Continuation)。

一段执行流有两种状态:

  • running:栈帧在某条 Carrier 的调用栈上
  • frozen:栈帧被复制到堆上,等待被"解冻"

Continuation.yield(scope) 触发从 running 到 frozen 的转换,Continuation.run() 触发从 frozen 到 running 的恢复。虚拟线程使用堆上的栈块(stack chunks)保存可挂起的 Java 执行状态,挂载和卸载涉及这些栈块与实际执行栈之间的协调,并非每次挂起都把所有栈帧完整复制到堆上、恢复时再完整复制回来。jdk.internal.vm.Continuation 是 JDK 内部实现,不是业务代码可依赖的公开 API;这里的 yield 和 run 用于解释机制,不是应用开发中直接调用的接口。

对读者的意义是:虚拟线程不是操作系统线程,也不是协程库,而是"栈可搬家的 Java 线程"。Java 语言不需要 async / await 关键字,因为搬家的动作发生在 JDK 内部的 IO 调用里,业务代码看不见。

2.3 调度器与 Carrier 池 ​

默认 Carrier 池的属性可以用系统属性调整:

bash
# Carrier 数量,默认等于 CPU 核数
-Djdk.virtualThreadScheduler.parallelism=16

# 最大并行度上限
-Djdk.virtualThreadScheduler.maxPoolSize=256

# 调度器用于控制可运行线程补偿行为的参数
-Djdk.virtualThreadScheduler.minRunnable=1

生产环境几乎不需要动这些参数——默认值已经是"CPU 核数",这也是平台线程池 IO 密集配置的经验值 2 × N_CPU 都被虚拟线程重新定义的原因:CPU 核数由 Carrier 决定,与 VT 数量无关。

2.4 创建虚拟线程的四种方式 ​

方式场景特点
Thread.startVirtualThread(runnable)一次性异步任务最简洁,立即启动
Thread.ofVirtual().name(...).start(runnable)需要命名、异常处理器通过 builder 配置
Executors.newVirtualThreadPerTaskExecutor()替换现有 ExecutorService兼容既有代码
StructuredTaskScope(第 14 章 §2.5)有生命周期约束的子任务组结构化并发入口,JDK 21 为预览 API
java
// 场景 1:一次性任务
Thread.startVirtualThread(() -> log.info("hello vt"));

// 场景 2:需要配置
Thread vt = Thread.ofVirtual()
    .name("order-worker-", 0)          // "order-worker-0"
    .uncaughtExceptionHandler((t, e) -> log.error("vt failed", e))
    .start(() -> processOrder(id));

// 场景 3:替换线程池
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (Request req : requests) {
        pool.submit(() -> handle(req));
    }
}   // try-with-resources 自动等待所有任务完成

注意 newVirtualThreadPerTaskExecutor 的语义:它不是"共享一批固定 Carrier 的池",而是"每提交一个任务就新起一条虚拟线程"。它更接近 newCachedThreadPool,但没有创建上限——因为 VT 本身就是廉价的。

3. pinning:synchronized 造成的钉住问题 ​

3.1 什么是 pinning ​

虚拟线程遇到阻塞点时,JVM 应当把它从 Carrier 卸载下来,让 Carrier 空出手服务别的 VT。但在某些情况下卸载会失败——这条 VT 被"钉"在了 Carrier 上,直到阻塞返回。这种现象叫 pinning。在 JDK 21 中,如果虚拟线程在 synchronized 块或方法内执行了需要阻塞的操作,且该操作无法让虚拟线程从 Carrier 上卸载,就可能发生 pinning。进入 synchronized 本身并不等于出现了有害的 pinning,更值得关注的是阻塞持续时间、发生频率,以及它对整体吞吐量的影响。

被钉住时的现场:

txt
虚拟线程 VT1 持有 monitor lock,进入 synchronized 块 → 挂载在 Carrier C1
                                    │
                                    │  发起 HTTP 请求,等待响应
                                    ▼
    正常情况:VT1 应该被卸载,C1 去跑其他 VT
    pinning:VT1 卡在 C1 上,C1 无法离开
    后果:VT1、C1 一起等 HTTP 响应;期间其他 VT 少一条可用 Carrier

如果 Carrier 池只有 8 条,且业务大量使用 synchronized 包裹阻塞 I/O,8 条 Carrier 可能全部被钉住。此时虚拟线程调度可能退化到接近传统线程池的瓶颈,实际吞吐也可能低于配置合理的固定线程池。

3.2 造成 pinning 的两类场景 ​

场景原因JDK 21 表现JDK 24 表现
synchronized 块内执行阻塞 IOmonitor 与 Carrier 强绑定,无法搬走栈帧钉住JEP 491 消除了 synchronized 导致的 pinning 限制
本地方法(JNI)内阻塞JVM 无法感知 native 栈帧钉住仍钉住

JDK 21–23 里 synchronized 是 pinning 的主要来源之一。JDK 24(2025-03)通过 JEP 491 让 synchronized 内的虚拟线程也能挂起,消除了这一类来源;JNI 等其他 pinning 原因仍需单独分析。生产环境停留在 JDK 21 LTS 时,应继续评估这段组合。

3.3 迁移建议:从 synchronized 到 ReentrantLock ​

java
// ❌ JDK 21 下会 pinning
public class OrderService {
    private final Object lock = new Object();

    public void update(String id) {
        synchronized (lock) {
            httpClient.send(request);   // 阻塞 IO,VT 被钉在 Carrier 上
            db.write(id);
        }
    }
}
java
// ✅ 用 ReentrantLock 替换:VT 会正常卸载
public class OrderService {
    private final ReentrantLock lock = new ReentrantLock();

    public void update(String id) {
        lock.lock();
        try {
            httpClient.send(request);   // VT 阻塞时被卸载,Carrier 空闲
            db.write(id);
        } finally {
            lock.unlock();
        }
    }
}

ReentrantLock 通过 LockSupport.park 挂起,JDK 21 的虚拟线程会把这段等待从 Carrier 上卸载。在 JDK 21–23 中,应重点排查 synchronized 与阻塞 I/O 的组合;运行在 JDK 24+ 时再按 JEP 491 后的行为重新评估。但这不意味着所有 synchronized 都要替换,只有确实存在频繁、长时间的阻塞与 pinning 组合时,才值得针对性改造。

3.4 pinning 的检测手段 ​

生产环境常用三条路径:

bash
# 1. 启动参数:VT 一旦 pinning 就打印栈
-Djdk.tracePinnedThreads=short   # 只显示相关问题栈帧
-Djdk.tracePinnedThreads=full    # 显示完整栈,并标出相关帧

# 2. JFR 事件
jcmd <pid> JFR.start settings=profile
# 事件名: jdk.VirtualThreadPinned

# 3. 线程快照
jcmd <pid> Thread.dump_to_file -format=json /tmp/vt-dump.json
# 线程转储用于观察虚拟线程的状态和调用栈;是否存在有害的 pinning,
# 应结合 jdk.VirtualThreadPinned 事件、jdk.tracePinnedThreads 输出及实际阻塞情况判断

jdk.tracePinnedThreads=short 的输出片段示例:

txt
Thread[#42,ForkJoinPool-1-worker-3,5,CarrierThreads]
    java.base/java.net.Socket.connect(Socket.java:...)
    <monitors:>
    - java.lang.Object@0x00000007c0a01234

<monitors:> 后列出的 monitor 就是钉住的元凶。

前面几节解释了虚拟线程的调度模型、pinning 机制和适用限制。下一步把这些结论转成迁移决策:哪些场景应保留平台线程、如何使用结构化并发,以及如何从传统 API 平滑迁移。

下一页: 适用边界、结构化并发与迁移

Released under the MIT License.