虚拟线程:调度器、Pinning 与容量边界(Java 21 及之后的版本差异)
结论基线:API 契约以 Java SE 21 为准,实现分析固定到 OpenJDK 21u
jdk-21.0.5+9(与运行 JDK 21.0.5 同 tag);本机实测 Oracle JDK 21.0.5+9(x86_64,macOS 26.7.1),JDK 17 对照由同一实验入口承担。每项结论都按版本标注适用 JDK:虚拟线程 JEP 425(19 预览)/JEP 436(20 二次预览)/JEP 444(21 正式);-Djdk.tracePinnedThreads是 21 的观察通道、被 JEP 491(JDK 24 正式)移除;Scoped Values/结构化并发在 21–24 是预览、Scoped Values 于 25 由 JEP 506 正式——本机未安装 JDK 24/25,相关差异按 JEP 推导并明确标注未实跑。既有并发契约(池接纳与关闭、中断协议、ThreadLocal 上下文、锁语义)的权威在 U07–U09 各正文,本页互链不复制;线程池与容量推理的主题权威在线程池正文。
问题:每请求一线程回来了,但"线程更轻"不是容量结论
JDK 21 之前,服务端并发在两条路里选:每请求一个平台线程(写法直白,但数千线程的栈内存与调度成本先崩)或线程池(复用线程,但阻塞调用会占住池线程,池大小就是并发上限,超卖与饥饿治理全靠排队/拒绝协议——线程池正文)。虚拟线程给出第三条:由 JVM 调度的轻量线程,阻塞时把栈卸载(unmount)到堆上、释放底层 OS 线程(carrier),让"每任务一线程"在万级并发下成立。
由此最容易得出错误结论:"虚拟线程更轻,所以容量不用管了。"本页反着教:虚拟线程改变的是线程的持有成本,不改变下游资源的容量、不改变阻塞期间占用 OS 线程的例外情形(pinning)、也不取消内存对栈的总账。三个都要按容量算:下游连接池仍是硬上限;pinning 把虚拟线程退化回"占着平台线程";百万线程的栈 chunk 与 ThreadLocal 是堆内存。JEP 444 自己给出的限流方案是 Semaphore 而不是"线程更轻"——这就是容量推理的第一性口径。
调度器与线程模型:每任务一线程,不池化虚拟线程
两个入口都是"每任务构造一个新虚拟线程":
原码连续摘录(仅统一公共缩进,截取方法局部):Thread.java,jdk-21.0.5+9,L839–L857。
/**
* Returns a builder for creating a virtual {@code Thread} or {@code ThreadFactory}
* that creates virtual threads.
*
* @apiNote The following are examples using the builder:
* {@snippet :
* // Start a virtual thread to run a task.
* Thread thread = Thread.ofVirtual().start(runnable);
*
* // A ThreadFactory that creates virtual threads
* ThreadFactory factory = Thread.ofVirtual().factory();
* }
*
* @return A builder for creating {@code Thread} or {@code ThreadFactory} objects.
* @since 21
*/
public static Builder.OfVirtual ofVirtual() {
return new ThreadBuilders.VirtualThreadBuilder();
}原码连续摘录(仅统一公共缩进,截取方法局部):Executors.java,jdk-21.0.5+9,L257–L271。
/**
* Creates an Executor that starts a new virtual Thread for each task.
* The number of threads created by the Executor is unbounded.
*
* <p> This method is equivalent to invoking
* {@link #newThreadPerTaskExecutor(ThreadFactory)} with a thread factory
* that creates virtual threads.
*
* @return a new executor that creates a new virtual Thread for each task
* @since 21
*/
public static ExecutorService newVirtualThreadPerTaskExecutor() {
ThreadFactory factory = Thread.ofVirtual().factory();
return newThreadPerTaskExecutor(factory);
}javadoc 明写 unbounded:这个 Executor 不设上限,接纳协议从"池的队列+拒绝"变成"调用方自己决定提交多少"(线程池正文的接纳六问在这里没有池替你回答)。JEP 444 的使用规则由此而来:不要池化虚拟线程——池化的意义是共享昂贵资源,虚拟线程不昂贵;要限制对下游的并发,用 Semaphore 等专用构造,把"限流"与"线程"解耦。调度器是 FIFO 模式的 ForkJoinPool(与并行流的 LIFO common pool 是两回事),默认并行度 = CPU 数,可用 -Djdk.virtualThreadScheduler.parallelism 调整。本机实测(8 核):100 个任务经 newThreadPerTaskExecutor 得到 100 个不同虚拟线程,全部 isVirtual()==true;parallelism 默认值打印为 8(观察值)。
上下文与中断按版本与模型解释(交叉引用不复制):ThreadLocal 在虚拟线程上完全可用,但每任务一线程意味着每任务一份拷贝,JEP 444 明确警告不要用 ThreadLocal 缓存昂贵资源(如每线程一条数据库连接);InheritableThreadLocal 在线程构造时快照父线程值——每任务一线程模型下快照时点恰好等于任务提交时点(本机实测 v1→v2 两次提交分别看到 v1、v2),而固定池 worker 首次提交时构造一次、之后父线程改动不可见(实测 p1→p2 两次提交都看到 p1)——这正是中断协议与上下文正文登记的池时错配在每任务一线程模型下的自然缓解,但"新线程=新快照"也是每请求一条 ThreadLocal 拷贝的内存来源。中断协议不变(interrupt 标志 + 可中断阻塞点醒来),实测 sleep 中断延迟 0ms 量级;Scoped Values(JEP 446,21 预览)是官方给出的不可变上下文替代,直到 JDK 25 由 JEP 506 才正式——在 21 上它是预览,不进生产签名(未实跑,按 JEP 推导)。
Pinning:JDK 21 的两个场景、补偿机制与观察通道
阻塞时能否卸载取决于线程此刻能否安全离开 carrier。JEP 444 列出两个不能卸载的场景:在 synchronized 块/方法内阻塞,或在native 方法/外来函数内阻塞。状态机与观察通道都在 VirtualThread.java:
原码连续摘录(仅统一公共缩进,截取方法局部):VirtualThread.java,jdk-21.0.5+9,L88–L113。
/*
* Virtual thread state transitions:
*
* NEW -> STARTED // Thread.start, schedule to run
* STARTED -> TERMINATED // failed to start
* STARTED -> RUNNING // first run
* RUNNING -> TERMINATED // done
*
* RUNNING -> PARKING // Thread parking with LockSupport.park
* PARKING -> PARKED // cont.yield successful, parked indefinitely
* PARKING -> PINNED // cont.yield failed, parked indefinitely on carrier
* PARKED -> UNPARKED // unparked, may be scheduled to continue
* PINNED -> RUNNING // unparked, continue execution on same carrier
* UNPARKED -> RUNNING // continue execution after park
*
* RUNNING -> TIMED_PARKING // Thread parking with LockSupport.parkNanos
* TIMED_PARKING -> TIMED_PARKED // cont.yield successful, timed-parked
* TIMED_PARKING -> TIMED_PINNED // cont.yield failed, timed-parked on carrier
* TIMED_PARKED -> UNPARKED // unparked, may be scheduled to continue
* TIMED_PINNED -> RUNNING // unparked, continue execution on same carrier
*
* RUNNING -> YIELDING // Thread.yield
* YIELDING -> YIELDED // cont.yield successful, may be scheduled to continue
* YIELDING -> RUNNING // cont.yield failed
* YIELDED -> RUNNING // continue execution after Thread.yield
*/PARKING → PINNED 的分支就是"卸载失败":cont.yield 不能把持有监视器的栈卸下,线程连同 carrier 一起停住。-Djdk.tracePinnedThreads 在这一刻打印栈:
原码连续摘录(仅统一公共缩进,截取方法局部):VirtualThread.java,jdk-21.0.5+9,L186–L204。
private static class VThreadContinuation extends Continuation {
VThreadContinuation(VirtualThread vthread, Runnable task) {
super(VTHREAD_SCOPE, wrap(vthread, task));
}
@Override
protected void onPinned(Continuation.Pinned reason) {
if (TRACE_PINNING_MODE > 0) {
boolean printAll = (TRACE_PINNING_MODE == 1);
VirtualThread vthread = (VirtualThread) Thread.currentThread();
int oldState = vthread.state();
try {
// avoid printing when in transition states
vthread.setState(RUNNING);
PinnedThreadPrinter.printStackTrace(System.out, reason, printAll);
} finally {
vthread.setState(oldState);
}
}
}生产环境更常用的观察通道是 JFR 事件 jdk.VirtualThreadPinned(默认开启,threshold 20ms,只记超过阈值的 pin):
原码连续摘录(仅统一公共缩进,截取方法局部):VirtualThreadPinnedEvent.java,jdk-21.0.5+9,L25–L31。
package jdk.internal.event;
/**
* Event recording that a virtual thread has parked on its carrier thread.
*/
public class VirtualThreadPinnedEvent extends Event {
}补偿机制:pinning 平时不会饿死调度器(实测推翻初始误解)
pinned 线程占住一个 carrier 后,ForkJoinPool 会临时创建新 carrier 补偿(受 jdk.virtualThreadScheduler.maxPoolSize 限制,默认 256),所以 pinning 的直接后果不是"调度器卡死",而是每多一个 pinned 阻塞就多占一个 OS 线程。本机受控实测(parallelism=1):
| 相位 | 配置 | 两个「synchronized+各自监视器+sleep(300ms)」任务 | 结论 |
|---|---|---|---|
| compensate | parallelism=1,默认 maxPoolSize=256 | 第二个 +13ms 起跑,总 330ms(重叠),trace 打出 1 个 reason:MONITOR <== monitors:1 栈 | 补偿掩盖了 pin;两个 pin 占 2 个 OS 线程 |
| exhaust | parallelism=1,maxPoolSize=1 | 第二个 +304ms 起跑,总 622ms(串行),trace 打出 2 个 pin 栈 | 补偿配额耗尽才暴露 pin 的容量代价 |
| 对照 | 同配置,各自 ReentrantLock+sleep | 两相都 ~300ms(重叠) | ReentrantLock 阻塞可卸载,唯一 carrier 先后服务两个任务 |
maxPoolSize=256 的补偿语义由本机观察与 JEP 444 文字互证,HotSpot 侧实现未逐行核验;具体毫秒数都是观察值非契约。容量换算:pinning 期间每个虚拟线程重新占用一个 OS 线程(默认预算 256 个),256 路并发 pinned 阻塞即占满补偿配额——这就是"synchronized 里做阻塞 I/O"在 21 上的容量账单,也是 JEP 444 建议热路径 I/O 用 ReentrantLock 的原因(锁语义本身的权威在锁正文)。
版本分界:JDK 24 之后 synchronized 不再 pin
JEP 491(JDK 24 正式)重做监视器实现:虚拟线程阻塞在 synchronized(含 Object.wait)时也能卸载,"几乎消除所有 pinning",-Djdk.tracePinnedThreads 属性被移除(设置无效),jdk.VirtualThreadPinned 事件保留用于剩余场景(native 回调等)。本机未安装 JDK 24,此段按 JEP 推导未实跑;工程含义是:在 21 上做的 ReentrantLock 迁移在 24+ 不再是性能必需(可按可读性回迁),但迁移对 17–23 的 LTS 组合仍然有效——写迁移建议时必须写明目标 JDK 版本区间。
容量推理:把"线程更轻"翻译回资源账单
虚拟线程没有取消任何一条既有容量账单,只是改了其中"线程栈"一项的系数:
| 资源 | 固定池(100 线程) | 每任务一虚拟线程 | 何时被谁限住 |
|---|---|---|---|
| OS 线程/内存 | 100 条栈(默认 1MB 保留额) | 仅 carrier 数(≈CPU)+ 活动栈 chunk 在堆上 | 虚拟线程把线程内存从"线程数×栈"变为"活动栈×堆",但 100 万活动任务的栈 chunk 与 ThreadLocal 仍是堆总量 |
| 下游连接 | 池大小 vs 连接池,取小者 | 不变:Semaphore/连接池仍是硬上限 | 线程模型改变不了下游 |
| CPU 并行 | ≤池大小 | ≤调度器并行度(≈CPU) | CPU 型负载用虚拟线程无收益 |
| pinned 阻塞 | —(本来占线程) | 每路占 1 个 carrier(默认配额 256) | 21 上 synchronized 内阻塞的热路径 |
本机对照实测(200 任务 × sleep(50ms) 模拟下游、Semaphore(10) 限流):固定 200 池线程 wall≈1143ms,每任务一虚拟线程 wall≈1137ms,两者 in-flight 峰值都恰好 10——下游 10 的容量把两种模型压到同一条吞吐线(floor=200/10×50ms=1000ms)。去掉信号量后虚拟线程把 200 路并发同时打到下游(实测 200/200 在途)——线程更轻反而让下游更容易被打垮。TDD 红2正是这个误解的实证:断言"无信号量时在途数会被 CPU 数(8) 自然限住",实跑在途 200,exit=1。
线程池在虚拟线程时代的定位变化:池化虚拟线程没有意义(不昂贵、无需复用);ExecutorService 接口仍然成立(newThreadPerTaskExecutor 是它的实现),因此 FutureTask/CompletableFuture 正文的结果状态机、超时与取消语义原样适用;线程池正文的关闭协议由 ExecutorService.close()/shutdown 承接。改变的是接纳边界的位置:从"池大小+队列"移到"Semaphore + 调用方并发预算"。
版本边界表
| 版本 | 虚拟线程相关状态 | 对本页结论的影响 |
|---|---|---|
| JDK 17 | 无(JEP 425 尚未出现) | 本页全部实验不适用;固定池/中断/锁各正文为 17 权威 |
| JDK 19/20 | JEP 425/436 预览,需 --enable-preview | 预览 API 不进生产签名 |
| JDK 21 | JEP 444 正式;synchronized/native pinning;tracePinnedThreads;ScopedValue/结构化并发预览 | 本页全部实测基线 |
| JDK 22–23 | Scoped Values 二/三次预览(JEP 464/476)、结构化并发二/三次预览(JEP 462/480) | 仍在预览轨道,未实跑 |
| JDK 24 | JEP 491:synchronized 不再 pin,tracePinnedThreads 移除;Scoped Values 四次预览 | pinning 章节按版本分界;未实跑,按 JEP 推导 |
| JDK 25 | JEP 506 Scoped Values 正式;JEP 505 结构化并发第五次预览;JEP 502 Stable Values 预览 | 上下文传播的长期方向是 Scoped Values;未实跑,按 JEP 推导 |
设计案例:同步阻塞调用迁到虚拟线程的容量评审(算例+实测对照)
负载假设:网关服务每秒 5000 请求,每请求顺序调用 2 个下游(各 30ms,阻塞 I/O),下游各自最多承受 200 并发;容器 4 核,当前固定池 200 线程。不变量:任何一路下游的并发不超过 200;p99 由下游容量而非线程数决定。
选择 1:保持固定池 + 每下游连接池限流。 5000×2×30ms=300 秒下游时间/秒,两下游各 200 并发上限共 400 路 → 理论承载 400/0.03≈13333 请求/秒,池 200 线程 × 2 路顺序调用 → 有效 100 路在途 → 瓶颈在池:200 线程 /(2×30ms)≈3333 请求/秒 < 5000,必须加池或加并发。加池到 400 线程的代价是 400 份平台线程栈。
选择 2:虚拟线程 + 两个 Semaphore(200)(每下游一个)+ 调度并行度默认。 线程内存账单消失,接纳上限显式化为两个信号量;synchronized 仅用于内存操作(pin 无害——JEP 444 口径:不常阻塞的监视器不需要迁锁),热路径 I/O 用连接池客户端(无 synchronized 内阻塞)。承载上限=下游 200+200 并发≈13333 请求/秒,与线程数解耦。实测锚点(本机受控实验):同样加 Semaphore(10) 的固定池与虚拟线程 wall 时间几乎相同(1143/1137ms)、in-flight 峰值同为 10——限流器决定容量,线程模型只决定成本;该实测是 sleep 模拟下游的单机观察,不能当作网关 p99 结论。恢复责任:信号量排队需要超时与降级协议(RPC 可靠性正文的预算口径),迁移评审必须同时给出这两者,不能只写"换虚拟线程"。
条件变化与边界
| 条件变化 | 需要复核的点 |
|---|---|
| JDK 17 部署 | 虚拟线程不存在;所有"轻量"结论回到固定池模型(线程池正文) |
| JDK 21 + synchronized 内阻塞 I/O | pinning 占 carrier(默认配额 256);用 tracePinnedThreads/JFR 事件定位;热路径改 ReentrantLock |
| JDK 24+ | synchronized 不再 pin(JEP 491,未实跑按 JEP 推导);tracePinnedThreads 设置无效;迁移建议写明版本区间 |
| CPU 密集负载 | 卸载收益为零,并行度仍是 CPU 数;不要把并行流 common pool 与虚拟线程调度器混谈 |
| ThreadLocal 缓存连接 | 每任务一份拷贝放大内存;改连接池/ScopedValue(25 正式、21 预览未实跑) |
| 池化虚拟线程 | 无意义且有害(JEP 444);限流用 Semaphore,接纳用调用方预算 |
| 结构化并发 | 21–24 一直预览(JEP 453→480→499→505),未实跑;取消传播语义按 JEP 推导,不进生产签名 |
实验:输入、步骤、预期与边界
实验入口:labs/modern-java(VirtualThreadProbe.java + Jep400Probe.java,run.sh virtual|jep400,固定 JDK 21 + JDK 17 对照)。
cd <仓库根>
export JAVA_HOME17=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
export JAVA_HOME21=/Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home
bash labs/modern-java/run.sh virtual # JDK21 主实验(pin-child 两相由入口自动执行)
bash labs/modern-java/run.sh jep400 # JEP 400 断层三档对照(17/21/21+COMPAT)输入与预期(2026-10-25 实测,完整输出与五次红绿记录见证据):
| 场景 | 输入 | 预期与实测 |
|---|---|---|
| 线程模型 | 100 任务经 newThreadPerTaskExecutor | 100 个不同虚拟线程;parallelism 默认 8(观察值) |
| pin 补偿 | parallelism=1 + 两个 synchronized+sleep | +13ms 起跑、330ms 完成(重叠);trace 1 个 monitors:1 栈 |
| pin 耗尽 | 同上 + maxPoolSize=1 | +304ms 起跑、622ms 完成(串行);trace 2 个 pin 栈 |
| 卸载对照 | 各自 ReentrantLock+sleep | 两相都 ~300ms(重叠) |
| JFR 事件 | Recording(threshold=1ms) + pinned sleep | jdk.VirtualThreadPinned 事件 1 条,时长≈300ms |
| 下游限流 | 200 任务 + Semaphore(10) | 池/虚拟 wall 1143/1137ms、in-flight 均 10;无信号量虚拟 200 并发 |
| 上下文 | ITL 每任务 vs 固定池 | v1→v2(每提交快照)vs p1→p1(worker 构造一次) |
| JEP 400 | LC_ALL=C 三档 | 17:US-ASCII+乱码;21:UTF-8+正确;21+COMPAT:US-ASCII+乱码回归 |
TDD 红绿(真实,不倒造):红1 pinning 饿死调度器的误解(gap=16ms 实测推翻,引入 compensate/exhaust 两相);红1b 同锁重叠的误解(609ms 实测推翻,互斥与卸载拆开);红2 线程更轻自带限额的误解(在途 200 实测推翻);红4 JDK21 仍跟随平台字符集的误解(UTF-8 实测推翻)。边界:毫秒级数字均为单机观察值;native/FFI pinning 场景未复现;JDK 24/25 差异未实跑;JEP 400 的中文 Windows GBK 场景按规范推导(本机无法复现该平台)。
追问链
第一层:虚拟线程为什么便宜?便宜的边界在哪?
栈在堆上按需增长(栈 chunk),构造与调度不走 OS;阻塞在可卸载点时 JVM 只保存栈、释放 carrier。边界:CPU 型负载无收益(并行度仍是 CPU 数);pinned 阻塞期间恢复"一线程一 OS 线程";百万任务的栈 chunk 与 ThreadLocal 是堆内存——便宜的是空闲与阻塞,不是活动与驻留。
第二层:为什么不要池化虚拟线程,却仍要连接池?
池化的本质是共享昂贵且可复用的资源。虚拟线程不昂贵(构造≈微秒),池化只加排队;连接/会话昂贵且有硬上限,必须池化。两者叠加的正确形态:每任务一虚拟线程 + 连接池/Semaphore 限流,而不是"虚拟线程池"。
第三层:pinning 在生产里怎么发现、怎么算账?
发现:JFR jdk.VirtualThreadPinned(默认 20ms 阈值)按栈定位热点监视器;-Djdk.tracePinnedThreads=full 复现用(21,24 移除)。算账:pinned 并发路数 × 单路时长 ≈ 占用的额外 carrier(OS 线程),与默认配额 256 比较;超过即出现调度停顿。24+ 由 JEP 491 消除 synchronized pinning,账单里只剩 native 场景。
第四层:评审"我们上了虚拟线程"的迁移方案按什么顺序问?
- 调用方并发预算在哪(Semaphore/信号量/连接池),无界 Executor 后面有没有无界下游调用;2. 热路径是否在 synchronized 内阻塞(21 的 pin 账单、24 的版本差异);3. ThreadLocal 缓存了什么(每任务一份拷贝的内存账);4. 中断/关闭协议是否按 ExecutorService 契约承接(取消与超时语义不因线程类型改变);5. 目标 JDK 版本区间写了吗(21 的迁移在 24+ 可能退化为止痛药)。这五问把"线程更轻"翻译回容量、内存与版本三张账单。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JEP 444 Virtual Threads | JDK 21 | 调度器、两个 pinning 场景、Semaphore 限流、ThreadLocal 警告、tracePinnedThreads(2026-10-25 查询) |
| JEP 491 Synchronize Virtual Threads without Pinning | JDK 24 | synchronized pinning 消除与 tracePinnedThreads 移除(推导引用,未实跑) |
| JEP 425/436/446/462/464/476/480/499/505/506 | 各版本 | 预览/正式轮次(状态表与版本边界表) |
| Java SE 21 Thread/Executors API | Java SE 21 | ofVirtual/newVirtualThreadPerTaskExecutor 契约 |
| OpenJDK 21u Thread.java | jdk-21.0.5+9 | ofVirtual(L839) |
| OpenJDK 21u Executors.java | jdk-21.0.5+9 | 每任务一线程 unbounded(L257) |
| OpenJDK 21u VirtualThread.java | jdk-21.0.5+9 | 状态机 PINNED 分支(L88)与 tracePinnedThreads 通道(L186) |
本地 labs/modern-java 实验 | Oracle 21.0.5 + 17.0.12 对照 | 本文全部行为实测(证据) |
访问日期:2026-10-25。源码行号以 jdk-21.0.5+9 为准;JDK 24/25 差异按 JEP 推导未实跑并已逐项标注;pin 补偿的 HotSpot 侧实现未逐行核验,以本机观察 + JEP 444 文字互证。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。