线程 60 年来最大重写 — 栈在堆上按需生长, 阻塞在 IO 点 unmount, 少量 carrier 撑起百万虚拟线程
虚拟线程把"线程"从内核资源降级成了堆上的普通对象: 栈按需生长、百万个也才几个 GB, 由 JVM 里的少量 carrier(默认 = CPU 核数的 ForkJoinPool) 轮流承载。魔法全在阻塞点: socket.read() 挂起时, VT 连人带栈 unmount 搬进堆里排队, carrier 立刻 mount 下一个 VT — IO 等待第一次变成"零线程占用"。于是你能用最朴素的同步写法(阻塞读/顺序调), 拿到 Netty/反应式才有的事件级吞吐。但记住边界: CPU 密集没红利, synchronized 里做 IO 会把 carrier 钉死, 而"不池化、用信号量限流"是它与平台线程池最反直觉的用法差异。
Thread vt = Thread.startVirtualThread(() -> { // JDK 21+ 转正 System.out.println(Thread.currentThread()); }); // 输出形如 VirtualThread[#21,module-main] vt.isVirtual(); // → true 栈在堆上, KB 级
Runtime.getRuntime().availableProcessors(); // → 8 即 carrier 默认并行度 (ForkJoinPool) // 挂载中的 VT 形如 VirtualThread[#21]@ForkJoinPool-1-worker-3 // worker-3 = carrier; 海量 VT 在 8 根 carrier 上轮流坐
var in = socket.getInputStream(); in.read(); // 阻塞点 → unmount: 栈搬堆, carrier 空出跑别的 VT // 数据就绪 → 重新入队, 被任意 carrier mount 恢复 // 关键: 局部变量/调用链完好, 代码视角"从没停过"
Thread.ofVirtual().start(r)、Thread.startVirtualThread(r)、批量任务用 Executors.newVirtualThreadPerTaskExecutor()(一任务一线程的 executor)。前两者拿裸线程, 第三种配 try-with-resources 自动 shutdown。
Thread.startVirtualThread(task); // 1 静态方法 Thread.ofVirtual().name("vt-1").start(task); // 2 builder try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) { vt.submit(task); } // 3 自动 shutdown
Semaphore 控"同时在飞的数量", 用下游容量反推许可数, 而不是缩线程池。
// 错思路: 池化 VT — 廉价对象当昂贵资源复用 Semaphore rate = new Semaphore(200); // 对: 限"同时在飞" rate.acquire(); try { call(); } finally { rate.release(); }
-Djdk.tracePinnedThreads=full; 修复: 换 ReentrantLock。
synchronized (lock) { jdbc.query(); } // JDK 21-23: 钉死 carrier private final ReentrantLock lock = new ReentrantLock(); // 排查: java -Djdk.tracePinnedThreads=full Main 打印钉住栈
static final ThreadLocal<User> CTX = new ThreadLocal<>(); // 每 VT 一份 // 判定: 对象大小 × 最大 VT 数! 512KB × 1M = 500GB // 大上下文: 显式传参, 或 ScopedValue 用完即走
setPriority 无意义(调度不管优先级), stop/suspend 依旧弃用且对 VT 行为一致抛异常; Thread.getAllStackTraces 对百万 VT 极慢 — 用 Thread.dump_to_file 替代。
vt.setPriority(Thread.MAX_PRIORITY); // 无意义: 调度不认优先级 vt.stop(); // → UnsupportedOperationException // 百万 VT 别 getAllStackTraces: jcmd <pid> Thread.dump_to_file
StructuredTaskScope 把"一批子任务的生命周期"绑成一个作用域: join/取消/异常传播自动成套 (ShutdownOnFailure 全成全败, ShutdownOnSuccess 取先成功)。多轮预览后逐步稳定, 引入前锁版本核对包名。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var a = scope.fork(() -> taskA()); // 预览 API, 锁版本核对包名 var b = scope.fork(() -> taskB()); scope.join().throwIfFailed(); // 一个失败 → 全取消 }
Future<Info> f = vt.submit(() -> httpClient.get(url)); // IO 密集 (网关/扇出/阻塞 JDBC): 红利满格 // CPU 密集 (压缩/加密): 零收益, 走核数大小的平台池
// 反应式: get().async(onOk, onErr) 回调地狱, 栈断了 Info info = infoClient.get(id); // VT: 同步写法, 阻塞不烧线程 try { use(info); } catch (IOException e) { log.warn("io", e); } // 栈完整
spring.threads.virtual.enabled=true: Tomcat 走一请求一 VT。注意它只改"入口线程模型", @Async/自定义 Executor/JDBC 池要自己评估 (见场景 1 与坑 13)。
// application.properties (Boot 3.2+, JDK 21+) // spring.threads.virtual.enabled=true → Tomcat 一请求一 VT // 只改入口线程模型; @Async/自定义 Executor 要单独换
synchronized (lock) { jdbc.query(); } // JDK 21-23: 阻塞 → pinning (LTS 雷区, 必须绕) // JDK 24 (JEP 491) 起: 不再 pinning, 可正常 unmount // 结论必须带版本号, 按所在 LTS 的实测行为写
IO 密集的老服务, 200 线程池常年打满排队; JDK 21 + Boot 3.2 起一行配置把"每请求一线程"换成 VT:
// application.properties (JDK 21+, Boot 3.2+) // spring.threads.virtual.enabled=true // Tomcat: 每个请求一个虚拟线程, 替代 maxThreads=200 的平台池 // 开开关前的三件前提, 缺一个都白改: // 1) @Async / 自定义 Executor 的地方不受该开关影响, 要单独换 VT executor // 2) ThreadLocal 缓存大对象的代码先清理 (内存放大千倍) // 3) synchronized 内做 IO 的老代码 → 先压测看 pinned, 见场景 5
只读接口灰度一周: P99 从 1.9s 降到 410ms, 机器没加一台。
CompletableFuture 编排三层 thenCombine, 异常要在流里接; VT 化后就是三行同步代码:
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) { Future<Info> a = vt.submit(() -> infoClient.get(id)); // 阻塞调用放心写 Future<Price> b = vt.submit(() -> priceClient.get(id)); Future<Stock> c = vt.submit(() -> stockClient.get(id)); return render(a.get(), b.get(), c.get()); // get() 阻塞的是 VT, 不烧内核线程 } // 代码量 -40%, 异常回归普通 try/catch, 栈完整可调试 // executor 用完即关 (try-with-resources), 别做成全局静态常驻 — 见坑 7
大促后跑百万条补偿, 平台线程池 64 根跑三天; VT 一任务一线程, 但下游承不住无限并发 — 信号量是唯一闸门:
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { Semaphore rate = new Semaphore(200); // 全局同时在飞 ≤200, 按下游容量定 List<Future<?>> fs = new ArrayList<>(); for (long id : ids) { fs.add(pool.submit(() -> { rate.acquire(); // 排队等许可: 阻塞在 VT 上, 零内核占用 try { compensate(id); } finally { rate.release(); } })); } for (Future<?> f : fs) f.get(); // 别忘 get: 不 get 异常照样静默 (老坑不变) }
同样 8C 容器: 3 天 → 5 小时, 下游错误率零变化 (许可数对齐下游压测上限)。
VT 化后 QPS 没涨? 阻塞 JDBC 在 VT 上没问题, 但连接池只有 10 — 排队从线程池挪到了池子里:
HikariConfig cfg = new HikariConfig(); cfg.setMaximumPoolSize(50); // 池上限 = 新瓶颈: VT 百万也过不去这 50 个连接 // 压测数据示意 (8C 容器, 下游 SQL P99 50ms): // 平台线程 200 + 池 10 → 8.2k QPS · P99 1.8s (线程池排队) // VT + 池 10 → 9.0k QPS · P99 1.6s (排队挪到连接池) // VT + 池 50 → 21.0k QPS · P99 420ms (真瓶颈消除) // 再往上是 DB 自己的极限 — 限流与熔断永远要配着加, VT 不豁免容量规划
灰度 VT 后吞吐反而降: 一段老代码在 synchronized 里查库, 把 carrier 全钉死了:
// 灰度 JVM 参数: -Djdk.tracePinnedThreads=full // 日志: Thread[#67,ForkJonPool-1-worker-3] pinned due to: // com.x.OrderService.warmCache → synchronized (lockCache) { jdbc query... } private final ReentrantLock lock = new ReentrantLock(); // 修复: 换锁 void warmCache() { lock.lock(); // ReentrantLock 阻塞可 unmount try { warmWithDb(); } finally { lock.unlock(); } } // 修完 P99 410ms; 顺带把"锁内查库"这个老毛病也治了 — 锁内 IO 本就该移出去
读多写少的配置查询, 主备机房同时发, 用先回的那份; 手写 Future 竞速要管取消, 结构化并发自动收尾:
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) { scope.fork(() -> primary.get(url)); // 主机房 scope.fork(() -> standby.get(url)); // 备机房, 两个 VT 同步竞速 scope.join(); // 阻塞的是"当前 VT", 不占内核线程 return scope.result(); // 第一个成功的结果; 其余自动取消 } // 注意: 结构化并发长期预览 (JEP 505 等), 包名/类名随版本核对, 生产锁死 JDK 版本再上
链路上下文 512KB 缓存进 ThreadLocal, VT 一百万个就是 500GB; 平台线程池时代 200 份没问题, 现在量纲变了:
// 坏: 大对象 × 最大 VT 数 = 灾难 static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>(); // 512KB × 1M // 好: 上下文显式传参; 必须线程绑定时用有界作用域 private static final ScopedValue<UserCtx> CTX = ScopedValue.newInstance(); ScopedValue.where(CTX, userCtx).run(() -> handler.process(req)); // 用完即走 // 判定标准一句话: 这个对象 × 最大 VT 数, 还小吗? // 小对象 (locale/traceId 几十字节) 照用 ThreadLocal, 不用过度设计
老网关用 Netty 写了三千行 pipeline, 新人接不住; 换 VT 后回到教科书写法:
try (ServerSocket server = new ServerSocket(8080); ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { while (running) { Socket s = server.accept(); pool.submit(() -> handle(s)); // 一连接一 VT, 万级也轻 } } static void handle(Socket s) throws IOException { try (s; var in = new BufferedReader(new InputStreamReader(s.getInputStream()))) { for (String line; (line = in.readLine()) != null; ) // 阻塞 = unmount dispatch(line); // 闲连接零线程占用 } }
同容器同下游 (P99 45ms), 只切线程模型, wrk -t8 -c2000 -d10m, 变量只有一个:
// 平台线程池 (max 200): 6.5k QPS · P99 2.1s · CPU 40% // → 大量上下文切换, 200 根线程在 8 核上来回踩 // 虚拟线程 (一请求一 VT): 18.7k QPS · P99 380ms · CPU 55% // → 切换消失 (mount/unmount 是用户态搬栈), IO 等待不占线程 // 边界验证 (重要): 同服务的 CPU 密集路由 (压缩+加密) 单独压 — // 平台池 5.1k QPS vs VT 5.2k QPS: 原地踏步, CPU 密集零红利 // 结论模板: 收益只来自"阻塞等待占比", 压测报告必须分路由看
直接全量切 VT 风险大 (pinning/内存放大都是压测才现形); 开关化灰度, 出事改一行配置回滚:
@Bean ExecutorService jobExecutor(@Value("${jobs.virtual:false}") boolean virtual) { return virtual ? Executors.newVirtualThreadPerTaskExecutor() // VT 通道 : Executors.newFixedThreadPool(64); // 原通道兜底 } // 灰度顺序: 只读接口 → 混部 5% 流量 → 全量; 每步观察: // QPS / P99 / pinned 计数 (jcmd Thread.dump_to_file) / 下游错误率 / 堆增长 // 别跳步: 场景 5 与 7 的两个坑, 都是 5% 流量跑了两天才现形的
ReentrantLock; JDK 24+ 才有 JEP 491 缓解。
synchronized (cacheLock) { dao.warm(); } // 错: IO 钉死 carrier (JDK<24) private final ReentrantLock lock = new ReentrantLock(); lock.lock(); try { dao.warm(); } finally { lock.unlock(); } // 对
ScopedValue。
static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>(); // 错: 512KB×1M process(req, ctx); // 对: 显式传参 ScopedValue.where(CTX2, ctx).run(() -> handler(req)); // 对: 作用域
vt.submit(() -> gzip(bytes)); // 错: CPU 密集跑 VT, 零收益还抢 carrier ExecutorService cpu = Executors.newFixedThreadPool( Runtime.getRuntime().availableProcessors()); // 对: 核数个平台线程
newVirtualThreadPerTaskExecutor 一任务一线程, 限并发用 Semaphore。
// 错: newFixedThreadPool(200, Thread.ofVirtual().factory()) // 并发被 200 卡死, 廉价对象当昂贵资源复用 Executors.newVirtualThreadPerTaskExecutor(); // 对: 一任务一线程
vt.setPriority(Thread.MAX_PRIORITY); // 错: 静默无效 vt.stop(); // 错: 抛 UnsupportedOperationException vt.interrupt(); // 对: 取消走 interrupt 协作
// 错: 两个 synchronized+IO 热点把 8 根 carrier 全钉死 jcmd <pid> Thread.dump_to_file -format=json // 对: 看 pinned 计数 // 告警阈值挂在 pinned 计数, 别等健康接口一起倒
// 错: static final ExecutorService VT = ...; 从不 close, 重部署泄漏 try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) { runBatch(vt); } // 对: 用完即关
// 错: 背 "synchronized 必 pinning" 跑到 JDK 25 还在绕锁 // 对: 结论带版本区间 — JDK 21-23 绕, 24+ (JEP 491) 已解决 // 升级 LTS 后重跑 pinning 压测再定策略
HikariConfig cfg = new HikariConfig(); cfg.setMaximumPoolSize(10); // 错: VT 百万也过不去 10 连接 cfg.setMaximumPoolSize(50); // 对: 按下游容量调, 配限流熔断
Object.hashCode() (身份哈希) 会让 VT 在 synchronized 处无法 unmount. 正解: 关键锁类覆写 hashCode 或版本升级; 老版本用 tracePinnedThreads 验证。
class CacheKey { } // 错: 默认身份哈希 (JDK 21-23) → pinning class CacheKey { @Override public int hashCode() { return id; } // 对: 覆写 }
executor 参数把回调钉回 VT executor; 或全链路 VT 化别混搭。
future.thenApply(this::render); // 错: 回调落 commonPool, 上下文丢 future.thenApplyAsync(this::render, vtExecutor); // 对: 钉回 VT // 或全链路 VT 化, 别混搭
setUncaughtExceptionHandler 上报; 或统一走 executor+Future.get (与异常页坑 12 同源)。
Thread.startVirtualThread(task); // 错: 异常只进 stderr, 无告警 Thread.ofVirtual().uncaughtExceptionHandler( (t, e) -> reporter.error(t.getName(), e)).start(task); // 对
spring.threads.virtual.enabled=true 只换 Tomcat 入口线程; @Async 的自定义池、MQ 消费线程、定时任务还是平台池, 压测收益砍半. 正解: 逐个线程来源盘点 (入口/异步/调度/消费), 配置或 Bean 分别替换。
// spring.threads.virtual.enabled=true 只换 Tomcat 入口线程! // 错: 以为 @Async/MQ 消费/定时任务也自动 VT 化了 // 对: 逐个线程来源盘点, 配置或 Bean 分别替换
// 错: 一请求 fork 几十个 VT 调下游, 大促并发数雪崩 Semaphore gate = new Semaphore(500); // 对: 下游容量反推 gate.acquire(); try { callDownstream(); } finally { gate.release(); }
// 错: 抄 JDK 21 博客的 ScopedValue 写法到 25 → 编译不过 // 对: 锁死团队 JDK 版本再引入; 升级时把 ScopedValue // 列入 API 兼容性检查清单
// 错: FastThreadLocal/RxJava 线程亲和库直接跑 VT, 缓存全 miss // 对: 按官方 VT 适配版本走; 过渡期该部分保留平台线程模型
jcmd <pid> Thread.dump_to_file -format=json 看结构化 VT dump; 监控按 carrier 数+VT 总数聚合, 别逐线程报警。
jstack <pid> > dump.txt // 错: 输出爆炸, 运维脚本卡死 jcmd <pid> Thread.dump_to_file -format=json dump.json // 对 // 监控按 carrier 数 + VT 总数聚合, 别逐线程报警
// 反应式: obs.onError(err -> fallback(err)) 错误走回调流 Info i = client.get(id); // VT 化后变回同步 throw // 对: 入口 filter/全局异常兜底覆盖同步抛出路径
// ReentrantLock 不 pinning, 但竞争激烈时大量 VT 挂起排队 // → 内存里堆的是"挂起的栈" // 对: 缩小临界区/细粒度锁; 锁竞争治理 VT 不豁免
// 错: "能扛百万并发" → 放宽下游限流与重试 → 下游先死 // 对: 容量按最弱下游规划; VT 省下的资源换成更严的 // 背压与熔断, 而不是更野的放大