简单了解下 JDK19 预览版的 Virtual Threads
最近看到JDK19发布了,拖了这么多年终于加入了虚拟线程功能,目前还是预览阶段,OpenJDK网站上有详细的介绍,来先看看 Java 版的协程是怎么样的。
设计目标和解决的问题
虚拟线程是作为轻量级线程使用,以显著减少编写和维护并发程序的工作量。
设计目标
目标:
- 能够以简单的每个线程一个线程的风格来编写服务端应用,获得最佳的硬件利用率;
- 在已有的
java.lang.Thread
API 做扩展修改,做到最少修改; - 使用现有JDK工具能对虚拟线程进行故障排除、调试和分析。
非目标:
- 不是为了移除传统的线程实现,或者静默迁移现有应用程序使用虚拟线程;
- 不改变 Java 的基本并发模型;
- 不是为了在 Java 语言和库中提供一个新的数据并行结构处理工具,Stream API 仍然是并行处理大型数据集的首选方式。
线程池和异步线程的问题
在程序中使用线程,特别是需要创建大量的线程,每个线程有占用一定的资源(约512K~1M的空间),线程的上下文切换代价也比较大,所以使用线程池可以复用线程,限制无限使用系统的资源。
而对于需要提高系统吞吐量使用异步线程,编码将变得复杂,尤其是需要返回值编写回调函数的情况(回调地狱),而且跟踪调试困难。Java8 引入的 CompletableFuture 响应式接口在一定程度上改善了 Future 异步编程的回调问题,可组合的链式调用方式也使得编码逻辑更清晰。但是异步请求涉及多个线程和调试困难问题依旧灭解决。
针对以上的问题,再来看使用虚拟线程的优点:
对比线程池,由于虚拟线程非常轻量级,我们可以随意创建成千上万的虚拟线程而不耗费很多系统资源,每个虚拟线程只在本身生命周期完成单一任务,所以也完全没必要池化虚拟线程。
对于异步编程,虚拟线程同样具有响应式编程的特点,不会阻塞等待响应,并且降低编码和调试分析难度。
此外,我们知道各种的语言的协程不管怎么实现,实际上都是为了针对IO堵塞的场景能够有轻量高效的方法处理大量并发任务,即协程适用于IO密集型任务,对于CPU 密集型任务则无用。Java 在原本没有协程实现的时候用的是 netty 这类三方IO多路复用框架来解决问题,在系统层面上,IO多路复用单一线程就可以管理大量的 socket;在程序语言层面,创建大量的虚拟线程也同样只用到少量线程,两者解决方法的思路相似。
下面看具体的API看看虚拟线程是如何使用的。
相关API
先下载 JDK19,由于虚拟线程还是预览版功能,所以编译和运行程序都要带上相关参数才行,比如编译 com.zguishen
包下的 VirtualThreadTest.java
文件。
1 | javac -d . --enable-preview -source 19 VirtualThreadTest.java |
如果使用 IDEA,如需设置两个地方,一是 Setting-Build,Execution,Deployment-Compiler-Java Compiler 中,Project bytecode version 直接填19,Override compiler parameters per-module 添加工程,Compilation options 参数设置为--enable-preview --source 19
;二是运行的 VM Options 也要添加参数 --enable-preview
,嫌每个类方法运行都要添加麻烦,就直接在 Run/Debug Configuration Templates 添加。
Thread类
Thread.ofVirtual()
创建一个虚拟线程,Thread.ofPlatform()
创建一个平台线程;isVirtual()
函数判断是否为虚拟函数;Thread.startVirtualThread()
创建并启动虚拟线程。
1 |
|
Executors类
Executors.newVirtualThreadPerTaskExecutor()
创建任务线程。
1 |
|
写一个模拟任务跟传统线程池做对比。
1 |
|
结果:
1 | 虚拟线程耗时:1291ms |
以上是创建10000个虚拟线程同时执行任务,休眠一秒钟,在现代硬件的操作系统上只需要少量线程就可以完成,执行时间也很快。
可以看到 Executors.newVirtualThreadPerTaskExecutor()
比使用固定大小的线程池 Executors.newFixedThreadPool(300)
或者线程数无限(实际上限是 Integer.MAX_VALUE
)的线程池 Executors.newCachedThreadPool()
耗时明显变短,效率大大提高。
如果你用的是 Linux 系统,可以用 top -H -d 0.5
观察线程数的变化,使用虚拟线程线程数基本没变化,使用没限制的线程池将会至少创建多几千个线程,内存占用也有所增加,使用固定线程池会多出几百个线程。
虚拟线程调度
JDK 的虚拟线程调度程序是一个 work-stealing ForkJoinPool,它以 FIFO 模式运行。调度器并行数量等于虚拟线程平台线程数,默认是CPU核心数量。虚拟线程的 ForkJoinPool 和普通的 ForkJoinPool.commonPool()不同,后者用于并行流(Stream),以 LIFO 模式运行。
JDK 的调度程序不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程,即虚拟线程的 M:N 调度,大量(M)虚拟线程被调度到较少数量(N)的操作系统线程上运行。
先看一段程序:
1 |
|
输出
1 | create virtual thread to run task. |
可以看到 VirtualThread 后面的数字是虚拟线程 id,虚拟线程可以大量创建;worker 后面的的 id 数表示平台线程,不会超过 CPU 核心数。
再看 i=0 对应的信息,延时前后的虚拟线程 id 相同,但 worker 已经不同的,虚拟线程在IO堵塞时会从平台线程上卸载,保存堆栈信息,当阻塞操作完成时调度器会重新挂载,还原现场,不过重新挂载到的平台线程可能不是之前的同一个。
不过,有一些堵塞操作在堵塞期间,JDK 无法将虚拟线程从平台线程卸载的,这将严重影响性能,比如以下两种情况:
- 在 synchronized 代码块或方法中执行;
- 在本地方法和外部函数(Foreign Function,也是JDK的预览功能,允许 Java 程序与 JVM 运行时之外的代码和数据互操作)中执行。
很不幸的是,JDBC 的 API 也用到了 synchronized,但是平常业务有离不开涉及数据库的操作。对于以上情况,synchronized 可以用 ReentrantLock 来代替;修改系统参数最大平台线程数jdk.virtualThreadScheduler.maxPoolSize
,设置超过处理器数量的平台线程数,保证有足够的平台线程可用。
与其他语言的协程对比
Python
Python 对协程的支持是通过 generator 实现的,之前写过一篇笔记。
Golang
最近在学 Go,Go 里面的协程用起来更加简单,只要在函数前加个 go 关键字就行,配合通道(channel)和多路复用器(select)的玩法用起来也很简便。
之前在推特上看到的一道面试题,考察的内容是典型竞态检测,涉及协程、通道和多路复用器的运用。
1 | func TestGoroutines(t *testing.T) { |
下面是一个解法,当 slow 任务大于1秒,多路复用器会先收到 context 的超时事件,返回结束;当 slow 任务小于1秒,多路复用器会先收到数据1,正常返回结束。
1 | func foo(ctx context.Context) { |
不同编程语言的协程在实现方法和语法上有所差异,但目的不外乎都是为了能简化编码,用同步的思维写异步程序,减少线程的上下文切换和内存开销等等。
参考
1和3是更详细的参考文章。