Java19发布,带来了 Java 开发者期待已久的新特性——虚拟线程。在 Java 有这个新特性之前,Golang 的 协程已经流行了很长时间,在并发编程领域大获成功。随着 Golang 的快速发展和推广,协程似乎已经成为世界上最好的语言的必备特性之一。
Java19 虚拟线程可以填补这一空白。在这篇文章中,我们将带你通过对虚拟线程的介绍以及与 Golang 协程的对比,带你领略 Java19 虚拟线程的风采。
我们常见的Java线程与系统内核线程是一一对应的,系统内核线程调度器负责调度Java线程。为了提高应用程序的性能,我们会创建越来越多的Java线程,显然系统在调度Java线程时会消耗大量资源,来处理线程上下文切换。
近几十年来,我们一直依靠上述多线程模型来解决 Java 中的并发编程问题。为了提高系统的吞吐量,我们必须不断增加线程的数量,但是机器的线程很昂贵,可用线程的数量是有限的。尽管我们使用各种线程池来最大限度地提高线程的成本效益,但在 CPU、网络或内存资源被耗尽之前,线程往往成为我们应用程序性能的瓶颈,无法释放硬件应具有的最大性能。
为了解决这个问题,Java19 引入了虚拟线程。在 Java19 中,我们以前使用的线程称为平台线程,仍然与系统内核线程一一对应。大量 (M个) 的虚拟线程,运行在少量 (N个) 的平台线程上(与 OS 线程一一对应)(M:N 调度)。JVM调度多个虚拟线程在特定平台线程上执行,并且在平台线程上一次只执行一个虚拟线程。
Thread.ofVirtual()和Thread.ofPlatform()分别用于创建虚拟和平台线程的新 API。
1 |
//output thread ID including virtual threads and system threads Thread.getId() deprecated from jdk19 |
使用Thread.startVirtualThread(Runnable)快速创建虚拟线程并启动它。
1 |
// Output thread IDs including virtual threads and system threads |
使用Thread.isVirtual()方法定义一个线程是否为虚拟线程的。
1 |
Runnable runnable = () -> System.out.println(Thread.currentThread().isVirtual()); |
使用Thread.join()等待虚拟线程完成作业调度,使用Thread.sleep()讲虚拟线程致为休眠状态。
1 |
Runnable runnable = () -> System.out.println(Thread.sleep(10)); |
使用 Executors.newVirtualThreadPerTaskExecutor()。创建ExecutorService 。ExecutorService 可以为每个任务创建一个新的虚拟线程。
1 |
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { |
也可以使用线程池和 ExecutorService ,完成现有代码的替换和迁移。
因为虚拟线程是Java19中的一个预览特性,所以本文出现的代码用到的运行命令如下。
Go 语言采用两级线程模型,其中 Goroutine 是 M:N 与系统内核线程,符合 Java 虚拟线程。 最终的 goroutine 仍然交给 OS 线程执行,但需要一个中介来提供上下文。 这是 G-M-P 模型。
排队
Go 调度程序有两个不同的运行队列。
交接机制
当G执行阻塞操作时,GMP调度空闲M执行阻塞M LRQ中的其他G,以防止阻塞M影响LRQ中其他G的执行。
偷工减料机制
GMP 为了最大化硬件的性能,任务窃取机制用于在 M 空闲时执行其他等待的 G。
切换机制是为了防止 M 阻塞,任务窃取是为了防止 M 空闲。
JDK 依赖于操作系统中的线程调度器来调度基于操作系统线程实现的平台线程。对于虚拟线程,JDK 有自己的调度程序。JDK的调度器不是直接将虚拟线程分配给系统线程,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的M:N调度)。平台线程由操作系统的线程调度系统调度。
JDK 的虚拟线程调度器是一个类似ForkJoinPoolFIFO 模式的线程池。调度程序中的并行量取决于调度程序虚拟线程中的平台线程数。默认值是可用的 CPU 内核数,但可以使用系统属性进行调整jdk.virtualThreadScheduler.parallelism。注意ForkJoinPool这里与 不同ForkJoinPool.commonPool(),后者用于实现并行流,以 LIFO 模式运行。
ForkJoinPool并ExecutorService以不同的方式工作。ExecutorService有一个等待队列来存储其任务,其中的线程将接收并处理这些任务。虽然ForkJoinPool每个线程都有一个等待队列,但当一个线程运行的任务生成另一个任务时,该任务会添加到该线程的等待队列中,当我们运行Parallel Stream并将一个大任务分为两个较小的任务时会发生这种情况。
为了防止线程饥饿问题,当一个线程的等待队列中没有更多任务时,ForkJoinPool还实现了另一种称为任务窃取的模式,这意味着一个饥饿的线程可以从另一个线程的等待队列中窃取一些任务。这类似于 Go GMP 模型中的工作窃取机制。
虚拟线程执行
通常,当虚拟线程在 JDK 中执行 I/O 或其他阻塞操作时,会从平台线程中卸载虚拟线程,例如BlockingQueue.take(). 当阻塞操作准备好完成时(例如,网络 IO 已接收到字节数据),调度程序将虚拟线程挂载到平台线程上以恢复执行。
JDK 中的大多数阻塞操作从平台线程中卸载虚拟线程,从而允许平台线程执行其他工作任务。但是,JDK 中的一些阻塞操作不会卸载虚拟线程,因此会阻塞平台线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如Object.wait())的限制。当这些阻塞操作阻塞平台线程时,它们会通过临时增加平台线程的数量来补偿其他平台线程阻塞的损失。因此,调度程序中的平台线程数ForkJoinPool可能会暂时超过 CPU 可用的内核数。可以使用系统属性调整调度程序可用的最大平台线程数jdk.virtualThreadScheduler.maxPoolSize. 这种阻塞补偿机制类似于 Go GMP 模型中的切换机制。
在以下两种情况下,虚拟线程固定在运行它的平台线程上,并且在阻塞操作期间无法卸载。
虚拟线程是固定的,并不影响程序运行的正确性,但可能会影响系统的并发和吞吐量。如果虚拟线程执行诸如 I/O 之类的阻塞操作,或者BlockingQueue.take()在它被固定时,负责运行它的平台线程将在操作期间被阻塞。(如果虚拟线程不固定,在执行I/O等阻塞操作时,会从平台线程中卸载掉)。
如何卸载虚拟线程
我们通过 Stream 创建了 5 个未启动的虚拟线程,它们的任务是打印当前线程,然后休眠 10 毫秒,然后再次打印线程。然后启动这些虚拟线程并调用jion()以确保控制台可以看到所有内容。
1 |
public static void main(String[] args) throws Exception { threads.forEach(Thread::start); |
从控制台输出中,我们可以看到 VirtualThread[#21] 首先在 ForkJoinPool 的线程 1 上运行,并在从睡眠状态返回时继续在线程 4 上运行。
为什么虚拟线程在休眠后会从一个平台线程跳转到另一个?
如果我们阅读sleep方法的源码,我们发现sleep方法在Java19中已经被重写,并且重写的方法增加了虚拟线程相关的判断。
1 |
public static void sleep(long millis) throws InterruptedException { if (currentThread() instanceof VirtualThread vthread) { if (ThreadSleepEvent.isTurnedOn()) { |
深入研究代码,我们发现虚拟线程休眠时调用的真正方法是Continuation.yield.
1 |
@ChangesCurrentThread |
这意味着Continuation.yield将当前虚拟线程的堆栈从平台线程的堆栈转移到Java堆内存中,然后将其他准备就绪的虚拟线程的堆栈从Java堆复制到当前平台线程的堆栈以继续执行。阻塞操作如 IO 或BlockingQueue.take()引起虚拟线程切换,就像睡眠一样。虚拟线程切换也是比较耗时的操作,但是比平台线程的上下文切换要轻很多。
在 Go 编程中,Goroutine 与 channel 配合得很好,使用 Goroutine 计算数组元素的总和。
1 |
package main import "fmt" func sum(s []int, c chan int) { c := make(chan int) fmt.Println(x, y, x+y) |
1 |
import java.util.concurrent.ArrayBlockingQueue; public class main4 { public static void main(String[] args) throws InterruptedException { System.out.printf("%d %d %d\n", x, y, x + y); |
由于 Java 中没有切片,因此使用数组和索引。Java 中没有通道,所以BlockingQueue使用类似于管道的 .
定义一个say()方法体,方法体循环 sleep 100ms,然后输出 index,分别使用java虚拟线程和 go 协执行该方法。进行比较性能。
1 |
package main import ( func say(s string) { func main() { |
1 |
public final class VirtualThreads { public static void main(String[] args) throws InterruptedException { |
可以看到,两种语言编写协程的方式非常相似,一般Java虚拟线程写起来稍微麻烦一点,Go使用关键字轻松创建协程。
反应式编程解决了平台线程需要阻塞等待其他系统响应的问题。使用异步 API 通过回调通知您结果,而不是阻塞和等待响应。当响应到达时,JVM 从线程池中分配另一个线程来处理响应。这样,处理单个异步请求将涉及多个线程。
在异步编程中,我们可以减少系统的响应延迟,但是由于硬件的限制,平台线程的数量仍然是有限的,所以我们仍然存在系统吞吐量的瓶颈。另一个问题是异步程序在不同的线程中执行,很难调试或分析它们。
虚拟线程通过较小的语法调整提高了代码质量(降低了编码、调试和分析代码的难度),同时具有可以显着提高系统吞吐量的反应式编程的优势。
不要池化虚拟线程
因为虚拟线程非常轻量级,并且每个虚拟线程在其生命周期内只运行一个任务,所以不需要池化虚拟线程。
虚拟线程下的ThreadLocal
public class main {
private static ThreadLocal stringThreadLocal = new ThreadLocal<>();
public static void getThreadLocal(String val) {
stringThreadLocal.set(val);
System.out.println(stringThreadLocal.get());
}
public static void main(String[] args) throws InterruptedException {
Thread testVT1 = Thread.ofVirtual().name("testVT1").unstarted(() ->main5.getThreadLocal("testVT1 local var"));
Thread testVT2 = Thread.ofVirtual().name("testVT2").unstarted(() ->main5.getThreadLocal("testVT2 local var"));
testVT1.start();
testVT2.start();
System.out.println(stringThreadLocal.get());
stringThreadLocal.set("main local var");
System.out.println(stringThreadLocal.get());
testVT1.join();
testVT2.join();
}
}
//output
null
main local var
testVT1 local var
testVT2 local var
虚拟线程的支持ThreadLocal方式与平台线程相同,平台线程无权访问虚拟线程设置的变量,虚拟线程无权访问平台线程设置的变量,由平台线程负责用于运行对虚拟线程透明的虚拟线程。但是,由于可以创建数百万个虚拟线程,因此在虚拟线程中使用之前请三思ThreadLocal。如果我们在应用程序中创建一百万个虚拟线程,就会有一百万个ThreadLocal实例及其引用的数据。大量的对象会给内存带来很大的负担。
用 ReentrantLock 替换 Synchronized
因为Synchronized将虚拟线程一直固定在平台线程上,阻塞操作不会卸载虚拟线程,影响程序的吞吐量,所以需要ReentrantLock使用Synchronized.
之前:
1 |
public synchronized void m() { |
后:
1 |
private final ReentrantLock lock = new ReentrantLock(); public void m() { |
如何迁移
概括
本文介绍了 Java 线程模型、Java 虚拟线程的使用、原理和适用场景,并与流行的 Go 协程 进行了比较,也发现了两种实现方式的相似之处,希望对大家理解 Java 虚拟线程有所帮助。