想象你经营着一家传统餐厅。餐厅最宝贵的资源不是食材,而是服务员——每一位进店的顾客都需要一位固定的服务员全程陪同。从点单、上菜到结账,这位服务员只能服务你一个人。哪怕你只是坐在那儿翻菜单发呆 10 分钟,服务员也必须站在旁边干等。为了不怠慢客人,你只能不停雇佣新的服务员,可每个服务员都要占工位(约 1MB 内存)、要培训(创建成本高)。很快餐厅就陷入“客人排队、服务员不够、成本爆炸”的困境。 Project Loom 带来了一种“叫号快餐厅”的全新模式:顾客进门领一张叫号单(虚拟线程),等餐时可以在大厅随意走动;真正的收银员(平台线程)只在为你服务的瞬间才出现——你点菜时收银员为你敲单,你去等餐时收银员立刻转身服务下一位顾客。这样一来,一家餐厅只需两三个收银员,就能接待成百上千的顾客。这就是本文的故事:虚拟线程如何用极小的开销承接海量并发,让 Java 高并发编程从“省着用线程”变成“随心所欲开线程”。
掌握虚拟线程,你就能用最简代码撑起高并发系统,是后端工程师从“线程池调参”走向“架构级吞吐设计”的关键一步。
目录
可视化演示
展示虚拟线程M:N调度模型(多虚拟线程映射少载体线程挂载卸载)、虚拟线程vs平台线程对比(内存1:500/并发1000x/创建成本)与Continuation挂起恢复机制(栈帧捕获yield→Heap→remount),交互切换核心技术。
本节配有交互动效(进入视口后自动加载,约 3KB)。
虚拟线程与Project Loom M:N调度:大量虚拟线程(M)映射到少量载体线程(N),JVM用户态调度挂载/卸载 vs平台线程:内存1:500(0.2KB vs 1MB),并发量提升1000x,创建成本降低500x Continuation:yield捕获栈帧→卸载到Heap→恢复时remount,避免synchronized防Pinning VT_mem ≈ 0.2KB | PT_mem ≈ 1MB | M:N = VT → CarrierPool | yield = Continuation.unmount→remount Java 21轻量级线程,同步阻塞编程模型实现高并发
1. Loom项目背景:Java的并发之痛
老式餐厅最怕什么?怕客人多,更怕客人坐下来只看不点。每一位顾客都必须配一位全程陪同的服务员(平台线程),哪怕顾客只是发呆半小时,服务员也得陪着。Java 多年来的并发模型就是这样:一个请求占一个线程,线程是操作系统提供的“专职服务员”,创建要花时间、占内存(默认栈约 1MB),而且一旦阻塞在 I/O 上就彻底空闲。为了控制成本,大家只好用线程池固定服务员数量——客人一多,剩下的人只能在门口排队。
虚拟线程并非凭空发明,其他语言早有类似方案:Go 的 Goroutine、Ruby 的 Fiber(纤程),本质都是“用户态调度的轻量级任务”。Project Loom 是 Java 官方给出的答案:让 JVM 自己管理海量轻量线程,把“服务员”和“顾客”解耦,把操作系统线程从“一对一陪伴”中解放出来。
传统线程能开多少,直接受内存约束:
Loom 的愿景,就是给 Java 开一家“叫号快餐厅”:不再为每位顾客配专属服务员,而是让顾客拿着号单(虚拟线程)自由活动,让收银员(平台线程)高效轮转起来。
- 内存总量 \(M_{\text{RAM}}\)服务器可用的物理内存,例如 8GB。
- 单线程栈 \(S_{\text{stack}}\)每个平台线程默认栈空间约 1MB。
- 可创建线程数 \(N_{\text{feasible}}\)内存除以单栈,得出理论上限。
2. 虚拟线程原理:一张叫号单
新式快餐厅里,顾客进门领一张叫号单。叫号单很便宜,一叠纸只要几块钱,发出去一万张也不心疼;但收银员很贵,店里只雇两三位。虚拟线程正是这张“叫号单”——它记录了顾客(任务)的身份、状态和要执行的事,但本身不占用“人力”。
虚拟线程是 java.lang.Thread 的实例,却由 JVM 在用户态创建和调度,不再一对一绑定操作系统线程。真正干活的是“载体线程”(Carrier Thread),也就是平台线程。当一个虚拟线程需要运行 CPU 指令时,JVM 把它挂到某个空闲的载体线程上;当它阻塞时,就把它从载体线程上摘下来。这就是经典的 M:N 映射模型:
每张叫号单只是一份轻量记录,虚拟线程的栈初始只有几 KB,按需增长。于是你可以大方地“每个任务发一张单子”,再也不用精打细算地复用线程。
- 虚拟线程数 \(M_{\text{virtual}}\)应用可以创建的海量任务数,可达百万级。
- 载体线程数 \(N_{\text{carrier}}\)真正执行任务的平台线程数,通常等于 CPU 核心数或略多。
- MN 映射:M 个虚拟线程分时复用 N 个载体线程。
3. 调度机制:叫号系统的运转
叫号快餐厅里有个隐形的大脑——叫号系统(调度器)。它盯着所有叫号单:谁的餐好了,就叫谁来取;谁还在等,就让他继续等。虚拟线程的调度器是 JVM 内置的,它会在虚拟线程执行阻塞操作时自动介入。
当虚拟线程执行 Thread.sleep()、Socket 读写、等待锁这类阻塞操作时,JVM 会把它挂起,立刻从载体线程上摘下来;载体线程转头就去执行另一个就绪的虚拟线程。等阻塞结束后,虚拟线程重新进入就绪队列,等待调度器再次把它放上载体线程。这个切换完全在用户态完成,不经过操作系统内核:
需要警惕的是“钉住”现象:如果虚拟线程在 synchronized 同步块内阻塞,JVM 无法在锁边界安全挂起,载体线程会被“钉住”空等。这就像收银员被一个顾客拽住袖子不放,没法去服务别人——调度机制虽好,用错锁依然会卡住。
- 平台线程切换 \(T_{\text{switch}}^{p}\)操作系统内核态线程切换,通常耗时数微秒。
- 虚拟线程切换 \(T_{\text{switch}}^{v}\)JVM 用户态切换,只需保存少量寄存器,耗时约几十纳秒。
- 数量级差距10~100 倍的切换开销差距,让调度器可以频繁切换而不心疼。
4. 性能优势:同样的店,十倍接待量
同样是几十平米的店面,老餐厅极限接待 20 桌客人,新餐厅却敢发 2000 张号单。因为前者每个顾客占一个服务员,后者所有顾客共享两三个收银员。虚拟线程的性能优势,来自三个层面的“瘦身”。
首先是内存。平台线程栈默认约 1MB,虚拟线程初始栈只有几 KB;其次是创建成本,平台线程创建需要系统调用,耗时毫秒级,虚拟线程纯粹在堆上分配,耗时微秒级;最后是切换成本,前面说过差了两个数量级。三者叠加,让 Java 应用第一次能轻松创建几十万甚至上百万个并发任务。
简单说,过去线程池卡在几百上千并发,虚拟线程能直接拉到十万级。吞吐量提升不是靠“跑得更快”,而是靠“不再傻等”。
- 虚拟线程栈 \(S_{\text{virtual}}\)初始约 4KB,按需动态增长。
- 平台线程栈 \(S_{\text{platform}}\)默认约 1MB,固定分配。
- 开销比 \(\rho\)两者相除得到约 1/256,粗略记为 1/100。
5. 结构化并发:包厢里的整桌管理
新餐厅还有一种高阶服务:包厢。一桌客人点了八道菜,后厨必须等这桌的菜全部上齐才算服务完成;如果客人中途要退单,后厨也得把没上的菜全部取消,不能让菜炒到一半滞留。结构化并发(StructuredTaskScope)就是这种“包厢管理”。
在传统异步编程里,你 submit 一堆任务到线程池,很难统一管理它们的生命周期——要么忘记等待,要么任务抛异常后其他任务还在后台继续跑。结构化并发要求:子任务的生命周期必须限定在代码块之内。你 fork 出去的每个子任务,都在同一个 StructuredTaskScope 中运行,join 会等待全部子任务结束;一旦任一子任务失败,其余未完成的子任务会被自动取消,异常统一抛出。
结构化并发把“发散-汇合”的并发模式变得像写同步代码一样清晰。包厢里要么整桌好菜上齐,要么整桌取消,绝不让菜滞留后厨——这正好解决了虚拟线程时代“任务太多管不过来”的新问题。
- 子任务耗时 \(T_{i}\)第 \(i\) 个并发子任务(如调用下游服务)的执行时间。
- 最大值 \(\max_{i=1}^{n}\)作用域要等最慢的子任务,其他快的先完成等待汇合。
- 作用域总耗时 \(T_{\text{scope}}\)整体耗时由最慢路径决定。
6. 应用场景:新模式的用武之地
叫号模式适合所有“顾客多、等待久”的餐厅:火锅店等位长、快餐店翻台快、外卖店单量爆炸。对应到 Java 世界,凡是阻塞占比高、并发量大、每个请求都要等外部资源的场景,都是虚拟线程的主场。
高并发 Web 服务是最典型的场景。传统 Tomcat 线程池默认 200,每个请求占一个平台线程,遇到数据库慢查询就集体卡死。换成虚拟线程后,每个请求一个虚拟线程,容器只需要少量载体线程即可承接海量并发。阻塞 JDBC 访问同样受益:JDBC 驱动是阻塞 API,过去只能靠加大线程池硬扛,现在虚拟线程让阻塞调用不再浪费平台线程。API 网关/代理转发到下游服务时,长时间等待响应也不会耗尽线程。
收益可以用一个简单的公式估算:
反过来,纯 CPU 密集型计算(图像渲染、矩阵运算)中几乎不阻塞,虚拟线程优势很小——就像高档法餐厅,顾客本来就安静用餐,叫号单反而多余。
- CPU 计算耗时 \(T_{\text{cpu}}\)请求真正使用 CPU 处理业务的时间,比如 50ms。
- 阻塞等待耗时 \(T_{\text{block}}\)请求等数据库、等下游响应的时间,比如 450ms。
- 加速比 Speedup阻塞时间越长,虚拟线程节省的空等资源越多。
7. 最佳实践:经营新餐厅的守则
叫号模式虽好,经营也有门道。第一条守则是不要池化虚拟线程。平台线程稀缺才需要池化,虚拟线程廉价到可以“每任务创建”,池化反而引入排队和复杂度。第二条是用信号量(Semaphore)限制对下游资源的并发访问。虚拟线程再多,数据库连接池只有 100 个,你得控制同时访问数据库的虚拟线程数,而不是让它们一窝蜂冲上去。
第三条是避免在虚拟线程中使用 synchronized。前面说过钉住现象,推荐改用 ReentrantLock,让 JVM 能在锁等待时安全挂起虚拟线程。第四条是用 ScopedValue 替代 ThreadLocal——虚拟线程可能数以万计,ThreadLocal 变量会随之膨胀内存,作用域值更安全可控。迁移策略上,优先改造 IO 密集的服务模块,先小范围验证再全面铺开。
锁竞争程度决定了钉住风险的大小:
遵循这些守则,你的“新餐厅”才能在高峰时段游刃有余:叫号单随手发、收银员不空转、后厨不积压。虚拟线程不是银弹,用对地方它就是高并发系统最锋利的刀。
ReentrantLock。- 临界区耗时 \(T_{\text{critical}}\)线程持锁执行同步代码块的时间。
- 锁外耗时 \(T_{\text{other}}\)线程不持锁的其他执行时间。
- 竞争概率 \(P_{\text{contention}}\)临界区占比越高,多个虚拟线程碰撞同一把锁的概率越大。
AI 应用场景
LLM 推理网关的高并发转发
阶段:模型推理与部署
解决的问题:大模型应用网关需要把海量用户请求转发到推理服务,每次转发要等上游生成结果(动辄数秒),期间线程完全阻塞。传统 200 线程的网关只能同时处理 200 个请求,高峰期大量排队。改用虚拟线程后,每个转发任务一个虚拟线程,阻塞等待时自动让出载体线程,网关吞吐量提升数倍且代码保持同步风格。
具体案例:某 AI 聊天应用的网关,用户请求经网关转发到 LLM 推理集群,平均等待 3 秒。线程池模型下 200 并发即饱和;迁移到虚拟线程后,单机即可支撑 3000+ 并发等待,代码只改动了线程创建方式。
数据采集管道的大规模并行拉取
阶段:数据采集与预处理
解决的问题:训练数据往往要从数十个数据源(HTTP API、数据库、对象存储)拉取,每个源的读取都是阻塞网络 I/O。传统方式用固定线程池,线程数受限于连接数;虚拟线程让每条数据记录对应一个虚拟线程,海量并行拉取互不阻塞。
具体案例:某推荐系统每天从 50 个数据源采集用户行为日志,单源响应延迟最高 800ms。用虚拟线程改写采集器后,并行度从 200 提升到 5000,全量采集耗时从 47 分钟缩短到 6 分钟。
在线推理特征拼接服务
阶段:在线推理与特征工程
解决的问题:一个推理请求需要同时从特征存储、Redis 缓存和多个下游特征服务取数,串行调用延迟叠加严重。虚拟线程配合结构化并发并行发起所有特征请求,全部返回后再统一拼装特征向量,既提升速度又避免线程池失控。
具体案例:某广告 CTR 预估服务,单次推理需拼装 30 个特征。传统串行取特征耗时 90ms;使用虚拟线程 + StructuredTaskScope 并行取数后降到 25ms,且代码可读性与同步代码一致。
Python 演示
import asyncioimport threadingimport time def spawn_threads(n: int) -> float: """模拟传统平台线程:创建 n 个 OS 线程并行执行""" start = time.perf_counter() threads = [] for _ in range(n): t = threading.Thread(target=lambda: time.sleep(0.02)) threads.append(t) t.start() for t in threads: t.join() return time.perf_counter() - start def spawn_tasks(n: int) -> float: """模拟虚拟线程模型:创建 n 个轻量任务由事件循环调度""" start = time.perf_counter() async def main(): await asyncio.gather(*(asyncio.sleep(0.02) for _ in range(n))) asyncio.run(main()) return time.perf_counter() - start if __name__ == "__main__": n = 100 t_platform = spawn_threads(n) t_lightweight = spawn_tasks(n) print(f"platform threads: {t_platform:.3f}s") print(f"lightweight tasks: {t_lightweight:.3f}s") print(f"speedup: {t_platform / t_lightweight:.1f}x")
threading.Thread(target=lambda: time.sleep(0.02))为每个任务创建一个操作系统线程,对应“每个顾客配一个服务员”threads.append(t); t.start()启动线程并加入列表,线程启动后立即并发运行t.join()主线程等待所有平台线程结束,类似传统ExecutorService的shutdown()+awaitTermination()async def main()定义一个协程入口,对应虚拟线程模型中“一个任务一个协程”asyncio.gather(*(asyncio.sleep(0.02) for _ in range(n)))一次性创建 n 个轻量任务并发执行,对应虚拟线程的“每任务一个虚拟线程”asyncio.run(main())事件循环在单线程内调度所有协程,对应 JVM 调度器在载体线程上切换虚拟线程time.perf_counter()高精度计时,对比两种模型的创建与切换总开销
常见误区
误区我以为把业务代码换成虚拟线程,CPU 计算就会变快,就像升级了 CPU 一样,所有性能问题都能解决。
正解虚拟线程的加速来自“消除阻塞浪费”,不是提升计算速度。对 CPU 密集型任务,虚拟线程没有收益;对 IO 密集型任务,收益也主要是吞吐量提升而非单请求延迟降低。就像叫号餐厅并不会让厨师炒菜更快,只是让收银员不空等。
为何常见:“性能优化”四个字太笼统,容易让人误以为虚拟线程是通用加速器,忽略它优化的其实是“线程空等”。
误区既然虚拟线程只要几 KB 内存,那我可以放心开十万个,数据库连接、下游服务都不用管,反正线程够多。
正解虚拟线程本身廉价,但下游资源依然有限:数据库连接池、文件句柄、第三方服务配额都是稀缺资源。不加控制地并发访问,会用光连接池或打垮下游。正确做法是用信号量(Semaphore)限制同时访问有限资源的虚拟线程数。
为何常见:把“线程资源”和“业务资源”混为一谈,只盯着线程便宜,忘了真正的瓶颈在下游。
误区synchronized 是 Java 最基础的同步手段,虚拟线程也是线程,用起来应该一样,我根本没想过要换。
正解虚拟线程在 synchronized 块内阻塞时,JVM 无法在锁对象上安全挂起虚拟线程,会把载体线程一起“钉住”空等,导致其他虚拟线程也无法使用该载体。高并发下,钉住效应会显著削弱虚拟线程的复用能力。优先改用 ReentrantLock,它允许 JVM 在等待锁时挂起虚拟线程。
为何常见:synchronized 在低并发下表现正常,钉住问题只有在虚拟线程大规模竞争锁时才暴露,容易被归咎于“配置问题”。
误区以前用 `Executors.newFixedThreadPool(200)` 配合 `Future` 很顺手,现在也应该建一个固定大小的虚拟线程池来控制并发。
正解虚拟线程的设计初衷就是“每个任务创建一个”,池化虚拟线程会引入不必要的排队等待,反而降低吞吐量。需要限制并发时,应该用 Semaphore 精确控制对某类资源的访问,而不是限制虚拟线程本身的数量。
为何常见:平台线程时代的“稀缺资源必须池化”思维惯性太强,一时难以切换到“廉价资源随心创建”的新模型。
误区ThreadLocal 在传统线程里用得最多,虚拟线程也是 `Thread` 的子类,迁移时直接把 ThreadLocal 带过去就行。
正解虚拟线程数量可达十万甚至百万级,ThreadLocal 的内存会随线程数线性膨胀,导致 OOM 风险。更严重的是,虚拟线程可能在不同载体线程间迁移,ThreadLocal 的“线程隔离”语义会变得混乱。Project Loom 提供了 ScopedValue(作用域值)作为更安全、更省内存的替代方案。
为何常见:ThreadLocal 用得太顺手,加上虚拟线程表面上仍是 Thread,让人忽略了两者数量级和调度模型的本质差异。
面试题
Q1. Project Loom 为 Java 带来的核心能力是什么?
- A.引入协程标准库
- B.新增虚拟轻量级线程← 正确答案
- C.改善垃圾回收器
- D.提供响应式编程框架
答案:B。Project Loom 最核心的成果是虚拟线程(Virtual Thread),它让 Java 能创建百万级轻量线程。协程标准库、GC 优化、响应式框架都不是 Loom 的直接产物。选项 B 正确,其他选项描述的是别的技术方向。
Q2. 虚拟线程与平台线程最本质的区别是什么?
- A.虚拟线程没有线程栈
- B.虚拟线程由 JVM 用户态调度← 正确答案
- C.虚拟线程不能执行阻塞操作
- D.虚拟线程只能用于网络请求
答案:B。平台线程由操作系统内核调度,虚拟线程由 JVM 在用户态调度,这是本质区别。虚拟线程也有栈,只是更小且按需增长;它同样可以阻塞,只是阻塞时会自动让出载体线程。B 正确。
Q3. 平台线程在虚拟线程模型中充当什么角色?
- A.任务调度队列
- B.载体线程← 正确答案
- C.守护线程
- D.线程工厂
答案:B。虚拟线程最终要落到实际线程上执行,这个真正运行的平台线程称为载体线程(Carrier Thread)。就像叫号单最终要由收银员接单处理,虚拟线程只是任务描述,载体线程才是执行主体。B 正确。
Q4. 虚拟线程遇到以下哪些情况会自动挂起并让出载体线程?
- A.Thread.sleep() 强制睡眠← 正确答案
- B.Socket 网络读写阻塞← 正确答案
- C.纯 CPU 循环计算任务
- D.等待锁或条件变量← 正确答案
答案:ABD。睡眠、Socket 阻塞、等待锁都是阻塞操作,JVM 检测到后会自动挂起虚拟线程、释放载体线程。纯 CPU 循环不产生阻塞,虚拟线程会一直占用载体线程,不会主动让出。A、B、D 正确,C 错误。
Q5. 为什么虚拟线程能显著提升 IO 密集场景的吞吐量?
- A.虚拟线程利用多核并行加速 IO
- B.虚拟线程阻塞时让出载体线程← 正确答案
- C.虚拟线程压缩了 IO 数据量
- D.虚拟线程绕过了操作系统调度
答案:B。虚拟线程的优势在于阻塞等待时让出载体线程,让一个平台线程服务多个虚拟线程,消除了“线程空等”的浪费。它没有加速 IO 本身,也没有压缩数据。B 正确。
Q6. 平台线程栈约 1MB,虚拟线程栈约 4KB。同样 8GB 内存理论上可创建的虚拟线程数量约为平台线程的多少倍?
- A.约 1 比 1,数量完全相当
- B.约 1 比 100,相差两个量级
- C.约 1 比 250,相差两百多倍← 正确答案
- D.约 1 比 1000,相差三个量级
答案:C。8GB 内存按每线程约 1MB 估算,平台线程约 8192 个;按每虚拟线程 4KB 估算,虚拟线程约 209 万个。两者相除约 256 倍,最接近 C 的“约 1 比 250”。C 正确。
Q7. 关于 synchronized 钉住(pinning)现象,下列说法正确的是?
- A.虚拟线程会永久替换载体线程
- B.持锁阻塞时载体线程被钉住← 正确答案
- C.钉住会导致 JVM 直接崩溃
- D.只有 JDBC 操作会触发钉住
答案:B。虚拟线程在 synchronized 块内阻塞时,JVM 无法安全挂起它,载体线程会被“钉住”空等,无法执行其他虚拟线程。钉住会降低并发效率但不会崩溃,也不只限于 JDBC,任何 synchronized 内的阻塞都可能触发。B 正确。
Q8. 结构化并发(StructuredTaskScope)具备哪些特点?
- A.子任务生命周期跟随代码块← 正确答案
- B.任一异常则取消全部子任务← 正确答案
- C.自动创建线程池管理资源
- D.全部完成后统一返回结果← 正确答案
答案:ABD。StructuredTaskScope 把子任务的生命周期绑定到代码块,join 等待全部完成;任一子任务异常会触发其余任务取消;所有任务完成后统一收集结果。它不创建线程池,而是依赖虚拟线程“每任务创建”的模型。A、B、D 正确,C 错误。
Q9. 使用虚拟线程处理高并发请求时,合理的并发控制方式是?
- A.限制虚拟线程池大小为 200
- B.用 Semaphore 限制下游资源← 正确答案
- C.使用 synchronized 保护所有操作
- D.不做控制,全靠操作系统限制
答案:B。虚拟线程应该每任务创建、不该池化;真正要限制的是下游有限资源(如数据库连接),用 Semaphore 精确限流最合适。synchronized 可能引发钉住,完全不控制则可能打爆下游。B 正确。
Q10. 以下哪些场景适合使用虚拟线程?
- A.大量阻塞 Socket 连接← 正确答案
- B.JDBC 数据库阻塞访问← 正确答案
- C.纯 CPU 矩阵运算加速
- D.REST API 网关请求转发← 正确答案
答案:ABD。虚拟线程擅长 IO 密集、阻塞占比高的场景:大量 Socket 连接、JDBC 阻塞查询、网关转发等待下游响应都高度受益。纯 CPU 矩阵运算几乎没有阻塞,虚拟线程无法带来吞吐提升,应使用并行流或 GPU。A、B、D 正确,C 错误。