你有没有遇到过这种情况——
一个典型的 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 服务并发上不去,先想清楚两件事:
- 任务是 IO 密集还是 CPU 密集? IO 密集才考虑虚拟线程,CPU 密集先优化计算本身
- 代码里有没有 synchronized / JDBC 阻塞? 有的话,先解决 pinning 场景再上虚拟线程
虚拟线程不是银弹,但它确实把 Java 带进了「百万级并发」的赛道——前提是用对场景。
本文基于 JEP 444(openjdk.org/jeps/444)官方规范与 Java 版本历史(Wikipedia)的梳理,平台线程默认栈 1MB、虚拟线程数千万级等数据均来自该来源。

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