Featured image of post 理解虚拟线程:从一次阻塞说起

理解虚拟线程:从一次阻塞说起

从一次阻塞说起,理解虚拟线程如何提升 IO 并发能力。

主题 Java并发

你有没有遇到过这种情况——

一个典型的 Java Web 服务,Tomcat 默认线程池 200。请求处理逻辑很简单:查一下数据库,调一个远程接口,返回结果。每个请求从进入线程池到返回,大部分时间都在等数据库查询、等远程响应。

并发一上来,问题就出现了:200 个线程很快全部阻塞在 IO 等待上,新请求全部排队。

这时候你多半会想:线程池不够,加线程不就行了?

试了才知道这条路走不通。加到 1000,确实多撑了一会儿——但 1000 个线程照样会阻塞在 IO 上,第 1001 个请求照样排队。想靠加线程支撑真正的并发量?几万并发需要几万线程,每个线程 1MB 栈,就是几十 GB 内存;就算内存扛得住,操作系统调度几万个线程的开销也会把 CPU 吃光。

于是你看到一种奇怪的状况:CPU 大多是空闲的——因为线程都在等 IO,没有计算在跑;内存也没满——因为当前只有 200 个线程在跑,只占了 200MB 栈。但并发就是上不去,而且加线程这条路本身是死路

这不是硬件问题,是线程模型问题:线程是稀缺的「重」资源,一个线程被 IO 阻塞时,它什么都没干,却把一个宝贵的线程占住了。而 JDK 21 的虚拟线程,就是来改这个的。

这篇文章用一条问题链讲透虚拟线程:平台线程为什么不够用 → 虚拟线程怎么解决 → 它适合什么、不适合什么。

平台线程的四个瓶颈

Java 的传统线程(平台线程)和操作系统线程是一一对应的:Java 代码每创建一个线程,JVM 就调用操作系统 API 创建一个 OS 线程。

这种 1:1 模型在 Java 诞生时没问题,但放到现代高并发场景,四个瓶颈逐个暴露。

瓶颈表现根源
创建销毁贵线程创建销毁涉及内核态切换要操作系统参与
内存占用大每个线程默认分配 1MB 栈空间JVM 为每个平台线程预留栈
上下文切换贵保存恢复大量寄存器、栈状态,CPU 缓存失效内核调度,涉及用户态/内核态切换
可用数量有限一个进程通常只能创建数千个线程内存和调度成本双重限制

一句话总结:平台线程是「重」资源,不能随便创建。所以 Java 应用被迫用线程池复用线程——但线程池也有上限,并发一高就排队。

这就是为什么现代语言纷纷引入轻量线程:Go 的 goroutine、Kotlin 的协程。Java 直到 JDK 21 才正式端出虚拟线程(JEP 444,2023 年 9 月 19 日发布)。

虚拟线程:用户态轻量级线程

虚拟线程和平台线程最大的区别:它不由操作系统管理,由 JVM 管理

维度平台线程虚拟线程
创建销毁内核态,贵用户态,JVM 完成,便宜
内存占用默认 1MB 栈几 KB 级
上下文切换内核调度,保存大量状态用户态,保存少量状态
可用数量数千级数百万级

数字对比很直观:平台线程 1MB 栈,虚拟线程只要几 KB——内存开销差两个数量级。这也是为什么虚拟线程能支撑「thousands or millions」级别的并发,而平台线程到几千就顶不住了。

但虚拟线程的核心价值不只是「轻」,而是它的调度机制——这才是它和 goroutine 一样优雅的地方。

虚拟线程如何工作:阻塞就挂起

理解虚拟线程,先理解一个词:carrier(载体线程)

JVM 底层有一组平台线程专门用来运行虚拟线程(数量默认等于可用 CPU 核数),虚拟线程被调度器分配到这组 carrier 上执行。调度器是 JDK 内置的 work-stealing ForkJoinPool,FIFO 模式。

关键机制在阻塞发生时。看这个流程:

当虚拟线程调用阻塞 I/O 时,JVM 运行时自动把阻塞调用替换成非阻塞 OS 调用,然后挂起虚拟线程、释放 carrier。carrier 立刻去运行别的虚拟线程,不空等。

对比平台线程:一个平台线程阻塞在数据库查询或远程调用上,它只能空等,线程池里这个位置就被白白占用。200 个请求并发,200 个线程全在等 IO,第 201 个请求只能排队——这就是开篇那个场景。

JEP 444 把这层意思说得很直白:线程数往往在 CPU 或网络连接等资源耗尽之前,就先成为限制因素(“the number of threads often becomes the limiting factor long before other resources, such as CPU or network connections, are exhausted”)。阻塞的线程不消耗 CPU,但占着线程名额——所以你会看到资源有余、并发却上不去的怪象。

虚拟线程的思路是:我阻塞,但不占着茅坑。挂起时不消耗 carrier 资源,恢复后继续执行。这就是「thread-per-request」风格能跑通百万并发的根本原因——每个请求一个虚拟线程,阻塞时自动让位。

调度开销:虚拟线程省下的第二个大头

上面讲的是「阻塞时不占线程」,虚拟线程还省下另一笔大账——调度开销

平台线程的调度由操作系统完成:切换线程时,内核要保存/恢复寄存器和栈状态,还可能导致 CPU 缓存失效,这是一笔昂贵的开销。线程越多,OS 花在调度上的时间比例越高,真正干活的 CPU 时间反而变少——这是平台线程并发数上不去的第二个原因,和内存占用并列。

虚拟线程的调度完全不同:它由 JVM 的 ForkJoinPool 调度到 carrier 上运行,挂起和恢复都在用户态完成,只保存/恢复少量状态,不触发 OS 级上下文切换。JEP 444 原话是虚拟线程「cheap to create and almost infinitely plentiful」(创建便宜,数量近乎无限)。

所以虚拟线程提高并发是双管齐下

成本维度平台线程虚拟线程
内存成本1MB 栈/线程,几万线程就几十 GB几 KB/线程,支持百万级
调度成本OS 上下文切换,保存大量状态,缓存失效JVM 用户态挂起/恢复,保存少量状态

内存和调度两个大头都降下来,并发数才能从「数千」跳到「数百万」。这也是为什么说虚拟线程是低成本提高并发的手段——不是堆硬件,而是把每个并发单元的代价做小。

虚拟线程的边界:不是万能药

虚拟线程很强大,但有两个边界必须知道。

边界一:CPU 密集型任务没优势

虚拟线程解决的瓶颈是「大量任务等待 I/O」。对 CPU 密集型任务——任务本身在计算,不阻塞——虚拟线程没有帮助。

场景虚拟线程效果原因
IO 密集型(请求等待数据库/网络/文件)✅ 巨大提升阻塞期释放 carrier,并发数提升几个数量级
CPU 密集型(计算、加密、图像处理)❌ 无优势瓶颈是 CPU 核心数,不是线程数

CPU 密集场景的正确做法还是:合理分配任务到 CPU 核心,控制线程数。

边界二:pinning——虚拟线程被「钉住」

正常情况下,虚拟线程阻塞时释放 carrier。但如果虚拟线程执行到 synchronized 块或 native 方法 时发生阻塞,它会被「钉」在 carrier 上——因为 JVM 无法在这些场景下安全地挂起和切换。

这不会导致应用错误(JEP 444 明确说 pinning 不使应用 incorrect),但会阻碍扩展性——被钉住的 carrier 不能服务其他虚拟线程。

高频遇到 pinning 的场景:基于 JDBC 的数据库驱动(MySQL Connector/J、PostgreSQL JDBC 等)和 synchronized 方法。这也是为什么「虚拟线程 + 传统 JDBC 连接池」不是好组合——数据库连接是稀缺物理资源,连接池大小远小于虚拟线程并发数,大量虚拟线程会阻塞在等连接上,而 synchronized 又可能引发 pinning。

回到开头

回顾整条问题链:

平台线程 1:1 映射 OS → 重资源 → 并发受限于线程数
  → 虚拟线程:用户态轻量级线程 → 阻塞就挂起,不占 carrier
    → IO 密集型受益巨大(百万级并发)
    → CPU 密集型无优势
    → synchronized/native 场景可能 pinning

下次遇到 Java 服务并发上不去,先想清楚两件事:

  1. 任务是 IO 密集还是 CPU 密集? IO 密集才考虑虚拟线程,CPU 密集先优化计算本身
  2. 代码里有没有 synchronized / JDBC 阻塞? 有的话,先解决 pinning 场景再上虚拟线程

虚拟线程不是银弹,但它确实把 Java 带进了「百万级并发」的赛道——前提是用对场景。

本文基于 JEP 444(openjdk.org/jeps/444)官方规范与 Java 版本历史(Wikipedia)的梳理,平台线程默认栈 1MB、虚拟线程数千万级等数据均来自该来源。

留言

欢迎分享你的想法。评论提交后会在审核通过后显示。