VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

虚拟线程:等待不再占人——Java 工兵连第 9 篇

「Java 工兵连」第 9 篇,讲虚拟线程(JDK 21 转正,JEP 444)——版本史那篇说「21 变线程」,这篇就是兑现。它可能是十年里对 Java 服务端吞吐影响最大的一次变更,但用对它需要先推翻三个旧直觉。

一、先建直觉:餐厅服务员

左:平台线程一桌一服务员;右:虚拟线程少量服务员全店流转

平台线程模式:每桌配一个专职服务员(1:1 映射 OS 线程)。客人拿着菜单思考点什么——服务员干站着等;菜还没好——服务员继续站着。CPU(服务员)的时间全耗在「陪等」上。每个服务员成本还高:约 1MB 栈 + OS 资源,几千个就到顶——所以第 1 篇才需要线程池这种「贵重资源调度器」。

虚拟线程模式:全店只留 CPU 核数那么几个服务员(载体线程 carrier),客人(任务)要多少有多少(虚拟线程)。谁家在等待(IO 阻塞),服务员就走开去服务别桌;谁家菜好了(IO 返回),再安排人来端。等待不再占人——这就是吞吐质变的全部秘密。

技术上的对应关系:虚拟线程的栈放在堆上(KB 级起步,按需增长),由 JVM 而非 OS 调度;遇阻塞就卸载(unmount),载体线程立刻去跑别的虚拟线程。百万级虚拟线程在一台普通服务器上轻松跑起来。

二、怎么用:三种姿势

// ① 直接起一个
Thread vt = Thread.ofVirtual().name("order-", 0).start(() -> process(order));
Thread.startVirtualThread(() -> save(log));              // 快捷方式

// ② 执行器风格(最常用)——注意名字:每任务一个虚拟线程,没有池
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    orders.forEach(o -> executor.submit(() -> process(o)));   // 一万个订单 = 一万个虚拟线程,随便提
}

// ③ Spring Boot 3.2+ 一键切换:Tomcat 用虚拟线程处理请求
// application.yml:  spring.threads.virtual.enabled: true

谁受益最大:IO 密集服务——HTTP 调用、数据库查询、缓存读取,一切「大量时间在等对方回话」的代码。写法不用改:new URL(...).openStream()、JDBC、Thread.sleep 照旧,阻塞的语义还在,只是阻塞的代价没了。同步的风格,异步的吞吐——这是对响应式(WebFlux/Reactor)那套「用复杂度换吞吐」路线的降维打击。

三、推翻三个旧直觉

直觉一:「线程要池化」 → 不池化

池化存在的原因是线程贵(建得慢、占内存)。虚拟线程把「贵」解决了——建一个就是分配一个小对象,用完即弃。newVirtualThreadPerTaskExecutor 的名字就说明了一切:每任务新建,没有复用。给虚拟线程套 newFixedThreadPool 式的池化,等于给共享单车修车库。

但第 1 篇「资源隔离」的思想没有过期——只是对象换了:任务用虚拟线程敞开跑,真正的昂贵资源(数据库连接池、外部 API 配额)反而成了新瓶颈。百万虚拟线程压向 20 个连接的池,排队的就是连接池——限流和隔离的功课,从线程池挪到了连接池和信号量上。

直觉二:「ThreadLocal 随便用」 → 慎用

ThreadLocal 的开销跟着线程数走:以前几百个平台线程无感,现在百万虚拟线程,每个挂一个大对象就是内存爆炸。传递上下文请改用 ScopedValue(JDK 21 预览、25 转正):不可变、作用域结束自动失效、对虚拟线程友好。存量代码里的 ThreadLocal 缓存(SimpleDateFormat 之类)迁移前先审计大小。

直觉三:「synchronized 保平安」 → 会钉人(JDK 21–23)

Pinning(钉住):JDK 21–23 里,虚拟线程进入 synchronized 块后如果阻塞,无法卸载——载体线程被钉住陪着等。少量钉住无妨;高并发下大量虚拟线程挤进同一个 synchronized 区域,就会把少数载体线程全部钉死,吞吐断崖。两个应对:

-Djdk.tracePinnedThreads=full    # 打印钉住堆栈,排查利器
  • JDK 21–23 生产:热点路径的 synchronized 换 ReentrantLock(它不钉人——第 7 篇 Redisson 用的就是它);
  • JDK 24(JEP 491)已修复:synchronized 阻塞不再钉住载体。升级到 24+,这条坑自动消失——这也是「LTS 之外看 JEP」的又一次实践。

最后泼一盆清醒的水:虚拟线程治等待,不治计算。CPU 密集任务(编解码、加密、大数据计算)该多少核还是多少核——虚拟线程优化的是「等待的并发」,不是「计算的并行」。算力不够,答案还是并行流/ForkJoin/加机器。

四、不变的东西:线程安全

虚拟线程不解决数据竞争。一百个虚拟线程同时 count++,照样丢更新(第 3 篇 volatile 篇的老朋友);该用 LongAdder 还是用,该 CHM 还是 CHM。而且并发量放大几个数量级后,原来概率性的竞态会变成必现——迁移虚拟线程后偶发数据错乱变高频,先查自己代码里的「检查-再-行动」,而不是怀疑虚拟线程。

老兵点评

  1. 迁移顺序比迁移决心重要。推荐路径:先把入口层(Tomcat 请求处理)切虚拟线程拿吞吐红利,业务代码一行不改;跑稳后再处理 ThreadLocal 和 synchronized 的存量(用 -Djdk.tracePinnedThreads 做体检);DB 连接池按新并发画像重调。一把梭全切是事故配方。
  2. 响应式不是错,只是不再必要。WebFlux 解决的「线程数瓶颈」被虚拟线程釜底抽薪,但响应式在背压、流式组合上的表达力仍有场景。新项目默认「虚拟线程 + 阻塞式写法」,除非真有流式背压需求——复杂度要用在刀刃上。
  3. 面试讲虚拟线程的段位差:初级背「轻量、JDK 21」;中级讲载体线程、卸载和「不池化」;高级讲 pinning 的来龙去脉、ThreadLocal 的内存账、以及「同步写法拿到异步吞吐」对编程模型的冲击。

带走三句话

  1. 虚拟线程 = 等待不占人:栈在堆上、KB 级、百万并发;治等待不治计算。
  2. 三条新军规:不池化(贵的东西才池化,它不贵)、慎 ThreadLocal(换 ScopedValue)、21–23 慎 synchronized(钉载体,换 ReentrantLock;24 已修复)。
  3. 线程安全没变:数据竞争照旧,并发量放大只会让「检查-再-行动」的竞态更快现形。

本篇是「Java 工兵连」第 9 篇。→ 上一篇:第 8 篇 · 线程安全的集合怎么选 | 继续阅读:第 10 篇 · 排查 OOM 的固定套路(第一季收官) | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →