一、背景

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 代码,同享同一个渠道线程。** 一起虚拟线程的本钱很低,虚拟线程的数量能够比渠道线程的数量大得多。

虚拟线程原理及功能剖析

虚拟线程创立

办法一:直接创立虚拟线程

虚拟线程原理及功能剖析

办法二:创立虚拟线程但不主动运转,手动调用start()开端运转

虚拟线程原理及功能剖析

办法三:经过虚拟线程的 ThreadFactory 创立虚拟线程

虚拟线程原理及功能剖析

办法四:Executors.newVirtualThreadPer-TaskExecutor()

虚拟线程原理及功能剖析

虚拟线程完结原理

虚拟线程是由 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 操作让出控制权,等候虚拟线程重新分配载体线程而且履行,详细见下面的代码:

虚拟线程原理及功能剖析

虚拟线程中使命履行时分调用 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 能够在本地运转。

虚拟线程原理及功能剖析
虚拟线程原理及功能剖析
经过上述案例能够看出,Continuation 实例进行 yield 调用后,再次调用其 run 办法就能够从 yield 的调用之处持续往下履行,然后完结了程序的中断和康复。

虚拟线程内存占用评估

单个渠道线程的资源占用:

  • 根据 JVM 标准,预留 1 MB 线程栈空间。
  • 渠道线程实例,会占有 2000 byte 数据。

单个虚拟线程的资源占用:

  • Continuation 栈会占用数百 byte 到数百 KB 内存空间,是作为仓库块目标存储在 Java 堆中。
  • 虚拟线程实例会占有 200 – 240 byte 数据。

从对比成果来看,理论上单个渠道线程占用的内存空间至少是 KB 等级的,而单个虚拟线程实例占用的内存空间是 byte 等级,两者的内存占用差距较大,这也是虚拟线程能够大批量创立的原因。

下面经过一段程序去测验渠道线程和虚拟线程的内存占用:

虚拟线程原理及功能剖析

上面的程序运转后启动 4000 渠道线程,经过 -XX:NativeMemoryTracking=detail 参数和 JCMD 命令查看所有线程占有的内存空间如下:

虚拟线程原理及功能剖析
内存占用大部分来自创立的渠道线程,总线程栈空间占用约为 8096 MB,两者加起来占有总运用内存(8403MB)的 96% 以上。

用相似的办法编写运转虚拟线程的程序:

虚拟线程原理及功能剖析

上面的程序运转后启动 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 来做恳求映射和处理。

虚拟线程原理及功能剖析

WebFlux 压测成果如下:

虚拟线程原理及功能剖析

能够看到,WebFlux 的恳求完全没有堵塞,仅用了 25 个线程就达到了 964 req/sec 的吞吐。

Tomcat 虚拟线程池

与渠道线程相比,虚拟线程的内存占用量要低得多,运转程序很多的创立虚拟线程,而不会耗尽体系资源;一起当遇到 Thread.sleep(),CompletableFuture.await(),等候 I/O,获取锁时,虚拟线程会主动卸载,JVM 能够主动切换到另外的等候就绪的虚拟线程,提高单个渠道线程的运用率,确保渠道线程不会糟蹋在无意义的堵塞等候上。

要想运用虚拟线程,需求先在启动参数中加上 –enable-preview,一起 Tomcat 在 10 版本已支撑虚拟线程,咱们只需求替换 Tomcat 的渠道线程池为虚拟线程池即可。

虚拟线程原理及功能剖析

终究压测成果如下:

虚拟线程原理及功能剖析

能够看到虚拟线程的压测成果实践上与 WebFlux 的情况相同,但咱们根本没有运用任何复杂的呼应式编程技能。一起对慢速服务器的调用,也运用惯例的堵塞 RestTemplate。咱们所做的只是用虚拟线程履行器替换线程池就达到更复杂的 Webflux 写法相同的作用。

总的压测成果如下:

虚拟线程原理及功能剖析

经过以上压测成果,咱们能够得出以下结论:

  • 传统的线程池形式作用差强人意,能够经过进步线程数量能够提高吞吐,可是需求考虑到体系容量和资源约束,可是关于大部分场景来说运用线程池去处理堵塞操作仍然是干流且不错的挑选。
  • WebFlux 的作用十分好,可是考虑到需求完全依照呼应式风格进行开发,本钱及难度较大,一起 WebFlux 与现有的一些干流结构存在一些兼容问题,例如 Mysql 官方 IO 库不支撑 NIO、Threadlocal 兼容问题等等。现有使用的搬迁根本要重写所有代码,改动量和危险都不可控。
  • 虚拟线程的作用十分好,最大的优势便是咱们没有修改代码或选用任何反应式技能,唯一更改是将线程池替换为虚拟线程。尽管改动较小,但与运用线程池相比,功能成果得到了显著改善。

根据上述的压测成果,能够较为乐观的以为虚拟线程会颠覆咱们现在的服务和结构中的恳求处理办法。

六、总结

过去很长时刻,在编写服务端使用时,咱们关于每个恳求,都运用独占的线程来处理,恳求之间是相互独立的,这便是一恳求一线程的模型这种办法易于了解和编程完结,也易于调试和功能调优。

然而,一恳求一线程风格并不能简略地运用渠道线程来完结,由于渠道线程是操作体系中线程的封装。操作体系的线程会恳求本钱较高,存在数量上限。** 关于一个要并发处理海量恳求的服务器端使用来说,对每个恳求都创立一个渠道线程是不现实的。** 在这种前提下,涌现出一批非堵塞 I/O 和异步编程结构,如 WebFlux ,RX-Java。当某个恳求在等候 I/O 操作时,它会暂时让出线程,并在 I/O 操作完结之后持续履行。经过这种办法,能够用少数线程一起处理很多的恳求。这些结构能够提高体系的吞吐量,可是要求开发人员有必要了解所运用的底层结构,并依照呼应式的风格来编写代码,呼应式结构的调试困难,学习本钱,兼容问题使得大部分人望而却步 。 在运用虚拟线程之后,全部都将改动,开发人员能够运用现在最习气舒服的办法来编写代码,高功能和高吞吐由虚拟线程主动帮你完结,这极大地降低了编写高并发服务使用的难度。

参考文档

openjdk.org/jeps/444

zhuanlan.zhihu.com/p/514719325

www.vlts.cn/post/virtua…

zhuanlan.zhihu.com/p/499342616

*文/creed

本文属得物技能原创,更多精彩文章请看:[得物技能官网]

未经得物技能答应禁止转载,否则依法追究法律责任!