JDK21 在 9 月 19 号正式发布,带来了较多亮点,其中虚拟线程备受瞩目,毫不夸张的说,它改变了高吞吐代码的编写方式,只需要小小的变动就可以让目前的 IO 密集型程序的吞吐量得到提升,写出高吞吐量的代码不再困难。
本文将详细介绍虚拟线程的使用场景,实现原理以及在 IO 密集型服务下的性能压测效果。
在讲虚拟线程之前,我们先聊聊为了提高吞吐性能,我们所做的一些优化方案。
在当前的微服务架构下,处理一次用户/上游的请求,往往需要多次调用下游服务、数据库、文件系统等,再将所有请求的数据进行处理最终的结果返回给上游。
图片
图片
在这种模式下,使用串行模式去查询数据库,下游 Dubbo/Http 接口,文件系统完成一次请求,接口整体的耗时等于各个下游的返回时间之和,这种写法虽然简单,但是接口耗时长、性能差,无法满足 C 端高 QPS 场景下的性能要求。
为了解决串行调用的低性能问题,我们会考虑使用并行异步调用的方式,最简单的方式便是使用线程池 +Future 去并行调用。
图片
典型代码如下:
图片
这种方式虽然解决了大部分场景下的串行调用低性能问题,但是也存在着严重的弊端,由于存在 Future 的前后依赖关系,当使用场景存在大量的前后依赖时,会使得线程资源和 CPU 大量浪费在阻塞等待上,导致资源利用率低。
为了降低 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 私有的栈空间,大量平台线程会占据大量内存。
图片
那么有没有一种方法可以易于编写,方便迁移,符合日常编码习惯,同时性能很不错,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 实例中:
Scheduler 也就是执行器,由它将任务提交到具体的载体线程池中执行。
Runnable 则是真正的任务包装器,由 Scheduler 负责提交到载体线程池中执行。
JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):
mount 操作:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。
unmount 操作:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。
从 Java 代码的角度来看,其实是看不到虚拟线程及载体线程共享操作系统线程的,会认为虚拟线程及其载体都在同一个线程上运行,因此,在同一虚拟线程上多次调用的代码可能会在每次调用时挂载的载体线程都不一样。JDK 中使用了 FIFO 模式的 ForkJoinPool 作为虚拟线程的调度器,从这个调度器看虚拟线程任务的执行流程大致如下:
图片
图片
图片
图片
上面是没有阻塞场景的虚拟线程任务执行情况,如果遇到了阻塞(例如 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 组件十分重要,它既是用户真实任务的包装器,同时提供了虚拟线程任务暂停/继续的能力,以及虚拟线程与平台线程数据转移功能,当任务需要阻塞挂起的时候,调用 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 的调用之处继续往下执行,从而实现了程序的中断和恢复。
虚拟线程内存占用评估
单个虚拟线程的资源占用:
从对比结果来看,理论上单个平台线程占用的内存空间至少是 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 回收,又降低了虚拟线程的占用。
虚拟线程的局限及使用建议
在下面的测试中,我们将模拟最常使用的场景-使用 Web 容器去处理 Http 请求。
场景一:在 Spring Boot 中使用内嵌的 Tomcat 去处理 Http 请求,使用默认的平台线程池作为 Tomcat 的请求处理线程池。
场景二:使用 Spring -WebFlux 创建基于事件循环模型的应用程序,进行响应式请求处理。
场景三:在 Spring Boot 中使用内嵌的 Tomcat 去处理 Http 请求,使用虚拟线程池作为 Tomcat 的请求处理线程池 (Tomcat已支持虚拟线程)。
图片
默认情况下,Tomcat 使用一请求一线程模型处理请求,当 Tomcat 收到请求时,会从线程池中取一个线程去处理请求,该分配的线程将一直保持占用状态,直到请求结束才会释放。当线程池中没有线程时,请求会一直阻塞在队列中,直到有请求结束释放线程。默认队列长度为 Integer.MAX。默认线程池默认情况下,线程池最多包含 200 个线程。这基本上意味着单个时间点最多处理 200 个请求。对于每个请求服务都会以阻塞的方式调用平均 RT500ms 的慢速服务器。因此,可以预期每秒 400 个请求的吞吐量,最终压测结果非常接近预期值,为 388 req/sec。
增加线程池
生产环境为了吞吐考虑,一般不会使用默认值,会把线程池增大到 server.tomcat.threads.max=500+,调整到 500+ 之后的压测结果如下:
可以看出最终的吞吐量和线程数量呈比例上升,同时由于线程数的增加,请求等待减少,平均 RT 趋向于慢速服务器的响应平均 RT。
但是需要注意的是,平台线程的创建受到内存和 Java 线程映射模型的限制,不能无限扩展,同时大量线程会导致 CPU 资源大量消耗在上下文切换时,整体性能反而降低。
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 的吞吐。
与平台线程相比,虚拟线程的内存占用量要低得多,运行程序大量的创建虚拟线程,而不会耗尽系统资源;同时当遇到 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 写法相同的效果。
总的压测结果如下:
通过以上压测结果,我们可以得出以下结论:
基于上述的压测结果,可以较为乐观的认为虚拟线程会颠覆我们目前的服务和框架中的请求处理方法。
过去很长时间,在编写服务端应用时,我们对于每个请求,都使用独占的线程来处理,请求之间是相互独立的,这就是 一请求一线程的模型这种方式易于理解和编程实现,也易于调试和性能调优。
然而,一请求一线程风格并不能简单地使用平台线程来实现,因为平台线程是操作系统中线程的封装。操作系统的线程会申请成本较高,存在数量上限。对于一个要并发处理海量请求的服务器端应用来说,对每个请求都创建一个平台线程是不现实的。在这种前提下,涌现出一批非阻塞 I/O 和异步编程框架,如 WebFlux ,RX-Java。当某个请求在等待 I/O 操作时,它会暂时让出线程,并在 I/O 操作完成之后继续执行。通过这种方式,可以用少量线程同时处理大量的请求。这些框架可以提升系统的吞吐量,但是要求开发人员必须熟悉所使用的底层框架,并按照响应式的风格来编写代码,响应式框架的调试困难,学习成本,兼容问题使得大部分人望而却步 。
在使用虚拟线程之后,一切都将改变,开发人员可以使用目前最习惯舒服的方式来编写代码,高性能和高吞吐由虚拟线程自动帮你完成,这极大地降低了编写高并发服务应用的难度。