JDK21 虚拟线程原理剖析与性能深度解析
一、背景
JDK21 于 2023 年 9 月 19 日正式发布,带来众多新特性,其中虚拟线程(Virtual Thread)无疑是最受关注的亮点。它的出现,彻底革新了高吞吐量代码的编写范式。
在 IO 密集型程序开发中,仅需对现有代码进行少量修改,就能显著提升程序吞吐量,让我们能轻松实现高并发性能。
二、提升吞吐性能的优化演进
在深入了解虚拟线程之前,我们先来回顾在追求高吞吐性能过程中,常见的优化方案及发展历程。
(一)串行模式
在当下的微服务架构中,处理一次用户或上游请求,往往需要多次调用下游服务、数据库、文件系统等资源,并对获取的数据进行整合处理后,才能将最终结果返回给上游。
采用串行模式查询数据库、调用下游接口或访问文件系统时,完成一次请求的总耗时等于各个下游操作的返回时间之和。这种方式虽然代码逻辑简单,但存在接口响应时间长、性能低下的问题,无法满足 C 端高 QPS 场景下的性能需求。
(二)线程池 + Future 异步调用
为解决串行调用的性能瓶颈,并行异步调用成为首选方案,其中最基础的实现方式便是使用线程池结合 Future 进行并行调用。
通过线程池管理线程资源,利用 Future 获取异步计算结果。然而,这种方式存在明显弊端。当业务场景中存在大量具有前后依赖关系的操作时,线程资源和 CPU 会大量浪费在阻塞等待 Future 结果上,导致资源利用率大幅降低。
(三)线程池 + CompletableFuture 异步调用
为进一步降低 CPU 在阻塞等待上的时间消耗,提升资源利用率,Java 8 引入的 CompletableFuture 被广泛应用于调用流程编排。
相较于原生 Future,CompletableFuture 原生支持通过设置回调方法处理计算结果,并且提供了强大的组合编排操作,有效解决了回调地狱问题,使代码更具可读性。但 CompletableFuture 并未从根本上解决两个核心问题:
-
线程资源浪费:在 IO 密集型服务中,大量线程因等待下游 RPC 调用、数据库查询等 IO 操作而阻塞,导致 CPU 资源利用率低下。
-
线程数量限制:Java 线程模型采用 1:1 映射平台线程,创建成本高昂,无法无限扩展。随着 CPU 调度线程数增加,上下文切换开销增大,宝贵的 CPU 资源被大量消耗在无意义的切换操作中。
三、解决一请求一线程模型的困境
在 Web 应用开发中,一请求一线程模型是最常见的请求处理方式,即每个请求都由单独的线程负责处理。该模型具有易于理解、编码可读性高、调试方便等优点,但也存在明显缺陷。
当线程执行如数据库连接、网络调用等阻塞操作时,线程会被阻塞,直至操作完成,在此期间无法处理其他请求。在大促或突发流量场景下,为保证请求快速响应,有什么?通常会采用以下解决方案:
-
扩大服务最大线程数:该方法简单直接,但受限于系统资源,平台线程数量无法无限制扩充。过多的平台线程会消耗大量系统资源用于上下文切换,且每个平台线程默认开辟约 1MB 的私有栈空间,大量线程会占用巨额内存。
-
垂直扩展与水平扩展:通过升级机器配置(垂直扩展)或增加服务节点(水平扩展)提升服务性能,这是最常见的解决方案,但会显著增加成本,且在某些场景下扩容也无法完全解决性能瓶颈问题。
-
异步 / 响应式编程方案:采用 RPC NIO 异步调用、WebFlux、Rx-Java 等基于事件驱动的非阻塞框架,可通过少量线程实现高吞吐量请求处理,资源利用率高。但此类方案学习成本高,与传统一请求一线程模型编码风格差异大,代码调试困难,且存在兼容性问题。
JDK21 引入的虚拟线程为我们提供了全新的解决方案。它通过低成本的虚拟线程替代昂贵的平台线程进行阻塞操作,当代码执行到如 IO 操作、同步操作、Sleep 等阻塞 API 时,JVM 会自动将虚拟线程从平台线程上卸载,使平台线程能够继续处理其他虚拟线程任务,从而在底层实现用少量平台线程处理大量请求,大幅提升服务吞吐量和 CPU 利用率。
四、虚拟线程技术详解
(一)线程术语定义
- 操作系统线程(OS Thread):由操作系统直接管理,是操作系统进行任务调度的基本单位。
- 平台线程(Platform Thread):Java 中
java.lang.Thread
类的每个实例对应一个平台线程,它是对操作系统线程的封装,与操作系统线程呈 1:1 映射关系。 - 虚拟线程(Virtual Thread):一种轻量级线程,由 JVM 负责管理,对应的实例为
java.lang.VirtualThread
类。 - 载体线程(Carrier Thread):实际执行虚拟线程中任务的平台线程,当虚拟线程装载到某个平台线程上时,该平台线程即成为其载体线程。
(二)虚拟线程定义
平台线程在底层操作系统线程上运行 Java 代码,且在代码生命周期内独占操作系统线程,其数量受限于操作系统线程数量,由系统内核线程调度程序进行调度。
而虚拟线程不与特定操作系统线程绑定,它在平台线程上运行 Java 代码,但不会在整个生命周期内独占平台线程。多个虚拟线程可共享同一个平台线程,且虚拟线程创建成本极低,其数量可远超平台线程数量。
(三)虚拟线程创建
- 直接创建并自动运行
Thread vt = Thread.startVirtualThread(() -> {System.out.println("hello wolrd virtual thread");
});
- 手动控制运行
Thread vt = Thread.ofVirtual().unstarted(() -> {System.out.println("hello wolrd virtual thread");
});
vt.start();
- 通过 ThreadFactory 创建
ThreadFactory tf = Thread.ofVirtual().factory();
Thread vt = tf.newThread(() -> {System.out.println("Start virtual thread...");Thread.sleep(1000);System.out.println("End virtual thread. ");
});
vt.start();
- 使用 Executors 创建
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {System.out.println("Start virtual thread...");Thread.sleep(1000);System.out.println("End virtual thread.");return true;
});
(四)虚拟线程实现原理
虚拟线程由 Java 虚拟机调度,具有占用空间小的特点,通过轻量级任务队列进行调度,避免了基于内核的线程上下文切换开销,因此可大量创建和使用。从实现角度看,虚拟线程可表示为virtual thread = continuation + scheduler + runnable
:
-
Continuation:负责包装任务(
java.lang.Runnable
实例)。当任务需要阻塞挂起时,调用Continuation
的yield
操作,虚拟线程从平台线程卸载;任务解除阻塞继续执行时,调用Continuation.run
从阻塞点恢复执行。 -
Scheduler:作为执行器,继承自
java.util.concurrent.Executor
,负责将任务提交到具体的载体线程池中执行。虚拟线程框架默认提供一个 FIFO(先进先出)的 ForkJoinPool 用于执行虚拟线程任务。 -
Runnable:实际的任务包装器,由 Scheduler 提交到载体线程池中执行。
JVM 将虚拟线程分配给平台线程的过程称为mount(挂载)
,取消分配称为unmount(卸载)
。在没有阻塞的场景下,虚拟线程任务执行流程如下:
- 调度器(线程池)中的平台线程等待处理任务。
- 虚拟线程被分配到平台线程,该平台线程作为载体线程执行虚拟线程任务。
- 虚拟线程运行其
Continuation
,挂载到平台线程后,执行Runnable
包装的用户实际任务。 - 任务执行完成,标记
Continuation
终结,虚拟线程进入终结状态,清空上下文,等待 GC 回收,载体线程返回调度器等待处理下一个任务。
当遇到阻塞场景(如获取锁操作)时,以获取ReentrantLock
锁为例:
ReentrantLock lock = new ReentrantLock();
Thread.startVirtualThread(() -> {lock.lock();
});
// 确保锁已被持有
Thread.sleep(1000);
Thread.startVirtualThread(() -> {System.out.println("first");// 触发Continuation的yield操作lock.lock();try {System.out.println("second");} finally {lock.unlock();}System.out.println("third");
});
Thread.sleep(Long.MAX_VALUE);
此时,虚拟线程执行任务时调用Continuation#run()
,在尝试获取锁时触发阻塞操作,导致Continuation
的yield
操作让出控制权。若yield
操作成功,虚拟线程从载体线程unmount
,载体线程栈数据移动到Continuation
栈数据帧并保存在堆内存中,载体线程返回执行器等待新任务;若yield
操作失败,则对载体线程进行Park
调用,虚拟线程和载体线程同时阻塞(本地方法、Synchronized
修饰的同步方法等会导致yield
失败)。
当锁持有者释放锁后,唤醒虚拟线程重新获取锁。获取锁成功后,虚拟线程重新mount
,可能分配到另一个载体线程执行,Continuation
栈数据帧恢复到载体线程栈,再次调用Continuation#run()
恢复任务执行。任务完成后,虚拟线程和载体线程按正常流程处理。
通过以下代码可直观感受Continuation
的暂停和恢复功能(需添加编译参数--add-exports java.base/jdk.internal.vm=ALL-UNNAMED
):
ContinuationScope scope = new ContinuationScope("scope");
Continuation continuation = new Continuation(scope, () -> {System.out.println("before yield开始");Continuation.yield(scope);System.out.println("after yield 结束");
});
System.out.println("1 run");
// 第一次执行Continuation.run
continuation.run();
System.out.println("2 run");
// 第二次执行Continuation.run
continuation.run();
System.out.println("Done");
执行结果显示,Continuation
实例调用yield
后,再次调用run
方法可从yield
处继续执行,实现程序的中断和恢复。
(五)虚拟线程内存占用评估
线程类型 | 资源占用详情 |
---|---|
平台线程 | 1. 预留 1MB 线程栈空间 2. 线程实例占用 2000+ byte 数据 |
虚拟线程 | 1. Continuation 栈占用数百 byte 到数百 KB 内存,存储在 Java 堆中 2. 线程实例占用 200 - 240 byte 数据 |
通过实际测试程序验证:
// 测试平台线程内存占用
private static final int COUNT = 4000;
public static void main(String[] args) throws Exception {for (int i = 0; i < COUNT; i++) {new Thread(() -> {try {Thread.sleep(Long.MAX_VALUE);} catch (Exception e) {e.printStackTrace();}}, String.valueOf(i)).start();}Thread.sleep(Long.MAX_VALUE);
}
运行后,通过-XX:NativeMemoryTracking=detail
参数和JCMD
命令查看,4000 个左右的平台线程的线程栈空间占用约为使用内存的 96% 以上。
// 测试虚拟线程内存占用
private static final int COUNT = 4000;
public static void main(String[] args) throws Exception {for (int i = 0; i < COUNT; i++) {Thread.startVirtualThread(() -> {try {Thread.sleep(Long.MAX_VALUE);} catch (Exception e) {e.printStackTrace();}});}Thread.sleep(Long.MAX_VALUE);
}
运行 4000 个虚拟线程后,堆内存和总内存实际占用均不超过 300MB,证明虚拟线程在大量创建时不会占用过多内存,且其堆栈可被 GC 回收,进一步降低内存占用。
(六)虚拟线程的局限及使用建议
-
调用
native
方法或外部方法
(Foreign Function & Memory API,jep 424 )时,虚拟线程无法进行yield
操作,会导致载体线程阻塞。 -
在
synchronized
修饰的代码块或方法中,虚拟线程不能进行yield
操作,建议使用ReentrantLock
替代。 -
虽然虚拟线程支持
ThreadLocal
,但由于虚拟线程数量庞大,会导致ThreadLocal
中存储的线程变量过多,频繁触发 GC,影响性能。官方建议尽量少用ThreadLocal
,避免在其中存储大对象,未来计划通过ScopedLocal
替代ThreadLocal
,但 JDK21 尚未正式发布该功能。 -
虚拟线程资源占用极少,可大量创建,无需进行池化操作。池化反而会引入额外开销,应采用即用即创建、用完即销毁的方式。
(七)虚拟线程适用场景
-
大量存在 IO 阻塞等待的任务,如下游 RPC 调用、数据库查询等场景。
-
批量处理执行时间较短的计算任务。
-
基于
Thread-per-request
(一请求一线程)风格的应用程序,如主流的 Tomcat 线程模型、基于该模型实现的 SpringMVC 框架等,只需少量改动即可大幅提升应用吞吐量。
五、虚拟线程压测性能分析
为直观展现虚拟线程的性能优势,我们模拟 Web 容器处理 Http 请求的常见场景,进行性能压测对比。
(一)测试场景
-
场景一:在 Spring Boot 中使用内嵌 Tomcat 处理 Http 请求,采用默认平台线程池作为请求处理线程池。
-
场景二:使用 Spring-WebFlux 创建基于事件循环模型的应用程序,实现响应式请求处理。
-
场景三:在 Spring Boot 中使用内嵌 Tomcat 处理 Http 请求,将 Tomcat 的请求处理线程池替换为虚拟线程池(Tomcat 10 版本已支持虚拟线程)。
(二)测试流程
-
使用 Jmeter 开启 500 个线程并行发起请求,每个线程等待请求响应后再发起下一次请求,单次请求超时时间设为 10s,测试持续 60s。
-
测试的 Web Server 接收 Jmeter 请求,调用慢速服务器获取响应并返回。
-
慢速服务器以随机超时响应,最大响应时间 1000ms,平均响应时间 500ms。
(三)衡量指标
主要关注吞吐量(单位时间内处理的请求数量)和平均响应时间,吞吐量越高、平均响应时间越低,代表性能越好。
(四)测试结果与分析
- Tomcat + 普通线程池
-
默认线程池:Tomcat 默认采用一请求一线程模型,线程池最多包含 200 个线程。由于每个请求会阻塞调用平均 RT 为 500ms 的慢速服务器,预期吞吐量为每秒 400 个请求,实际压测结果为 388 req/sec,接近预期值。
-
增加线程池:在生产环境中,为提升吞吐量,通常会增大线程池大小(如设置
server.tomcat.threads.max=500+
)。调整后,吞吐量随线程数量增加呈比例上升,平均 RT 趋近于慢速服务器的平均响应时间。但需注意,平台线程受内存和 Java 线程映射模型限制,无法无限扩展,过多线程会导致 CPU 资源大量消耗在上下文切换,反而降低整体性能。
-
WebFlux:WebFlux 采用事件循环模型,通过非阻塞 I/O 操作处理多个请求,无需为每个请求分配专用线程。在压测中,使用 WebClient 进行非阻塞 Http 调用,并通过 RouterFunction 进行请求映射和处理。最终压测结果显示,仅用 25 个线程就实现了每秒 964 个请求的吞吐量,请求处理完全无阻塞。
-
Tomcat + 虚拟线程池:虚拟线程内存占用低,可大量创建且在遇到阻塞操作时自动卸载,提升平台线程利用率。使用虚拟线程时,需在启动参数中添加
--enable-preview
,并将 Tomcat 的线程池替换为虚拟线程池。
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandler() {return protocolHandler ->protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}private final RestTemplate restTemplate;
@GetMapping
public ResponseEntity<Object> callSlowServer() {return restTemplate.getForEntity("http://127.0.0.1:8000", Object.class);
}
压测结果显示,虚拟线程实现了与 WebFlux 相同的性能表现,但无需使用复杂的响应式编程技术,对慢速服务器的调用仍采用常规阻塞的 RestTemplate,仅通过替换线程池就达到了显著的性能提升效果。
六、总结
传统服务端开发采用“一请求一线程”模型,但平台线程成本高、数量有限,难以应对高并发场景。为此,业界转向非阻塞I/O和异步编程框架(如WebFlux、RX-Java),通过线程复用提升吞吐量,但带来了响应式编程的复杂性和高学习成本。
虚拟线程的出现改变了这一局面,开发者无需改变编程习惯,仍以同步阻塞的方式编写代码,即可自动获得高并发能力,大幅降低了高并发、高吞吐应用的开发门槛。