2024全网最全VirtualThread专题(25秋招走起~)

前提#
JDK19于2022-09-20发布GA版本,该版本提供了虚拟线程的预览功能。下载JDK19之后翻看了一下有关虚拟线程的一些源码,跟早些时候的Loom项目构建版本基本并没有很大出入,也跟第三方JDK如鹅厂的Kona虚拟线程实现方式基本一致,这里分析一下虚拟线程设计与源码实现。
Platform Thread与Virtual Thread#
因为引入了虚拟线程,原来JDK存在java.lang.Thread类,俗称线程,为了更好地区分虚拟线程和原有的线程类,引入了一个全新类java.lang.VirtualThread(Thread类的一个子类型),直译过来就是"虚拟线程"。
●题外话:在Loom项目早期规划里面,核心API其实命名为Fiber,直译过来就是"纤程"或者"协程",后来成为了废案,在一些历史提交的Test类或者文档中还能看到类似于下面的代码:
Thread在此基础上做了不少兼容性工作。此外,还应用了建造者模式引入了线程建造器,提供了静态工厂方法Thread#ofPlatform()和Thread#ofVirtual()分别用于实例化Thread(工厂)建造器和VirtualThread(工厂)建造器,顾名思义,两种建造器分别用于创建Thread或者VirtualThread,例如:
更新的JDK文档中也把原来的Thread称为Platform Thread,可以更明晰地与Virtual Thread区分开来。这里Platform Thread直译为"平台线程",其实就是"虚拟线程"出现之前的老生常谈的"线程"。
后文会把Platform Thread称为平台线程,Virtual Thread称为虚拟线程,或者直接用其英文名称
那么平台线程与虚拟线程的联系和区别是什么?JDK中的每个java.lang.Thread实例也就是每个平台线程实例都在底层操作系统线程上运行Java代码,并且平台线程在运行代码的整个生命周期内捕获系统线程。可以得出一个结论,平台线程与底层系统线程是一一对应的,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的总数量受限于系统线程的总数量。

image.png


总的来说,平台线程有下面的一些特点或者说限制:
●资源有限导致系统线程总量有限,进而导致与系统线程一一对应的平台线程有限
●平台线程的调度依赖于系统的线程调度程序,当平台线程创建过多,会消耗大量资源用于处理线程上下文切换
●每个平台线程都会开辟一块私有的栈空间,大量平台线程会占据大量内存
这些限制导致开发者不能极大量地创建平台线程,为了满足性能需要,需要引入池化技术、添加任务队列构建消费者-生产者模式等方案去让平台线程适配多变的现实场景。显然,开发者们迫切需要一种轻量级线程实现,刚好可以弥补上面提到的平台线程的限制,这种轻量级线程可以满足:
●可以大量创建,例如十万级别、百万级别,而不会占据大量内存
●由JVM进行调度和状态切换,并且与系统线程"松绑"
●用法与原来平台线程差不多,或者说尽量兼容平台线程现存的API
Loom项目中开发的虚拟线程就是为了解决这个问题,看起来它的运行示意图如下:

image.png


当然,平台线程不是简单地与虚拟线程进行1:N的绑定,后面的章节会深入分析虚拟线程的运行原理。
虚拟线程实现原理#
虚拟线程是一种轻量级(用户模式)线程,这种线程是由Java虚拟机调度,而不是操作系统。虚拟线程占用空间小,任务切换开销几乎可以忽略不计,因此可以极大量地创建和使用。总体来看,虚拟线程实现如下:
虚拟线程会把任务(一般是java.lang.Runnable)包装到一个Continuation实例中:
●当任务需要阻塞挂起的时候,会调用Continuation的yield操作进行阻塞
●当任务需要解除阻塞继续执行的时候,Continuation会被继续执行
Scheduler也就是执行器,会把任务提交到一个载体线程池中执行:
●执行器是java.util.concurrent.Executor的子类
●虚拟线程框架提供了一个默认的ForkJoinPool用于执行虚拟线程任务
下文会把carrier thread称为"载体线程",指的是负责执行虚拟线程中任务的平台线程,或者说运行虚拟线程的平台线程称为它的载体线程
操作系统调度系统线程,而Java平台线程与系统线程一一映射,所以平台线程被操作系统调度,但是虚拟线程是由JVM调度。JVM把虚拟线程分配给平台线程的操作称为mount(挂载),反过来取消分配平台线程的操作称为unmount(卸载):
●mount操作:虚拟线程挂载到平台线程,虚拟线程中包装的Continuation栈数据帧或者引用栈数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程
●unmount操作:虚拟线程从平台线程卸载,大多数虚拟线程中包装的Continuation栈数据帧会留在堆内存中
这个mount -> run -> unmount过程用伪代码表示如下:
从Java代码的角度来看,虚拟线程和它的载体线程暂时共享一个OS线程实例这个事实是不可见,因为虚拟线程的堆栈跟踪和线程本地变量与平台线程是完全隔离的。JDK中专门是用了一个FIFO模式的ForkJoinPool作为虚拟线程的调度程序,从这个调度程序看虚拟线程任务的执行流程大致如下:
●调度器(线程池)中的平台线程等待处理任务

image.png


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

image.png


●虚拟线程运行其Continuation,从而执行基于Runnable包装的用户任务

image.png


●虚拟线程任务执行完成,标记Continuation终结,标记虚拟线程为终结状态,清空一些上下文变量,运载线程"返还"到调度器(线程池)中作为平台线程等待处理下一个任务

image.png


上面是描述一般的虚拟线程任务执行情况,在执行任务时候首次调用Continuation#run()获取锁(ReentrantLock)的时候会触发Continuation的yield操作让出控制权,等待虚拟线程重新分配运载线程并且执行,见下面的代码:
●虚拟线程中任务执行时候首次调用Continuation#run()执行了部分任务代码,然后尝试获取锁,会导致Continuation的yield操作让出控制权(任务切换),也就是unmount,运载线程栈数据会移动到Continuation栈的数据帧中,保存在堆内存,虚拟线程任务完成(但是虚拟线程没有终结,同时其Continuation也没有终结和释放),运载线程被释放到执行器中等待新的任务;如果Continuation的yield操作失败,则会对运载线程进行park调用,阻塞在运载线程上

image.png


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

image.png


●最终虚拟线程任务执行完成,标记Continuation终结,标记虚拟线程为终结状态,清空一些上下文变量,运载线程"返还"到调度器(线程池)中作为平台线程等待处理下一个任务
Continuation组件十分重要,它既是用户真实任务的包装器,也是任务切换虚拟线程与平台线程之间数据转移的一个句柄,它提供的yield操作可以实现任务上下文的中断和恢复。由于Continuation被封闭在java.base/jdk.internal.vm下,可以通过增加编译参数--add-exports java.base/jdk.internal.vm=ALL-UNNAMED暴露对应的功能,从而编写实验性案例,IDEA中可以按下图进行编译参数添加:

image.png


然后编写和运行下面的例子:
这里可以看出Continuation的奇妙之处,Continuation实例进行yield调用后,再次调用其run方法就可以从yield的调用之处往下执行,从而实现了程序的中断和恢复。
源码分析#
主要包括:
●Continuation
●VirtualThread
●线程建造器
Continuation#
Continuation直译为"连续",一般来说表示一种语言构造,使语言可以在任意点保存执行状态并且在之后的某个点返回。在JDK中对应类jdk.internal.vm.Continuation,这个类只有一句类注释A one-shot delimited continuation,直译为一个只能执行一次的回调函数。由于Continuation的成员和方法缺少详细的注释,并且大部分功能由JVM实现,这里只能阅读其一些骨干源码和上一小节编写的Continuation相关例子去了解其实现(笔者C语言比较薄弱,有兴趣的可以翻阅JVM的源码)。先看成员变量和构造函数:
Continuation是一个双向链表设计,它的唯一一组构造参数是ContinuationScope和Runnable:

image.png


这里不深入研究内部StackChunk、Pinned等实现,直接看run、enter系列方法和yield方法:
说实话,Continuation源码的可读性比想象中低,连代码注释也留下了"丑陋的"这句吐槽。通过上面源码分析和上一节Continuation的一个例子,可以得知Continuation#yield()可以让程序代码中断,然后再次调用Continuation#run()可以从上一个中断位置继续执行,JVM在这个过程中为使用者屏蔽了Continuation和运行此Continuation的平台线程之间的交互细节,让使用者可以专注实际的任务开发即可。
VirtualThread#
前面花了不少篇幅介绍Continuation,它是一个全新的API。已有的JUC类库已经十分完善,如果可以把Continuation融入到已有的JUC体系,那么就可以通过线程池技术去管理运载线程,原有的大多数并发相关API也能直接在协程体系中使用。从这个背景来看,创造一个Thread类的全新子类用于融合JUC和Continuation是十分合适的,这样通过很小的改造成本就能通过Java继承特性把这个全新子类适配JUC体系,也能扩展一些API让它适配协程新引入的特性,这个全新的子类就是java.lang.VirtualThread:

image.png


VirtualThread类的继承体系如下:
VirtualThread是BaseVirtualThread的子类,而BaseVirtualThread是一个"密封类",它是Thread的子类,只对VirtualThread和ThreadBuilders.BoundVirtualThread开放,并且VirtualThread是包私有访问权限的同时用final关键字修饰,无法被继承。接着看VirtualThread的成员变量和构造函数:
这里唯一的构造函数是比较复杂的,抛开一些钩子接口,最终想达到的效果就是:
用户任务实际被包裹了很多层,在最里面一层才会回调。VirtualThread中提供了两个静态全局的线程池实例,一个用于调度,一个用于唤醒,这里看看两个线程池是如何构造的:
对于默认调度器(DEFAULT_SCHEDULER)的创建,它是一个ForkJoinPool实例,构造参数的选取如下:
●parallelism参数由系统变量jdk.virtualThreadScheduler.parallelism决定,默认值为Runtime.getRuntime().availableProcessors(),如果配置了系统参数jdk.virtualThreadScheduler.maxPoolSize则取min(parallelism,maxPoolSize)
●maxPoolSize参数由系统变量jdk.virtualThreadScheduler.maxPoolSize决定,默认值为min(parallelism, maxPoolSize)
●minRunnable参数由系统变量jdk.virtualThreadScheduler.minRunnable决定,默认值为max(parallelism / 2, 1)
●asyncMode参数固定值true,也就是选用FIFO模式
●keepAliveTime参数为固定值30秒
●saturate参数在JDK17引入,是一个Predicate函数,在此固定返回true,用于忽略minRunnable值允许线程池饱和
●线程工厂用于创建CarrierThread实例,CarrierThread是ForkJoinWorkerThread的子类
在Intel 4C8T开发机器环境中,该ForkJoinPool实例创建时候的几个参数分别为:parallelism = 8, maxPoolSize = 256, minRunnable = 4。
对于调度线程池(UNPARKER)的创建,它是一个ScheduledThreadPoolExecutor实例,构造参数的选取如下:
●corePoolSize参数由系统变量jdk.unparker.maxPoolSize决定,并且确保最小值为1
●线程工厂用于创建InnocuousThread实例,线程名称为VirtualThread-unparker
接着看虚拟线程的启动方法start():
ForkJoinPool#lazySubmit()是JDK19新增的一个API,它的方法注释如下:
提交给定的任务,但不保证它最终会在没有可用活动线程的情况下执行。在某些上下文中,这种方法可以通过依赖于特定于上下文的知识来减少竞争和开销,即现有线程(如果在此池中操作,则可能包括调用线程)最终将可用来执行任务
使用此方法提交的目的就是希望可以用当前调用线程去执行任务,对于首次提交Continuation任务可能作用不明显,但是对于Continuation.yield()调用后的再次提交意义比较重大,因为这样就可以把运行的Continuation.run()方法链分配到同一个运载线程实例,在开发者的角度就是虚拟线程任务执行中断后恢复执行,执行任务的运载线程没有改变。
源码中还可以发现,run()方法覆盖了Thread#run()替换为空实现,因为VirtualThread最终是触发Continuation#run(),这一点已经在start()方法进行提交和调度。最后分析虚拟线程的阻塞(不带超时,也就是timeout = 0)、限时阻塞(timeout > 0)、join的实现。先看相对简单的joinNanos():
接着看虚拟线程阻塞和限时阻塞的现实:
总的来说就是:
●阻塞:通过Continuation.yield()调用实现阻塞,主要是提供给Thread.sleep()调用
●限时阻塞:Continuation.yield()调用之前计算唤醒时间并且向调度线程池(UNPARKER)提交一个延时执行的unpark任务通过"懒提交"方式重新运行Continuation.run()调用链解除阻塞,主要是提供给Thread.sleep(long nanos)调用
●join(Nanos):通过CountDownLatch.await()调用实现阻塞,在虚拟线程终结钩子方法afterTerminate()中调用CountDownLatch.countDown()解除阻塞,join(Nanos)()方法主要是提供给Thread.join()调用
●特殊情况:如果Continuation.yield()调用失败,则会通过Unsafe提供的park API阻塞在运载线程上,在unpark任务中通过Unsafe提供的unpark API解除阻塞
分析完虚拟线程实现的核心代码,这里总结一下虚拟线程的状态切换,由于支持的状态比较多,这里通过一张状态图进行展示:

image.png


还有其他像获取虚拟线程栈、JVM状态通知、获取虚拟线程状态、状态切换的CAS操作等方法限于篇幅这里就不展开分析。
线程建造器#
线程建造器和线程工厂建造器用于快速创建平台线程实例、平台线程工厂实例、虚拟线程实例或者虚拟线程工厂实例。熟悉Builder模式的开发者看这个新引入的功能源码应该比较轻松:
上面的Builder接口都在java.lang.ThreadBuilders中进行实现,因为整体实现比较简单,这里只看全新引入的VirtualThreadFactory和VirtualThreadBuilder:
值得注意的是:虚拟线程实现上来看都是"守护线程",也就是说虚拟线程不需要设置daemon参数。平台线程或者虚拟线程的建造器或者工厂实现都是包访问权限的内部类,其父类使用了permits关键字指定继承范围,目前是只能通过链式设置值的方式初始化,无法修改其中的成员或者方法。
其他探讨#
其他探讨主要包括:
●自定义执行器
●内存占用评估
●局限性
●适用场景
●JUC亲和性
自定义执行器#
虽然虚拟线程建造器屏蔽了执行器Executor实例的公共访问权限,在目前预留功能版本下只能所有虚拟线程的任务最终都是由全局的ForkJoinPool执行,可以通过VarHandle对其进行强制值设置,这样就能修改虚拟线程底层的载体线程为我们自定义线程池中的平台线程,例如这样:
可以看到最终效果,虚拟线程中的任务最终在自定义线程池中的唯一平台线程中运行。这里只是做一个实验性例子,使用反射或者MethodHandle对未稳定的API进行操作以后有很大概率会出现兼容性问题,不建议在生产环境这样操作,待虚拟线程完成预览正式发布后应该会提供对应的API让开发者设置自定义执行器。
资源占用评估#
平台线程(单个实例)的资源占用:
●通常是预留1 mb线程栈空间,额外需要16 kb操作系统核心数据源结构
●对于已经启动的平台线程实例,会占据2000+ byte数据,包括VM中平台线程的元数据等
虚拟线程(单个实例)的资源占用:
●Continuation栈会占据数百byte到数百kb内存空间
●虚拟线程实例会占据200 - 240 byte
两者对比一看,理论上得知单个平台线程占用的内存空间至少是kb级别的,而通常单个虚拟线程实例占用的内存空间是byte级别,两者的内存占用相差1个数量级。这里可以使用NMT参数和jcmd命令进行验证,见下面的代码和结果。
上面的程序运行后启动10w平台线程,通过NMT参数和jcmd命令查看所有线程占据的内存空间如下:

image.png


可见总已提交内存大部分来自创建的平台线程,这些平台线程占用了大概613 mb空间,它们的总线程栈空间占用约为5862 mb,两者加起来占据总使用内存(7495 mb)的86 %以上。用类似的方式编写运行虚拟线程的程序:
上面的程序运行后启动10w虚拟线程,同样通过NMT参数和jcmd命令查看:

image.png


这里有意缩小虚拟线程程序的最小最大堆内存为-Xms10m -Xmx100m,程序依然正常运行,并且堆内存的实际占用量和总内存的实际占用量都不超过200 mb,由此可以证明虚拟线程确实在极大量创建的前提下不会占据大量内存空间(这里暂时没有考虑到复杂调用情况下Continuation栈占据内存空间大小,不过已经大幅度优于平台线程)。
局限性#
当前的虚拟线程实现有如下局限性:
●Continuation栈存在native方法或者外部函数(FFM的API,见JEP-424)调用不能进行yield操作
●当持有监视器或者等待监视器的时候(一般是使用了synchronized关键字或者Object.wait())不能进行yield操作
●Continuation栈存在native方法调用、外部函数调用或者当持有监视器或者等待监视器的时候,虚拟线程会Pin到平台线程,导致虚拟线程无法从平台线程卸载,虽然不会影响程序正确执行,但是会影响性能,也就是如果这些虚拟线程是可复用的,永远无法切换到其运载线程,导致任务切换开销永久性增大
●虚拟线程可以像平台线程一样使用ThreadLocal,但是由于一般虚拟线程实例是会大量创建的,ThreadLocal本质是哈希表的一个链接,创建大量哈希表会带来额外的内存开销(这一点不算局限性,更接近于开发建议,建议使用虚拟线程的时候禁用ThreadLocal)
对于前三点出现的情况,一些文档中提到会导致虚拟线程无法从运载线程卸载,这个现象称为Pinned Thread,通过系统参数jdk.tracePinnedThreads可以打印具体的Pinned Thread栈,从而定位到哪些虚拟线程被固定到哪些平台线程中。对于这个问题,目前可以通过编程规范去规避,也就是虚拟线程执行的任务尽量规避调用native方法或者外部函数,对于synchronized关键字可以使用JUC中的锁API进行替换,例如ReentrantLock等等。
适用场景#
基于继承的特性,通过对java.lang.Thread(虚拟线程的超类)薄封装,也就是基于Thread的API可以直接透明地实现虚拟线程的挂起和恢复等操作,对使用者屏蔽了虚拟线程复杂的调度实现。由于虚拟线程实例占据的资源比较少,可以大量地创建而无须考虑池化,因此满足类似下面的使用场景:
●大批量的处理时间较短的计算任务
●大量的IO阻塞等待处理
●thread-per-request风格的应用程序,例如主流的Tomcat线程模型或者基于类似线程模型实现的SpringMVC框架等等
JUC亲和性#
还是基于继承的特性,java.lang.VirtualThread是java.lang.Thread子类型,因此使用到Thread类型的地方原则上可以透明使用VirtualThread,就是说通过下面的形式可以池化虚拟线程:

Java复制代码

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

public class VirtualThreadPool {

public static void main(String[] args) throws Exception {

ThreadFactory factory = Thread.ofVirtual().allowSetThreadLocals(false)

.name("VirtualFactoryWorker-", 0)

.inheritInheritableThreadLocals(false)

.factory();

// core = max = 10

ThreadPoolExecutor fixedVirtualThreadPool

= new ThreadPoolExecutor(10, 10, 0, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), factory);

fixedVirtualThreadPool.execute(() -> {

Thread thread = Thread.currentThread();

System.out.printf("线程名称:%s,是否虚拟线程:%s\n", thread.getName(), thread.isVirtual());

});

fixedVirtualThreadPool.shutdown();

fixedVirtualThreadPool.awaitTermination(5, TimeUnit.SECONDS);

}

}

但是前面也提到过:由于虚拟线程本身是轻量级的,在执行计算任务的时候更建议每个任务新创建一个虚拟线程实例,因为池化操作本身是会引入额外开销。另外,JUC下很多类库都是基于AQS数据结构实现,而AQS中无论独占模式还是共享模式,在队列中等待的节点以及抢占虚拟头节点的对象本质都是Thread实例,基于这一点来看,AQS也是无缝适配VirtualThread。见下面的例子:

Java复制代码

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

public class VirtualThreadJuc {

public static void main(String[] args) throws Exception {

CountDownLatch latch = new CountDownLatch(1);

Thread.startVirtualThread(() -> {

try {

System.out.println("before await");

latch.await();

System.out.println("after await");

} catch (InterruptedException e) {

e.printStackTrace();

}

Thread thread = Thread.currentThread();

System.out.printf("线程名称:%s,是否虚拟线程:%s\n", thread.getName(), thread.isVirtual());

});

Thread.sleep(1000);

System.out.println("main count down");

latch.countDown();

Thread.sleep(Long.MAX_VALUE);

}

}

// 运行结果

before await

main count down

after await

线程名称:,是否虚拟线程:true

总的来说,VirtualThread与JUC既有类库是亲和的,大部分类库可以在虚拟线程任务中使用,并且不建议池化虚拟线程而是使用per task per virtual thread的编程模式。
小结#
本文详细介绍了平台线程与虚拟线程的区别、虚拟线程实现原理、虚拟线程的源码实现以及关于虚拟线程的一些探讨,希望能够帮到读者理解Java虚拟线程。在JDK19中,虚拟线程是预览特性,希望这个特性能够早点发布GA版本,这样才能填补Java协程这一块短板,也能让大量基础API和框架进行一轮革新。
参考资料:
●JEP-425:https://openjdk.org/jeps/425
●JVMLS2018.pdf(这份PDF文档详细地介绍了Loom项目的目标和实现方式):https://cr.openjdk.java.net/~rpressler/loom/loom/JVMLS2018.pdf
(本文完 e-a-20221005 c-3-d)

若有收获,就点个赞吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值