04小节:虚拟线程能取代动态线程池吗?
作者:程序员马丁
热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试 成功!助力你在校招或社招上拿个offer。
虚拟线程能取代动态线程池吗?元数据信息:
- 什么是线程池oneThread:https://t.zsxq.com/5GfrN
- 代码仓库:https://gitcode.net/nageoffer/onethread —— 申请项目权限参考上述线程池项目链接
- 章节难度:★★★☆☆ - 较难
- 视频地址:类似于博客讲解,无
©版权所有 - 拿个offer-开源&项目实战星球专属学习项目,依据《中华人民共和国著作权法实施条例》和《知识星球产权保护》,严禁未经本项目原作者明确书面授权擅自分享至 GitHub、Gitee 等任何开放平台。违者将面临法律追究。
内容摘要:自从 Go 进入大众视野以来,其协程特性带来的并发能力一直备受推崇。相比之下,Java 在这方面一度显得力不从心,几乎被“按在地上摩擦”。所幸,随着 JDK 19 到 21 推出了虚拟线程,Java 在并发领域迎来了重要的技术革新,也为自己扳回了一局。
很多同学可能会疑惑:在 JDK 引入虚拟线程之后,动态线程池是否还有存在的必要?本文将围绕虚拟线程与线程池(动态)展开讨论,分析虚拟线程的优缺点,并探讨线程池(动态)在实际场景中的应用价值。
为确保严谨性,本文章发布时间为 2025-07-06,JDK 最高调研到 21。
课程目录如下所示:
- 背景概要
- 什么是虚拟线程?
- 虚拟线程 vs 动态线程池
- Web 容器如何关联虚拟线程?
- 虚拟线程的缺点
- 常见问题答疑
- 巨人的肩膀
- 文末总结
本文中有不少内容借助了 AI 生成,但整体框架和观点,都是我在查阅几十篇文章、结合自己的实战经验以及各大厂的文章说明后,整理和总结出来的。
背景概要
Java 传统的并发模型是一请求一线程(Thread-per-request),每个 java.lang.Thread
对应一个操作系统线程(平台线程),每个线程占用约 1MB 栈空间。在高并发场景下,线程数受到内存和操作系统线程数量的限制,一旦线程数过多就会耗尽资源,线程创建的成本很高,不能“无限”增加。同时,随着 CPU 调度的线程数增加,会导致更严重的资源争用,宝贵的 CPU 资源被损耗在上下文切换上。
为此,开发者常使用线程池复用线程,来减少创建和销毁的开销。但静态配置的线程池大小难以调优:线程太多会浪费内存,线程太少又会导致吞吐率下降。传统线程池还缺乏运行时的监控和告警机制,线程阻塞时难以定位问题。
为了解决这些问题,出现了动态线程池框架(如 Hippo4j)。它通过运行时动态调整参数、监控告警等手段,来增强线程池的能力。不过,它的底层仍依赖平台线程,性能瓶颈依然存在。
面对这些挑战,Java 21(19 中首次作为预览版本出现)引入了虚拟 线程(Virtual Threads),期望以轻量级的线程实现大规模并发。
一句话总结:为了突破传统平台线程在内存占用、上下文切换以及线程池参数难以评估的瓶颈,Java 引入了虚拟线程,以更轻量的方式实现高并发,解决“一请求一线程”模式下资源消耗过大的问题。
什么是虚拟线程?
1. 基本概念
Java 平台引入的虚拟线程是一种由 JVM 管理的轻量级线程实现。它依然是 java.lang.Thread
的实例,但不再一一对应 OS 线程;当虚拟线程执行阻塞操作(如 I/O、Thread.sleep
等)时,JVM 会自动将其挂起并释放底层的 OS 线程供其他虚拟线程使用。虚拟线程具有以下特性:创建/销毁开销极低、内存占用少(栈空间通常只有几百字节,存放于 Java 堆中);**可支持数百万级别的并发线程;**与现有线程 API 完全兼容,迁移现有代码无需大幅改动。官方文档将其描述为“轻量级线程,可以降低编写高吞吐并发应用的难度”。
虚拟线程的底层实现基于 Project Loom 的“continuation”机制。
Loom 是 OpenJDK 的一个官方项目,旨在在 Java 平台上引入更轻量的并发模型。很多人会把两者混为一谈,但严格来说 Loom → 一个项目,包含虚拟线程、continuations、structured concurrency 等一揽子技术。虚拟线程是 Project Loom 里最核心、最先落地的成果。
简单理解:最早 loom 是单独的项目,实现效果非常哇塞,后面就被直接并购进 JDK 内部实现啦。
Continuation(有限定的协程,可类比 Go 中的协程)可以在执行过程中自行挂起和恢复,类似于将线程的调用栈保存到堆上,当线程阻塞时挂起,待事件完成时从堆中恢复执行。这种设计使得虚拟线程与平台线程不同:平台线程始终占用一个独立的 OS 线程,而虚拟线程则是多对一调度(M:N 模式),大量虚拟线程可以映射到少量 OS 线程上执行。当虚拟线程遇到阻塞时,它被从底层线程上卸载(非阻塞态),释放 OS 线程去执行其它虚拟线程,从而避免了操作系统级的上下文切换开销。此外,虚拟线程仍支持线程局部变量(ThreadLocal
)等特性,以保证兼容性。
图片引用自得物技术文章虚拟线程原理及性能分析。
与平台线程(平常大家使用的线程 Thread)相比,虚拟线程的主要区别包括:
- 调度关系: 平台线程是一种轻包装的 OS 线程,任何时候都会独占一个内核线程;虚拟线程不固定绑定 OS 线程,多对一共享执行。
- 内存占用: 平台线程需要较大的栈(默认约1MB),启动时开销显著;虚拟线程的栈初始只有几百字节且可按需扩展,内存开销极低。因此 JVM 可以同时创建和管理数以百万计的虚拟线程。
- 适用场景: 虚拟线程适合处理大量阻塞型/IO 密集型任务,因为挂起一个虚拟线程不会阻塞底层 OS 线程;而对于CPU 密集型任务,虚拟线程并不会带来速度提升,它与平台线程执行速度相当。
- 兼容性: 虚拟线程完全兼容
Thread
和Executors
API,已有多线程代码几乎无需改动即可运行在虚拟线程上。现有的调试与诊断工具(如jstack
)也可以识别和显示虚拟线程。
虚拟线程并不是传统意义上完全独立的线程,而是由 JDK 的调度器管理,并在底层由线程池(如默认的 ForkJoinPool
)承载执行任务。传统情况下,如果线程在阻塞操作(比如 I/O)时,会一直占用操作系统线程,既消耗内存栈空间,又浪费宝贵的并发资源。而实际上,这段等待的时间完全可以被用来处理其他任务。类似 Netty 的事件驱动模型通过非阻塞 I/O 实现了对资源的高效利用,而虚拟线程则通过挂起和恢复栈帧,让阻塞式编程在高并发场景下重新变得可行,从而应运而生。
2. 简单示例
下面是一个简单示例,展示如何在 JDK21 中创建并启动虚拟线程:
public class TestVirtualThread {
public static void main(String[] args) throws InterruptedException {
// 创建虚拟线程的几种方式
// 方式1: 使用Thread.ofVirtual()
Thread.ofVirtual()
.start(() -> System.out.println("Running in virtual thread: " + Thread.currentThread()));
// 方式2: 使用Executors.newVirtualThreadPerTaskExecutor()
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("Task in virtual thread: " + Thread.currentThread()));
}
// 方式3: 直接启动
Thread.startVirtualThread(() -> {
System.out.println("Simple virtual thread: " + Thread.currentThread());
});
}
}
以上代码中,Thread.ofVirtual()
和 Executors.newVirtualThreadPerTaskExecutor()
会创建虚拟线程来执行任务,调用 join()
或 Future.get()
可以等待任务完成。
可以看输出日志,进一步印证 ForkJoinPool 理论:
Running in virtual thread: VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
Task in virtual thread: VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1
Simple virtual thread: VirtualThread[#26]/runnable@ForkJoinPool-1-worker-1
虚拟线程 vs 动态线程池
1. 创建成本
在创建成本方面,虚拟线程的开销非常低:创建一个虚拟线程仅需少量资源,可以瞬时创建成千上万的线程;而动态线程池使用的仍然是平台线程(OS 线程),启动时需要较大的栈空间和系统调用,开销较高。不过动态线程池在应用启动时通常已经创建好一定数量的线程,并在运行时复用它们,这样后续提交任务时无需反复创建销毁线程,摊薄了创建成本。
2. 调度效率
在调度效率方面,虚拟线程由 JVM 调度。当虚拟线程遇到阻塞操作时,它被挂起并释放底层 OS 线程,让其他虚拟线程继续运行。这意味着在 I/O 密集型场景下,虚拟线程可以显著提高并发度,因为一个线程的阻塞不会影响其他线程运行。而动态线程池的线程属于平台线程,一旦执行阻塞调用,该线程就被占用无法执行其他任务(只能通过增加线程池规模来提升吞吐)。因此虚拟线程在高并发阻塞场景下的上下文切换成本几乎为零,而平台线程池中的线程切换需要依赖操作系统,开销更大。
3. 资源占用
在资源占用方面,虚拟线程单个实例占用内存极少(几 KB 级别的栈),因此即使同时存在上百万个虚拟线程,也只消耗少量内存。平台线程每个通常占用约1MB栈空间,若线程池规模很大,则内存消耗巨大。动态线程池的线程数量可动态调整,但底层仍是平台线程,其每个线程固定内存开销不可避免。此外,动态线程池还维护任务队列和监控数据,这些也占用一定内存和 CPU 资源。
4. 使用复杂度
在使用复杂度方面,虚拟线程的编程模型与传统线程基本相同,开发者只需将 Thread.ofVirtual()
或 newVirtualThreadPerTaskExecutor()
替换原有线程池/线程创建代码即可,无需额外库或复杂配置。而使用动态线程池则需引入第三方框架(如 Hippo4j、oneThread 等)并进行配置中心或服务部署,同时编写配置文件或代码来管理线程池参数,学习成本和运维成本较高。
虚拟线程在 JDK21+ 环境下可直接使用,旧版本(JDK 19/20)则需要开启预览特性;动态线程池对 JDK 版本没有特殊要求,但需要额外的框架依赖和运行环境。
5. 调试与观测性
在调试与可观测性方面,虚拟线程可以借助现有的 Java 调试工具进行跟踪。例如,jstack
或 IDE 调试都能看到虚拟线程信息。由于虚拟线程不使用统一的任务队列,无法像线程池那样监控队列长度或活跃线程数等指标;但可以通过第三方埋点或 JVM 提供的线程 MXBean 查询当前活跃虚拟线程数。相比之下,动态线程池(以 Hippo4j 为例)提供了可视化控制台和报警系统,可实时查看每个线程池的运行状态、线程数、任务队列长度等信息。Hippo4j 还支持将线程池运行时数据推送到 Prometheus、InfluxDB 等监控系统,便于建立告警和趋势分析。总的来说,动态线程池在可观测性和运维支持方面更为丰富,而虚拟线程则偏向于简化开发、提升吞吐。
Web 容器如何关联虚拟线程?
1. Tomcat 如何启动虚拟线程?
网上很多举例都是错的,包括很多 AI 也是在胡言乱语。
我是通过 JDK21 和 SpringBoot 3.2.0 尝试,加上下述配置,将 SpringBoot Tomcat 虚拟线程启动成功。
spring:
threads:
virtual:
enabled: true
可以创建一个简单的 Controller,在其中打印当前线程的信息。虚拟线程的名称和类型与传统的平台线程有明显区别。
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ThreadInfoController {
private static final Logger log = LoggerFactory.getLogger(ThreadInfoController.class);
@GetMapping("/hello-virtual")
public void getThreadInfo() {
// Thread.currentThread() 会返回当前线程的引用
Thread currentThread = Thread.currentThread();
String threadInfo = "Response from thread: " + currentThread;
// isVirtual() 方法可以明确判断是否为虚拟线程
boolean isVirtual = currentThread.isVirtual();
log.info(threadInfo);
log.info("Is this a virtual thread? {}", isVirtual);
}
}
控制台看到类似以下的日志:
2025-07-06T16:13:47.190+08:00 INFO 18047 --- [omcat-handler-0] c.n.t.v.ThreadInfoController : Response from thread: VirtualThread[#53,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1
2025-07-06T16:13:47.191+08:00 INFO 18047 --- [omcat-handler-0] c.n.t.v.ThreadInfoController : Is this a virtual thread? true
线程名以 VirtualThread
开头,并且 isVirtual()
返回 true
。
2. 虚拟线程和工作线程关系
在 SpringBoot 3.2.0 中,没有较多说明如果配置了虚拟线程,默认 Web 容器的工作线程数量是否还有效?比如下面这个配置:
server:
tomcat:
threads:
max: 200
min-spare: 10
后续我又把 SpringBoot 版本升级到了 3.5.0,配置属性说明中增加了描述:
{
"name": "server.tomcat.threads.max",
"type": "java.lang.Integer",
"description": "Maximum amount of worker threads. Doesn't have an effect if virtual threads are enabled.",
"sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat$Threads",
"defaultValue": 200
}
{
"name": "server.tomcat.threads.min-spare",
"type": "java.lang.Integer",
"description": "Minimum amount of worker threads. Doesn't have an effect if virtual threads are enabled.",
"sourceType": "org.springframework.boot.autoconfigure.web.ServerProperties$Tomcat$Threads",
"defaultValue": 10
}
大致意思是:一旦启用了虚拟线程,Tomcat 的这两个线程相关参数就会失效。
不过虽然目前已经对虚拟线程的机制有了初步认识,但仍存在一些疑问 ,比如虚拟线程毕竟运行在平台线程之上,默认情况下 Web 容器虚拟线程和业务代码中的虚拟线程是否可能混用平台线程?这一点目前还没有深入验证,后续可以再细看相关实现。
3. Web 容器配置规范思考
其实这里我还有点小疑惑:为什么 Tomcat、Jetty 等 Web 容器的虚拟线程开关,要放在 spring
前缀的配置里?官方文档里暂时没看到特别明确的解释。
我能想到的可能原因,是 SpringBoot 想做 统一管理、简化配置、保持一致性。毕竟 SpringBoot 一直主张“约定优于配置”,把虚拟线程当作一种应用层的能力,而不仅仅是容器里的底层细节,也就不奇怪了。
不过因为还得忙别的资料,我还没仔细去研究这块源码。有兴趣的小伙伴可以去挖一挖,说不定能找到更深入的答案。