一、背景
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 从平台线程上卸载,平台线程就会去处理下一个虚拟线程,通过这种方式,提升了平台线程的利用率,让平台线

最低0.47元/天 解锁文章
604

被折叠的 条评论
为什么被折叠?



