Skip to main content

04小节:虚拟线程能取代动态线程池吗?

作者:程序员马丁

在线博客:https://nageoffer.com

note

热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。

虚拟线程能取代动态线程池吗?元数据信息:

©版权所有 - 拿个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”机制。

LoomOpenJDK 的一个官方项目,旨在在 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 密集型任务,虚拟线程并不会带来速度提升,它与平台线程执行速度相当。
  • 兼容性: 虚拟线程完全兼容 ThreadExecutors 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 一直主张“约定优于配置”,把虚拟线程当作一种应用层的能力,而不仅仅是容器里的底层细节,也就不奇怪了。

不过因为还得忙别的资料,我还没仔细去研究这块源码。有兴趣的小伙伴可以去挖一挖,说不定能找到更深入的答案。

解锁付费内容,👉 戳