一、背景
JDK21 在 9 月 19 号正式发布,带来了较多亮点,其中虚拟线程备受瞩目,毫不夸张的说,它改变了高吞吐代码的编写方式,只需要小小的变动就可以让目前的 IO 密集型程序的吞吐量得到提升,写出高吞吐量的代码不再困难。
本文将详细介绍虚拟线程的使用场景,实现原理以及在 IO 密集型服务下的性能压测效果。
二、为了提升吞吐性能,我们所做的优化
在讲虚拟线程之前,我们先聊聊为了提高吞吐性能,我们所做的一些优化方案。
串行模式
在当前的微服务架构下,处理一次用户/上游的请求,往往需要多次调用下游服务、数据库、文件系统等,再将所有请求的数据进行处理最终的结果返回给上游。
图片
图片
在这种模式下,使用串行模式去查询数据库,下游 Dubbo/Http 接口,文件系统完成一次请求,接口整体的耗时等于各个下游的返回时间之和,这种写法虽然简单,但是接口耗时长、性能差,无法满足 C 端高 QPS 场景下的性能要求。
线程池+Future异步调用
为了解决串行调用的低性能问题,我们会考虑使用并行异步调用的方式,最简单的方式便是使用线程池 +Future 去并行调用。
图片
典型代码如下:
图片
这种方式虽然解决了大部分场景下的串行调用低性能问题,但是也存在着严重的弊端,由于存在 Future 的前后依赖关系,当使用场景存在大量的前后依赖时,会使得线程资源和 CPU 大量浪费在阻塞等待上,导致资源利用率低。
线程池+CompletableFuture异步调用
为了降低 CPU 的阻塞等待时间和提升资源的利用率,我们会使用CompletableFuture对调用流程进行编排,降低依赖之间的阻塞。
CompletableFuture 是由 Java8 引入的,在 Java8 之前一般通过 Future 实现异步。Future 用于表示异步计算的结果,如果存在流程之间的依赖关系,那么只能通过阻塞或者轮询的方式获取结果,同时原生的 Future 不支持设置回调方法,Java8 之前若要设置回调可以使用 Guava 的 ListenableFuture,回调的引入又会导致回调地狱,代码基本不具备可读性。
而 CompletableFuture 是对 Future 的扩展,原生支持通过设置回调的方式处理计算结果,同时也支持组合编排操作,一定程度解决了回调地狱的问题。
使用 CompletableFuture 的实现方式如下:
图片
CompletableFuture 虽然一定程度上面缓解了 CPU 资源大量浪费在阻塞等待上的问题,但是只是缓解,核心的问题始终没有解决。这两个问题导致 CPU 无法充分被利用,系统吞吐量容易达到瓶颈。
线程资源浪费瓶颈始终在 IO 等待上,导致 CPU 资源利用率较低。目前大部分服务是 IO 密集型服务,一次请求的处理耗时大部分都消耗在等待下游 RPC,数据库查询的 IO 等待中,此时线程仍然只能阻塞等待结果返回,导致 CPU 的利用率很低。
线程数量存在限制, 为了增加并发度,我们会给线程池配置更大的线程数,但是线程的数量是有限制的,Java 的线程模型是 1:1 映射平台线程的,导致 Java 线程创建的成本很高,不能无限增加。同时随着 CPU 调度线程数的增加,会导致更严重的资源争用,宝贵的 CPU 资源被损耗在上下文切换上。
三、一请求一线程的模型
在给出最终解决方案之前,我们先聊一聊 Web 应用中常见的一请求一线程的模型。
在 Web 中我们最常见的请求模型就是使用一请求一线程的模型,每个请求都由单独的线程处理。此模型易于理解和实现,对编码的可读性,Debug 都非常友好,但是,它有一些缺点。当线程执行阻塞操作(如连接到数据库或进行网络调用)时,线程会被阻塞,直到操作完成,这意味着线程在此期间将无法处理任何其他请求。
图片
当遇到大促或突发流量等场景导致服务承受的请求数增大时,为了保证每个请求在尽可能短的时间内返回,减少等待时间,我们经常会采用以下方案:
- 扩大服务最大线程数,简单有效,由于存在下列问题,导致平台线程有最大数量限制,不能大量扩充。
系统资源有限导致系统线程总量有限,进而导致与系统线程一一对应的平台线程有限。
平台线程的调度依赖于系统的线程调度程序,当平台线程创建过多,会消耗大量资源用于处理线程上下文切换。
每个平台线程都会开辟一块大小约 1m 私有的栈空间,大量平台线程会占据大量内存。
图片
- 垂直扩展,升级机器配置,水平扩展,增加服务节点,也就是俗称的升配扩容大法,效果好,也是最常见的方案,缺点是会增加成本,同时有些场景下扩容并不能 100% 解决问题。
- 采用异步/响应式编程方案,例如 RPC NIO 异步调用,WebFlux,Rx-Java 等非阻塞的基于 Ractor 模型的框架,使用事件驱动使得少量线程即可实现高吞吐的请求处理,拥有较好的性能与优秀的资源利用,缺点是学习成本较高兼容性问题较大,编码风格与目前的一请求一线程的模型差异较大,理解难度大,同时对于代码的调试比较困难。
那么有没有一种方法可以易于编写,方便迁移,符合日常编码习惯,同时性能很不错,CPU 资源利用率较高的方案呢?
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 代码,并在代码的整个生命周期内独占操作系统线程,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的数量受限于操作系统线程的数量。
而虚拟线程(Virtual Thread)它不与特定的操作系统线程相绑定。它在平台线程上运行 Java 代码,但在代码的整个生命周期内不独占平台线程。这意味着许多虚拟线程可以在同一个平台线程上运行他们的 Java 代码,共享同一个平台线程。同时虚拟线程的成本很低,虚拟线程的数量可以比平台线程的数量大得多。
图片
虚拟线程创建
方法一:直接创建虚拟线程
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("hello wolrd virtual thread");
});
方法二:创建虚拟线程但不自动运行,手动调用start()开始运行
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();
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
虚拟线程会把任务(java.lang.Runnable实例)包装到一个 Continuation 实例中:
- 当任务需要阻塞挂起的时候,会调用 Continuation 的 yield 操作进行阻塞,虚拟线程会从平台线程卸载。
- 当任务解除阻塞继续执行的时候,调用 Continuation.run 会从阻塞点继续执行。
Scheduler 也就是执行器,由它将任务提交到具体的载体线程池中执行。
- 它是 java.util.concurrent.Executor 的子类。
- 虚拟线程框架提供了一个默认的 FIFO 的 ForkJoinPool 用于执行虚拟线程任务。
Runnable 则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行。
JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):
mount 操作:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。
unmount 操作:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。
从 Java 代码的角度来看,其实是看不到虚拟线程及载体线程共享操作系统线程的,会认为虚拟线程及其载体都在同一个线程上运行,因此,在同一虚拟线程上多次调用的代码可能会在每次调用时挂载的载体线程都不一样。JDK 中使用了 FIFO 模式的 ForkJoinPool 作为虚拟线程的调度器,从这个调度器看虚拟线程任务的执行流程大致如下:
- 调度器(线程池)中的平台线程等待处理任务。
图片
- 一个虚拟线程被分配平台线程,该平台线程作为载体线程执行虚拟线程中的任务。
图片
- 虚拟线程运行其 Continuation,Mount(挂载)平台线程后,最终执行 Runnable 包装的用户实际任务。
图片
- 虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文,等待 GC 回收,解除挂载载体线程会返还到调度器(线程池)中等待处理下一个任务。
图片
上面是没有阻塞场景的虚拟线程任务执行情况,如果遇到了阻塞(例如 Lock 等)场景,会触发 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 组件十分重要,它既是用户真实任务的包装器,同时提供了虚拟线程任务暂停/继续的能力,以及虚拟线程与平台线程数据转移功能,当任务需要阻塞挂起的时候,调用 Continuation 的 yield 操作进行阻塞。当任务需要解除阻塞继续执行的时候,则调用 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 的调用之处继续往下执行,从而实现了程序的中断和恢复。
虚拟线程内存占用评估
单个平台线程的资源占用:
- 根据 JVM 规范,预留 1 MB 线程栈空间。
- 平台线程实例,会占据 2000+ byte 数据。
单个虚拟线程的资源占用:
- Continuation 栈会占用数百 byte 到数百 KB 内存空间,是作为堆栈块对象存储在 Java 堆中。
- 虚拟线程实例会占据 200 - 240 byte 数据。
从对比结果来看,理论上单个平台线程占用的内存空间至少是 KB 级别的,而单个虚拟线程实例占用的内存空间是 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);
}
上面的程序运行后启动 4000 平台线程,通过 -XX:NativeMemoryTracking=detail 参数和 JCMD 命令查看所有线程占据的内存空间如下:
图片
内存占用大部分来自创建的平台线程,总线程栈空间占用约为 8096 MB,两者加起来占据总使用内存(8403MB)的 96% 以上。
用类似的方式编写运行虚拟线程的程序:
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);
}
上面的程序运行后启动 4000 虚拟线程:
图片
堆内存的实际占用量和总内存的实际占用量都不超过 300 MB,可以证明虚拟线程在大量创建的前提下也不会去占用过多的内存,且虚拟线程的堆栈是作为堆栈块对象存储在 Java 的堆中的,可以被 GC 回收,又降低了虚拟线程的占用。
虚拟线程的局限及使用建议
- 虚拟线程存在 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。
增加线程池
生产环境为了吞吐考虑,一般不会使用默认值,会把线程池增大到 server.tomcat.threads.max=500+,调整到 500+ 之后的压测结果如下:
可以看出最终的吞吐量和线程数量呈比例上升,同时由于线程数的增加,请求等待减少,平均 RT 趋向于慢速服务器的响应平均 RT。
但是需要注意的是,平台线程的创建受到内存和 Java 线程映射模型的限制,不能无限扩展,同时大量线程会导致 CPU 资源大量消耗在上下文切换时,整体性能反而降低。
WebFlux
WebFlux 跟传统的 Tomcat 线程模型不一样,他不会为每个请求分配一个专用线程,而是使用事件循环模型通过非阻塞 I/O 操作同时处理多个请求,这使得它能够用有限的线程数量处理大量的并发请求。
在压测的场景下,使用 WebClient 来进行一个非阻塞的 Http 调用慢速处理器,并使用 RouterFunction 来做请求映射和处理。
@Bean
public WebClient slowServerClient() {
return WebClient.builder()
.baseUrl("http://127.0.0.1:8000")
.build();
}
@Bean
public RouterFunction<ServerResponse> routes(WebClient slowServerClient) {
return route(GET("/"), (ServerRequest req) -> ok()
.body(
slowServerClient
.get()
.exchangeToFlux(resp -> resp.bodyToFlux(Object.class)),
Object.class
));
}
WebFlux 压测结果如下:
图片
可以看到,WebFlux 的请求完全没有阻塞,仅用了 25 个线程就达到了 964 req/sec 的吞吐。
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<Object> callSlowServer(){
return restTemplate.getForEntity("http://127.0.0.1:8000", Object.class);
}
最终压测结果如下:
图片
可以看到虚拟线程的压测结果实际上与 WebFlux 的情况相同,但我们根本没有使用任何复杂的响应式编程技术。同时对慢速服务器的调用,也使用常规的阻塞 RestTemplate。我们所做的只是用虚拟线程执行器替换线程池就达到更复杂的 Webflux 写法相同的效果。
总的压测结果如下:
通过以上压测结果,我们可以得出以下结论:
- 传统的线程池模式效果差强人意,可以通过提高线程数量可以提升吞吐,但是需要考虑到系统容量和资源限制,但是对于大部分场景来说使用线程池去处理阻塞操作仍然是主流且不错的选择。
- WebFlux 的效果非常好,但是考虑到需要完全按照响应式风格进行开发,成本及难度较大,同时 WebFlux 与现有的一些主流框架存在一些兼容问题,例如 Mysql 官方 IO 库不支持 NIO、Threadlocal 兼容问题等等。现有应用的迁移基本要重写所有代码,改动量和风险都不可控。
- 虚拟线程的效果非常好,最大的优势就是我们没有修改代码或采用任何反应式技术,唯一更改是将线程池替换为虚拟线程。虽然改动较小,但与使用线程池相比,性能结果得到了显著改善。
基于上述的压测结果,可以较为乐观的认为虚拟线程会颠覆我们目前的服务和框架中的请求处理方法。
六、总结
过去很长时间,在编写服务端应用时,我们对于每个请求,都使用独占的线程来处理,请求之间是相互独立的,这就是 一请求一线程的模型这种方式易于理解和编程实现,也易于调试和性能调优。
然而,一请求一线程风格并不能简单地使用平台线程来实现,因为平台线程是操作系统中线程的封装。操作系统的线程会申请成本较高,存在数量上限。对于一个要并发处理海量请求的服务器端应用来说,对每个请求都创建一个平台线程是不现实的。在这种前提下,涌现出一批非阻塞 I/O 和异步编程框架,如 WebFlux ,RX-Java。当某个请求在等待 I/O 操作时,它会暂时让出线程,并在 I/O 操作完成之后继续执行。通过这种方式,可以用少量线程同时处理大量的请求。这些框架可以提升系统的吞吐量,但是要求开发人员必须熟悉所使用的底层框架,并按照响应式的风格来编写代码,响应式框架的调试困难,学习成本,兼容问题使得大部分人望而却步 。
在使用虚拟线程之后,一切都将改变,开发人员可以使用目前最习惯舒服的方式来编写代码,高性能和高吞吐由虚拟线程自动帮你完成,这极大地降低了编写高并发服务应用的难度。