理解虚拟线程
为什么会有虚拟线程
JDK 21 把虚拟线程带进了 Java 标准库。它并不是要取代平台线程,也不是让 CPU 突然变快;它主要解决的是另一类问题:服务里有大量任务在等网络、数据库或其他外部资源时,怎样不让“等待”长期占住宝贵的操作系统线程。
先从我们熟悉的线程模型说起。传统 Java 平台线程与操作系统线程紧密对应。写一个线程很简单,但线程的栈、调度和上下文切换都要消耗系统资源。线程数量一旦上去,系统很可能不是在处理业务,而是在管理线程本身。
- 创建和销毁:平台线程的生命周期需要操作系统参与。它并非不能创建很多,但高频创建、销毁的成本通常不适合直接忽略。
- 内存占用:线程栈大小由 JVM 参数、操作系统和架构共同决定。例如:某些环境设置了
-Xss1m后,每条平台线程的栈上限可能接近 1MB;数万个线程的栈预留就会带来明显压力。具体数值必须以线上 JVM 配置和压测为准。 - 调度成本:操作系统负责平台线程的调度。线程远多于 CPU 核心、并且频繁切换时,保存和恢复上下文、缓存失效等成本会逐渐显现。
- 表达方式:因此,过去的服务通常会用线程池把并发任务排队。这很实用,但线程池既在复用线程,也常常不经意地承担了“限制并发”的职责。
虚拟线程的出发点很朴素:如果一个任务大部分时间都在等结果,那就不该为了这段等待一直占着一条 OS 线程。它让我们可以继续按“一项任务一条线程”的方式组织代码,只是这条线程不再必须从头到尾绑定同一条平台线程。
虚拟线程改变了什么
虚拟线程仍然是
java.lang.Thread,业务代码里依然可以使用熟悉的顺序式写法。变化发生在运行时:JVM 可以把大量虚拟线程映射到较少的平台线程上执行,并在合适的阻塞点把虚拟线程暂时卸载。- 创建成本更低:虚拟线程由 JVM 管理,通常比平台线程更轻,适合为大量独立、短生命周期的任务分别创建线程。
- 栈按需增长:它的初始开销通常显著低于平台线程,但并不是固定“几 KB”。调用栈、局部变量、任务对象和堆内存都会影响实际占用。例如:十万个主要在等待 HTTP 响应的任务,可能适合用十万个虚拟线程表达;如果每个任务都持有大对象,先碰到的仍可能是堆内存上限。
- 等待不必占住载体线程:在 JDK 支持的阻塞操作中,虚拟线程可以被挂起,载体线程转而运行其他任务。对 I/O 密集型服务而言,这往往比“每个等待任务长期占一条平台线程”更划算。
- 并发不等于容量承诺:虚拟线程让高并发的表达成本降低,但堆内存、CPU、数据库连接、文件描述符和下游服务仍然是硬约束。先从可观测、可压测的并发量开始,比把“数百万线程”当作目标更可靠。
所以,虚拟线程带来的核心收益是吞吐和可维护性,而不是单个请求的延迟突然变低。它特别适合“很多任务同时等待”的场景;对持续占用 CPU 的计算任务,换成虚拟线程并不会增加 CPU 核数,也不会让计算线性加速。
虚拟线程是如何运行的
JVM 会用一组平台线程来承载虚拟线程,这些平台线程称为载体线程(carrier threads)。虚拟线程运行时被挂载到某条载体线程上;当它在可卸载的阻塞操作中等待,运行时会把它挂起并卸载。载体线程随即可以去运行别的虚拟线程。等操作就绪后,调度器再把原来的虚拟线程挂载到任一可用载体线程上继续执行。
Java、JDK 与操作系统如何协作处理 HTTP I/O
这里有一个很容易混淆的点:虚拟线程不会自己操作网卡,也不会让另一条 Java 线程持续轮询 HTTP 响应。以
httpClient.send(...) 为例,业务代码向 JDK 发起一次同步调用;JDK 再通过 socket 使用操作系统的网络能力。真正的 TCP 收发、内核缓冲和等待数据到达,都由操作系统内核处理。当虚拟线程在等 HTTP 响应时,JDK 通常会注册“这个 socket 可读或连接完成时通知我”的就绪事件,然后挂起并卸载虚拟线程。原来的载体线程就可以去执行别的任务。响应到达后,操作系统通知 JDK;JDK 再把对应的虚拟线程放回调度队列,在某条可用载体线程上恢复它。
因此,同一段调用从两个视角看会呈现不同状态:对业务代码来说,
send() 依然是同步阻塞的,下一行要等响应回来;对载体线程来说,等待中的虚拟线程不必一直占着它。例如:一万个虚拟线程同时等待下游 HTTP 响应时,真正持续关注网络包的是操作系统的网络与事件机制,不是一万条 OS 线程。它和显式 NIO/Selector 的区别也在这里:使用 NIO 时,应用通常要自己处理“哪个连接已经就绪”;使用虚拟线程时,业务代码仍可以顺序地写
send()、read(),JDK 在底层完成事件注册、挂起和恢复。实现细节会随 JDK 版本和 HTTP 客户端而变化,第三方库仍应结合压测和 JFR 观测判断。理解这条协作链路后,还要避免另一个误解:虚拟线程可以卸载,不代表任何阻塞操作都一定能释放载体线程。
- 虚拟线程在
synchronized代码块或方法中执行阻塞操作时,可能被固定(pinned)在载体线程上。 - 虚拟线程执行 native 方法或 Foreign Function 调用时,不能在阻塞期间卸载。
- 部分 JDK 阻塞操作,例如许多文件系统操作或
Object.wait(),也可能不会卸载虚拟线程。JDK 会尝试临时扩展调度器并行度作补偿,但频繁、长时间的等待仍可能限制伸缩性。 - 第三方库,包括数据库驱动,应依据实际版本、调用路径和观测结果判断,不能只凭“JDBC”三个字下结论。
哪些场景更适合使用虚拟线程
如果一个服务的大部分时间都在等待 I/O,虚拟线程通常值得优先尝试:例如聚合多个下游 HTTP 服务、处理大量独立的消息任务,或者为每个数据库查询保留清晰的同步调用栈。代码不必为了节省线程而提前拆成层层回调,排查问题时也更容易沿着一条调用链看下去。
但它不是通用性能开关。图像编码、加密、复杂规则计算这类 CPU 密集任务,瓶颈主要在 CPU 核数和计算本身。此时更重要的是控制并发度、拆分计算任务,或者使用合适的专用执行策略,而不是创建更多虚拟线程。
两个容易绕进去的问题
用户态线程不等于“完全不需要操作系统”
虚拟线程的调度主要由 JVM 在用户态完成,但它运行 Java 代码、访问网络、读写文件时,依然要借助操作系统提供的线程、socket 和文件系统能力。更准确的说法是:JVM 把大量“等待中的 Java 任务”从稀缺的 OS 线程上卸下来,而不是绕过操作系统。
虚拟线程与 JDBC 连接池如何搭配
虚拟线程可以和 JDBC 连接池正常搭配。连接池仍然负责保护数据库这种稀缺的物理资源:连接不足时,等待连接的虚拟线程可以以较低的线程成本挂起,但请求本身仍在排队。因此,数据库连接数、慢 SQL 和事务时长依然决定端到端吞吐。
例如:连接池只有 20 个连接,而同时有 2,000 个请求需要访问数据库时,虚拟线程能让其余请求轻量地等待;它不会让数据库瞬间同时处理 2,000 条 SQL。实践中应按数据库承载能力设置连接池,并持续优化慢查询和限流策略。
虚拟线程最值得带走的,不是一组夸张的并发数字,而是一种更直接的建模方式:一个任务可以有一条清晰的同步调用链;当它在等待时,系统不必为这份等待支付一条 OS 线程的长期成本。剩下的事情,仍然是用监控、压测和容量规划回答。
资料:JEP 444: Virtual Threads;Oracle JDK 21 Virtual Threads Guide。
附录:阻塞操作与载体线程
下表以 JDK 21 的虚拟线程实现为准。“载体线程被阻塞”指承载该虚拟线程的平台线程和对应 OS 线程在等待期间不能去运行其他虚拟线程;这不等于业务请求已经完成或不再等待。
| 场景或操作 | 通常会卸载虚拟线程、释放载体线程吗? | 载体线程会被阻塞吗? | 示例与处理建议 |
|---|---|---|---|
| 网络阻塞 I/O(未处于 pinning) | 通常会 | 通常不会 | Socket 读写、建立 HTTP/TCP 连接等。例:1 万个虚拟线程等待下游 HTTP 响应时,JVM 可将等待中的虚拟线程卸载,让载体线程继续运行其他任务。 |
| JDK 常见阻塞协作原语(未处于 pinning) | 通常会 | 通常不会 | 例如 BlockingQueue.take()。它适合表达“等待工作到来”;但队列积压、下游速度和内存仍需单独监控。 |
Object.wait() 等部分 JDK 阻塞操作 | 可能不会 | 会 | JDK 21 将这类情况列为不能卸载的实现限制之一;调度器会尝试临时扩充并行度作补偿。它不是 pinning,但高频、长时间等待仍值得在压测中观察。 |
| 许多文件系统操作 | 可能不会 | 会 | 例如对本地或网络文件系统进行可能阻塞的读写,具体行为受操作系统和 JDK 实现影响;JDK 21 对这类无法卸载的操作会尝试补偿载体线程。不要据此假定所有文件 I/O 都“完全无代价”。 |
在 synchronized 块或方法内执行阻塞操作 | 不会(被 pin) | 会 | 例:synchronized(lock) { socket.read(...); }。如果这种 I/O 既频繁又耗时,会占住载体线程;优先缩小同步范围,必要时在这一类长 I/O 临界区使用 ReentrantLock。 |
| 在 native 方法或 Foreign Function 调用期间阻塞 | 不会(被 pin) | 会 | 例:JNI 库调用进入长时间阻塞的系统函数。此类 pinning 不会由调度器通过扩容补偿,应避免让它落在高并发请求的关键路径上。 |
| CPU 密集计算(不是阻塞操作) | 不适用 | 载体线程持续被占用 | 例:图像编码或大规模加密会持续占用 CPU;改成虚拟线程不会增加 CPU 核数,宜通过并发上限或专用执行策略控制。 |
排查建议:不要只凭 API 名称推断。用 JFR 的 jdk.VirtualThreadPinned 事件或 -Djdk.tracePinnedThreads=full 观察真实的 pinning 栈;对于文件系统和第三方库,再结合目标 OS、JDK 版本与压测结果判断。
资料:JEP 444: Virtual Threads;Oracle JDK 21 Virtual Threads Guide。
评论
发表评论