什么是虚拟线程
- 虚拟线程是一种轻量级线程,也可以称为协程。它是一种抽象的概念,可以理解为在程序中同时执行多个线程的并发执行。虚拟线程是由Java虚拟机(JVM)来实现的,它并不与特定的操作系统线程绑定,而是通过虚拟线程的调度来实现并发执行。
- 虚拟线程的调度由JVM负责,与平台线程有很大的不同。虚拟线程的状态管理、任务提交、休眠和唤醒等也是完全由JVM实现。虚拟线程适用于运行大部分时间被阻塞的任务,这些任务通常需要等待I/O操作的完成,例如网络请求和数据库查询等。
- 虚拟线程与平台线程不同,通常只有一个浅调用堆栈,例如只执行一次HTTP请求或者一次JDBC查询。虚拟线程也支持线程局部变量,因为单个JVM可能支持数百万的虚拟线程。然而,虚拟线程并不适用于长时间运行的CPU密集型操作。
- 虚拟线程是一种抽象的概念,通过Java虚拟机来实现并发执行,适用于需要等待I/O操作的任务,但并不适用于长时间运行的CPU密集型操作。
虚拟线程的作用
-
区别于虚拟线程,传统的线程对象叫做平台线程(platform thread)。平台线程在底层 OS 线程上运行 Java 代码,并在代码的整个生命周期中占用该 OS 线程,因此平台线程的数量受限于 OS 线程的数量。虚拟线程是
java.lang.Thread
的一个实例,它在底层 OS 线程上运行 Java 代码,但不会在代码的整个生命周期中占用该 OS 线程。也就是说,多个虚拟线程可以在同一个 OS 线程上运行其 Java 代码,可以有效地共享该线程。平台线程独占宝贵的 OS 线程,而虚拟线程则不会,因此虚拟线程的数量可以比 OS 线程的数量多得多,执行阻塞任务的整体吞吐量也就大了很多。 -
但如果上述任务不是简单的sleep 1s,而是计算了1s(例如做矩阵计算或数组排序等),用线程池和虚拟线程的执行时间区别就没有那么大。原因是虚拟线程虽然可以带来更大的吞吐量,但并不能让单个任务计算得更快,当使用平台线程执行任务已经让cpu没有任何空闲时,切换虚拟线程来执行也不会带来任何收益。
-
虚拟线程可以发挥的最大作用是,可以让采用单请求单线程(thread-per-request)的方式编写的服务器程序最大化地利用CPU计算资源 。 其原因在于服务器程序有两大特点,一是需要处理较大吞吐量的请求,二是请求处理的过程大多是由IO密集型逻辑组成,这就导致采用平台线程实现的单请求单线程编写方式,可能会有大量的IO阻塞占据了平台线程资源,从而不能充分利用CPU资源。我们在使用真实应用压测时观察到,当服务请求IO耗时增大时,使用虚拟线程的吞吐量会明显高于线程池,尤其是当服务下游依赖出现故障导致耗时增大时,虚拟线程带来的服务可用性提升会非常明显。
-
有些情况下,服务端开发者为了充分利用cpu硬件资源,会考虑放弃单请求单线程的编程风格,而采用基于netty、actor等异步框架来构建服务。这样虽然它消除了由于OS线程稀缺性带来的吞吐量限制,但代价很高:它需要异步编程风格,没有专用线程,开发人员必须将请求处理逻辑分解成小阶段,通常是lambda表达式或独立的回调handler对象,然后使用API将它们组合成一个顺序管道(例如CompletableFuture或响应式框架),在一定程度上放弃了代码的顺序执行逻辑和代码的可读性。
-
在异步风格中,请求的每个阶段可能在不同的线程上执行,每个线程以交替方式运行属于不同请求的阶段。这对于理解程序行为有很大的影响:堆栈跟踪不能提供可用的上下文,debug无法跟踪请求处理逻辑,而分析工具无法将请求处理与其调用者相关联。总的来说,除了底层服务框架和一些特定的功能性服务,大部分以业务开发为主导的服务器程序都不会采用这种编程风格进行逻辑开发
虚拟线程的工作原理
-
线程需要被调度才能执行任务,本质上是分配到CPU上执行。对于由操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度程序;而对于虚拟线程,JDK 先将虚拟线程分配给平台线程,然后平台线程按照通常的方式由操作系统进行调度。
-
调度器分配给虚拟线程的平台线程称为虚拟线程的载体线程(carrier)。虚拟线程可以在其生命周期内会被安排在不同的载体线程上。换句话说,调度器不维护虚拟线程和平台线程之间的亲和关系。从 Java 代码的角度来看,运行中的虚拟线程在逻辑上独立于其当前载体线程
-
载体线程的信息对虚拟线程不可见,Thread.currentThread() 返回的值始终是虚拟线程本身。
-
载体线程和虚拟线程的堆栈跟踪是分开的。在虚拟线程中抛出的异常将不包括载体线程的堆栈帧。线程dump不会在虚拟线程的堆栈中显示载体线程的堆栈帧,反之亦然。
-
载体线程的thread-local变量对虚拟线程不可用,反之亦然。
-
从 Java 代码的角度来看,开发者不能感知到虚拟线程和其载体线程临时共享了一个操作系统线程。但从本地代码(native code)的角度来看,虚拟线程和其载体在同一个本地线程上运行。因此,在同一虚拟线程上多次调用的本地代码可能会观察到不同的操作系统线程标识符。
注意事项
- JDK中绝大多数的阻塞操作(如LockSupport、网络库API、大部分IO操作)都会卸载虚拟线程,释放其载体线程和底层操作系统线程以执行其他虚拟线程。然而,JDK中有一些阻塞操作不会卸载虚拟线程,从而阻塞其载体线程和底层操作系统线程,这是因为操作系统层面(例如许多文件系统操作)或JDK层面(例如Object.wait())的限制。为了解决这些阻塞操作的问题,虚拟线程调度器会通过暂时扩展并行度来弥补平台线程被占用,因此在调度程序的ForkJoinPool中,平台线程的数量可能暂时超过可用处理器的数量,可以通过系统属性jdk.virtualThreadScheduler.maxPoolSize来调整调度器可用的平台线程的最大数量。
但有两种情况下虚拟线程在阻塞操作期间不能卸载,而是被固定(pinned)在其载体线程上:
-
当虚拟线程在 synchronized 代码块或方法中执行代码时
-
当它执行本地方法(native method )或外部函数(foreign function)时