来源:得物技术
目录
一、背景
二、为了提升吞吐性能,我们所做的优化
1. 串行模式
2. 线程池 +Future 异步调用
3. 线程池 +CompletableFuture 异步调用
三、一请求一线程的模型
四、虚拟线程
1. 线程术语定义
2. 虚拟线程定义
3. 虚拟线程创建
4. 虚拟线程实现原理
5. 虚拟线程内存占用评估
6. 虚拟线程的局限及使用建议
7. 虚拟线程适用场景
五、虚拟线程压测性能分析
1. 测试流程
2. 衡量指标
3. Tomcat+普通线程池
4. WebFlux
5. Tomcat+虚拟线程池
六、总结
一
背景
JDK21 在 9 月 19 号正式发布,带来了较多亮点,其中虚拟线程备受瞩目,毫不夸张的说,它改变了高吞吐代码的编写方式,只需要小小的变动就可以让目前的 IO 密集型程序的吞吐量得到提升,写出高吞吐量的代码不再困难。
二
为了提升吞吐性能,我们所做的优化
在讲虚拟线程之前,我们先聊聊为了提高吞吐性能,我们所做的一些优化方案。
串行模式
在当前的微服务架构下,处理一次用户/上游的请求,往往需要多次调用下游服务、数据库、文件系统等,再将所有请求的数据进行处理最终的结果返回给上游。


线程池+Future异步调用
为了解决串行调用的低性能问题,我们会考虑使用并行异步调用的方式,最简单的方式便是使用线程池 +Future 去并行调用。


线程池+CompletableFuture异步调用
为了降低 CPU 的阻塞等待时间和提升资源的利用率,我们会使用CompletableFuture
对调用流程进行编排,降低依赖之间的阻塞。

线程资源浪费瓶颈始终在 IO 等待上
,导致 CPU 资源利用率较低。目前大部分服务是 IO 密集型服务,一次请求的处理耗时大部分都消耗在等待下游 RPC,数据库查询的 IO 等待中,此时线程仍然只能阻塞等待结果返回,导致 CPU 的利用率很低。线程数量存在限制, 为了增加并发度,我们会给线程池配置更大的线程数,但是线程的数量是有限制的,Java 的线程模型是 1:1 映射平台线程的,导致 Java 线程创建的成本很高,不能无限增加。同时随着 CPU 调度线程数的增加,会导致更严重的资源争用,宝贵的 CPU 资源被损耗在上下文切换上。
三
一请求一线程的模型
在给出最终解决方案之前,我们先聊一聊 Web 应用中常见的一请求一线程的模型。

扩大服务最大线程数
,简单有效,由于存在下列问题,导致平台线程有最大数量限制,不能大量扩充。系统资源有限导致系统线程总量有限,进而导致与系统线程一一对应的平台线程有限。 平台线程的调度依赖于系统的线程调度程序,当平台线程创建过多,会消耗大量资源用于处理线程上下文切换。 每个平台线程都会开辟一块大小约 1m 私有的栈空间,大量平台线程会占据大量内存。

垂直扩展,升级机器配置,水平扩展,增加服务节点
,也就是俗称的升配扩容大法,效果好,也是最常见的方案,缺点是会增加成本,同时有些场景下扩容并不能 100% 解决问题。采用异步/响应式编程方案
,例如 RPC NIO 异步调用,WebFlux,Rx-Java 等非阻塞的基于 Ractor 模型的框架,使用事件驱动使得少量线程即可实现高吞吐的请求处理,拥有较好的性能与优秀的资源利用,缺点是学习成本较高兼容性问题较大,编码风格与目前的一请求一线程的模型差异较大,理解难度大,同时对于代码的调试比较困难。
JDK21 中的虚拟线程可能给出了答案
, JDK 提供了与 Thread 完全一致的抽象 Virtual Thread
来应对这种经常阻塞的情况,阻塞仍然是会阻塞,但是换了阻塞的对象,由昂贵的平台线程阻塞改为了成本很低的虚拟线程的阻塞,当代码调用到阻塞 API 例如 IO,同步,Sleep 等操作时,JVM 会自动把 Virtual Thread 从平台线程上卸载
,平台线程就会去处理下一个虚拟线程,通过这种方式,提升了平台线程的利用率,让平台线程不再阻塞在等待上,从底层实现了少量平台线程就可以处理大量请求,提高了服务吞吐和 CPU 的利用率
。四
虚拟线程
线程术语定义
操作系统线程(OS Thread)
:由操作系统管理,是操作系统调度的基本单位。
平台线程(Platform Thread)
:Java.Lang.Thread 类的每个实例,都是一个平台线程,是 Java 对操作系统线程的包装,与操作系统是 1:1 映射。虚拟线程(Virtual Thread)
:一种轻量级,由 JVM 管理的线程。对应的实例 java.lang.VirtualThread 这个类。载体线程(Carrier Thread)
:指真正负责执行虚拟线程中任务的平台线程。一个虚拟线程装载到一个平台线程之后,那么这个平台线程就被称为虚拟线程的载体线程。虚拟线程定义
JDK 中 java.lang.Thread 的每个实例都是一个平台线程
。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内独占操作系统线程,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的数量受限于操作系统线程的数量。

虚拟线程创建
方法一:直接创建虚拟线程
Thread vt = Thread.startVirtualThread(() -> { System.out.println("hello wolrd virtual thread");});
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.newVirtualThreadPer
-TaskExecutor()
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
实例中:当任务需要阻塞挂起的时候,会调用 Continuation 的 yield
操作进行阻塞,虚拟线程会从平台线程卸载。当任务解除阻塞继续执行的时候,调用 Continuation.run
会从阻塞点继续执行。
Scheduler
也就是执行器,由它将任务提交到具体的载体线程池中执行。它是 java.util.concurrent.Executor 的子类。 虚拟线程框架提供了一个默认的 FIFO 的 ForkJoinPool 用于执行虚拟线程任务。
Runnable
则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行。mount(挂载)
,取消分配平台线程的操作称为 unmount(卸载
)
:mount 操作
:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。unmount 操作
:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。调度器(线程池)中的平台线程等待处理任务。

一个虚拟线程被分配平台线程,该平台线程作为载体线程执行虚拟线程中的任务。

虚拟线程运行其 Continuation,Mount(挂载)平台线程后,最终执行 Runnable 包装的用户实际任务。

虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文,等待 GC 回收,解除挂载载体线程会返还到调度器(线程池)中等待处理下一个任务。

Continuation
的 yield
操作让出控制权,等待虚拟线程重新分配载体线程并且执行,具体见下面的代码: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 栈的数据帧中,保存在堆内存中,虚拟线程任务完成,此时虚拟线程和 Continuation 还没有终结和释放,载体线程被释放到执行器中等待新的任务;如果 Continuation 的 yield 操作失败,则会对载体线程进行 Park 调用,阻塞在载体线程上,此时虚拟线程和载体线程同时会被阻塞,本地方法,Synchronized 修饰的同步方法都会导致 yield 失败。

当锁持有者释放锁之后,会唤醒虚拟线程获取锁,获取锁成功后,虚拟线程会重新进行 mount,让虚拟线程任务再次执行,此时有可能是分配到另一个载体线程中执行,Continuation 栈会的数据帧会被恢复到载体线程栈中,然后再次调用 Continuation#run()
恢复任务执行。

虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文变量,解除载体线程的挂载载体线程返还到调度器(线程池)中作为平台线程等待处理下一个任务。
Continuation
的 yield
操作进行阻塞。当任务需要解除阻塞继续执行的时候,则调用 Continuation
的 run
恢复执行。--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.runcontinuation.run();System.out.println("2 run");// 第二次执行Continuation.runcontinuation.run();System.out.println("Done");

Continuation
实例进行 yield
调用后,再次调用其 run
方法就可以从 yield
的调用之处继续往下执行
,从而实现了程序的中断和恢复。虚拟线程内存占用评估
单个平台线程的资源占用:
根据 JVM 规范,预留 1 MB 线程栈空间。 平台线程实例,会占据 2000+ byte 数据。
Continuation 栈会占用数百 byte 到数百 KB 内存空间,是作为堆栈块对象存储在 Java 堆中。 虚拟线程实例会占据 200 - 240 byte 数据。
private static final int COUNT = 4000;
/**
* -XX:NativeMemoryTracking=detail
*
* @param args args
*/
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);
}

private static final int COUNT = 4000;
/**
* -XX:NativeMemoryTracking=detail
*
* @param args args
*/
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);
}

虚拟线程的局限及使用建议
虚拟线程存在 native
方法或者外部方法
(Foreign Function & Memory API,jep 424 ) 调用不能进行yield
操作,此时载体线程会被阻塞。当运行在 synchronized
修饰的代码块或者方法时,不能进行yield
操作,此时载体线程会被阻塞,推荐使用 ReentrantLock。ThreadLocal 相关问题,目前虚拟线程仍然是支持 ThreadLocal 的,但是由于虚拟线程的数量非常多,会导致 Threadlocal 中存的线程变量非常多,需要频繁 GC 去清理,对性能会有影响,官方建议尽量少使用 ThreadLocal,同时不要在虚拟线程的 ThreadLocal 中放大对象,目前官方是想通过 ScopedLocal 去替换掉 ThreadLocal,但是在 21 版本还没有正式发布,这个可能是大规模使用虚拟线程的一大难题。 无需池化虚拟线程 虚拟线程占用的资源很少,因此可以大量地创建而无须考虑池化,它不需要跟平台线程池一样,平台线程的创建成本比较昂贵,所以通常选择去池化,去做共享,但是池化操作本身会引入额外开销,对于虚拟线程池化反而是得不偿失,使用虚拟线程我们抛弃池化的思维,用时创建,用完就扔。
虚拟线程适用场景
大量的 IO 阻塞等待任务,例如下游 RPC 调用,DB 查询等。 大批量的处理时间较短的计算任务。 Thread-per-request (一请求一线程)风格的应用程序,例如主流的 Tomcat 线程模型或者基于类似线程模型实现的 SpringMVC 框架 ,这些应用只需要小小的改动就可以带来巨大的吞吐提升。
五
虚拟线程压测性能分析
在下面的测试中,我们将模拟最常使用的场景-使用 Web 容器去处理 Http 请求。
场景一:
在 Spring Boot 中使用内嵌的 Tomcat 去处理 Http 请求,使用默认的平台线程池作为 Tomcat 的请求处理线程池。场景二:
使用 Spring -WebFlux 创建基于事件循环模型的应用程序,进行响应式请求处理。场景三:
在 Spring Boot 中使用内嵌的 Tomcat 去处理 Http 请求,使用虚拟线程池作为 Tomcat 的请求处理线程池 (Tomcat已支持虚拟线程)。测试流程
Jmeter 开启 500 个线程去并行发起请求。每个线程将等待请求响应后再发起下一次请求,单次请求超时时间为 10s,测试时间持续 60s。 测试的 Web Server 将接受 Jmeter 的请求,并调用慢速服务器获取响应并返回。 慢速服务器以随机超时响应。最大响应时间为 1000ms。平均响应时间为 500ms。

衡量指标
吞吐量和平均响应时间,吞吐量越高,平均响应时间越低,性能就越好。
Tomcat+普通线程池
默认情况下,Tomcat 使用一请求一线程模型处理请求,当 Tomcat 收到请求时,会从线程池中取一个线程去处理请求,该分配的线程将一直保持占用状态,直到请求结束才会释放。当线程池中没有线程时,请求会一直阻塞在队列中,直到有请求结束释放线程。默认队列长度为 Integer.MAX。
默认线程池
默认情况下,线程池最多包含 200 个线程。这基本上意味着单个时间点最多处理 200 个请求。对于每个请求服务都会以阻塞的方式调用平均 RT500ms 的慢速服务器。因此,可以预期每秒 400 个请求的吞吐量,最终压测结果非常接近预期值,为 388 req/sec。

WebFlux
WebFlux 跟传统的 Tomcat 线程模型不一样,他不会为每个请求分配一个专用线程,而是使用事件循环模型通过非阻塞 I/O 操作同时处理多个请求,这使得它能够用有限的线程数量处理大量的并发请求。
@Bean
public WebClient slowServerClient() {
return WebClient.builder()
.baseUrl("http://127.0.0.1:8000")
.build();
}
@Bean
public RouterFunction
routes(WebClient slowServerClient) { return route(GET("/"), (ServerRequest req) -> ok()
.body(
slowServerClient
.get()
.exchangeToFlux(resp -> resp.bodyToFlux(Object.class)),
Object.class
));
}
Tomcat+虚拟线程池
与平台线程相比,虚拟线程的内存占用量要低得多,运行程序大量的创建虚拟线程,而不会耗尽系统资源;同时当遇到 Thread.sleep(),CompletableFuture.await(),等待 I/O,获取锁时,虚拟线程会自动卸载,JVM 可以自动切换到另外的等待就绪的虚拟线程,提升单个平台线程的利用率,保证平台线程不会浪费在无意义的阻塞等待上。
--enable-preview
,同时 Tomcat 在 10 版本已支持虚拟线程,我们只需要替换 Tomcat 的平台线程池为虚拟线程池即可。
@Bean
public TomcatProtocolHandlerCustomizer> protocolHandler() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
private final RestTemplate restTemplate;
@GetMapping
public ResponseEntity
return restTemplate.getForEntity("http://127.0.0.1:8000", Object.class);
}
传统的线程池模式效果差强人意,可以通过提高线程数量可以提升吞吐,但是需要考虑到系统容量和资源限制,但是对于大部分场景来说使用线程池去处理阻塞操作仍然是主流且不错的选择。 WebFlux 的效果非常好,但是考虑到需要完全按照响应式风格进行开发,成本及难度较大,同时 WebFlux 与现有的一些主流框架存在一些兼容问题,例如 Mysql 官方 IO 库不支持 NIO、Threadlocal 兼容问题等等。现有应用的迁移基本要重写所有代码,改动量和风险都不可控。 虚拟线程的效果非常好, 最大的优势就是我们没有修改代码或采用任何反应式技术,唯一更改是将线程池替换为虚拟线程
。虽然改动较小,但与使用线程池相比,性能结果得到了显著改善。
六
总结
过去很长时间,在编写服务端应用时,我们对于每个请求,都使用独占的线程来处理,请求之间是相互独立的,这就是 一请求一线程的模型
这种方式易于理解和编程实现,也易于调试和性能调优。
然而,一请求一线程风格并不能简单地使用平台线程来实现,因为平台线程是操作系统中线程的封装。操作系统的线程会申请成本较高,存在数量上限。对于一个要并发处理海量请求的服务器端应用来说,对每个请求都创建一个平台线程是不现实的。在这种前提下,涌现出一批非阻塞 I/O 和异步编程框架,如 WebFlux ,RX-Java。当某个请求在等待 I/O 操作时,它会暂时让出线程,并在 I/O 操作完成之后继续执行。通过这种方式,可以用少量线程同时处理大量的请求。这些框架可以提升系统的吞吐量,但是要求开发人员必须熟悉所使用的底层框架,并按照响应式的风格来编写代码,响应式框架的调试困难,学习成本,兼容问题使得大部分人望而却步 。
在使用虚拟线程之后,一切都将改变,开发人员可以使用目前最习惯舒服的方式来编写代码,高性能和高吞吐由虚拟线程自动帮你完成,这极大地降低了编写高并发服务应用的难度。
参考文档
https://openjdk.org/jeps/444
https://zhuanlan.zhihu.com/p/514719325
https://www.vlts.cn/post/virtual-thread-source-code#%E5%89%8D%E6%8F%90
https://zhuanlan.zhihu.com/p/499342616