7.4 线程调度与虚拟线程


7.4 线程调度与虚拟线程

本节摘要:平台线程是操作系统的贵重资产:一兆栈起步、阻塞即内核挂起,数量以千计封顶;虚拟线程把栈帧搬进堆、阻塞即卸载,由 JVM 调度到少量载体线程上,数量可以到百万。本节先补齐线程生命周期的完整状态机,再用实验对比两种线程的成本模型,收束到"什么负载该换、什么代价要防",并为全册收尾。

线程的状态机在第六章 jstack 输出里已经见过实物,这里先给全生命周期的规范视图,再进入本节主角:虚拟线程对线程成本模型的重写。

线程的一生:状态机与调度归属

线程从 new 到 terminate 经过六态:NEW(建好未启动)、RUNNABLE(可运行,含等 CPU 的就绪态)、BLOCKED(等监视器锁)、WAITING(无限期等动作:wait、join、LockSupport)、TIMED_WAITING(限时版)、TERMINATED(结束)。判读口诀在 6.3 已经用过:BLOCKED 与 WAITING 的本质差别是等什么——等锁还是等别人的动作。

调度的归属要分清:RUNNABLE 之外的状态,线程不在 OS 调度队列里;RUNNABLE 内部,就绪与运行的切换由操作系统抢占式调度决定——JVM 对优先级只是"建议",跨平台行为不可依赖。这套模型到虚拟线程为止都适用,但它有一个昂贵的地基:平台线程一对一映射内核线程。栈空间默认一兆起步(-Xss),创建与销毁要走系统调用,阻塞任何 IO 或锁都会触发内核级挂起与唤醒。所以线程池要配额、连接池要限流、"线程数 = 核数乘若干倍"成了祖传公式——全都是对这个贵重资产的记账方式。

虚拟线程:把栈搬进堆

虚拟线程(JDK 21 正式)改的是地基。它的栈不占系统栈空间,而是一块可生长的堆内存;它不映射内核线程,而是"挂载"在少量平台线程(称为载体,carrier)上执行。关键机制在阻塞时:虚拟线程遇到 IO 或锁,把自己的栈帧整体从载体上卸下存回堆,载体立刻转去跑别的虚拟线程;IO 完成后由调度器把它再挂载到某个载体上续跑——对内核来说,那几个载体线程始终在忙,没有挂起唤醒的开销。

import java.time.Duration; import java.util.concurrent.*; public class VThreadDemo { static final int TASKS = 100_000; public static void main(String[] args) throws Exception { // 平台线程方案:线程池限流到五百并发,全部跑完需多轮 try (var pool = Executors.newFixedThreadPool(500)) { long start = System.nanoTime(); for (int i = 0; i < TASKS; i++) { pool.submit(() -> { Thread.sleep(Duration.ofMillis(100)); // 模拟 IO 等待 return null; }); } pool.shutdown(); pool.awaitTermination(5, TimeUnit.MINUTES); System.out.println("platform-ish pool: " + (System.nanoTime() - start) / 1_000_000 + " ms"); } // 虚拟线程方案:十万个任务各一条线程,直上直下 try (var vp = Executors.newVirtualThreadPerTaskExecutor()) { long start = System.nanoTime(); for (int i = 0; i < TASKS; i++) { vp.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return null; }); } vp.close(); // 等待全部完成 System.out.println("virtual per-task: " + (System.nanoTime() - start) / 1_000_000 + " ms"); } } }
$ java VThreadDemo platform-ish pool: 20512 ms virtual per-task: 1143 ms

同一份 IO 密集负载,线程池方案被五百并发封顶、十万个任务排成两百轮,二十秒出头;虚拟线程方案十万个任务全量并发、单轮等完,一秒出头。差距的本质不是"线程更快了",而是容量模型变了:平台线程方案里并发度被池大小锁死,虚拟线程方案里并发度等于任务数——阻塞的成本从"占用一条内核线程"降为"堆里多存一份栈帧"。

API 上的迁移成本几乎为零:虚拟线程就是 java.lang.Thread,ExecutorService 接口通用,Thread.ofVirtual().name("biz-", 0).factory() 可以造带名字的工厂。值得记住的还有调度细节:虚拟线程的调度是 JVM 内的协作式 FIFO,没有抢占优先级;其上运行的代码若不做 IO、纯粹空转 CPU,反而得不到好处——CPU 密集负载的承载力永远由核数决定,这条物理底线虚拟线程改不了。

换与不换:一份务实的边界清单

适合上虚拟线程的负载特征:IO 密集(网关、编排、聚合类服务)、每请求一线程模型、阻塞点多且分散(JDBC、HTTP 客户端、消息收发)。收益最大的是那些"为了扛并发把线程池调到几千、又同时背着上下文切换与内存开销"的服务——虚拟线程直接把池这个概念取消。

需要警惕的代价同样明确,集中在"钉住"(pinning):虚拟线程在两种情况下无法从载体卸载——执行 synchronized 块内的阻塞(JDK 24 起大幅缓解,此前是主要坑),以及调用 native 方法时发生阻塞。钉住期间载体被占死,虚拟线程的容量优势瞬间蒸发。7.3 的锁知识在此闭环:重量级锁阻塞正是钉住的根源,迁移到虚拟线程的存量服务,热点路径上的 synchronized 阻塞要改成 ReentrantLock(可挂载阻塞)。

-Djdk.tracePinnedThreads=full # 钉住发生时打印完整栈,排查钉住的开关

另一条纪律:不要给虚拟线程做池化。它廉价的本意就是"用完即弃",池化反而把调度与记忆集搞复杂;每任务一条线程是官方推荐的姿势。ThreadLocal 重度用户注意:百万虚拟线程各自一份 ThreadLocal 副本,内存账要重算,能改作用域的改作用域(新出的作用域值 API 就是为替代滥用 ThreadLocal 而来)。

⚠️ 常见坑:把虚拟线程当"免费并发"无限开。下游连接池、数据库连接数、外部接口配额不会因为你开百万线程而变大——线程容量上去了,真正的瓶颈原样暴露在连接池与下游超时上。虚拟线程放大的是调度容量,不是系统容量,限流与熔断一件都不能少。

💡 关键直觉:虚拟线程赢在"阻塞变便宜",而不是"执行变快"。它把"每请求一线程"从奢侈品变回日用品,于是代码可以回到最直白的写法——同步的、顺序的、好调试的——不必再为并发度扭曲成响应式链条。这是十年来 Java 并发模型最大的一次"减负"。

要点回顾

  • 六态状态机:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED;BLOCKED 等锁、WAITING 等动作;
  • 平台线程贵在映射:内核线程一对一、栈以兆计、阻塞即挂起,数量以千计;
  • 虚拟线程的地基改造:栈存堆、阻塞即卸载、少量载体续跑,阻塞成本降为堆内存;
  • 实验证据:IO 密集十万任务,池化二十秒对虚拟线程一秒,容量模型之别;
  • 钉住是主要风险:synchronized 内阻塞与 native 阻塞无法卸载,存量迁移重点排查,用 tracePinnedThreads 定位;
  • 纪律三条:不池化、重算 ThreadLocal 账、下游限流照旧——虚拟线程放大调度容量而非系统容量。

至此全册解剖完毕。回看这台被你拆过的机器:第一章立规矩、第二章切内存、第三章跟类走、第四章点火执行引擎、第五章看清运、第六章学诊断、第七章收束线程。七个部件合起来,是同一句话——性能问题从来不是玄学,只是还没被解剖到病灶所在的层次。下一步的进阶方向也已在你手边:想更深,去读第五章收集器的源码级论文与 JEP 提案;想更实用,把第六章的病案库在你自己的服务上各演一遍。手术灯关掉,解剖台清空——但你看 JVM 的那双眼睛,已经不一样了。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U