环境:JDK21
1. 虚拟线程简介
虚拟线程是轻量级的线程,可以减少编写、维护和调试高吞吐量并发应用程序的工作量。线程是可以调度的最小处理单元。它与其他类似单元并发运行,而且在很大程度上是独立运行的。它是java.lang.Thread的一个实例。线程有两种,平台线程和虚拟线程。
2. 什么是平台线程
平台线程被实现为操作系统(OS)线程的薄包装器。平台线程在其底层操作系统线程上运行Java代码,平台线程在平台线程的整个生命周期中捕获其操作系统线程。因此,可用平台线程的数量受限于操作系统线程的数量。
平台线程通常有一个比较大的线程堆栈和由操作系统维护的其他资源。它们适用于运行所有类型的任务,但可能是有限的资源。
3. 什么是虚拟线程
与平台线程一样,虚拟线程也是java.lang.thread的一个实例。然而,虚拟线程并没有绑定到特定的操作系统线程。虚拟线程仍然在操作系统线程上运行代码。但是,当虚拟线程中运行的代码调用阻塞I/O操作时,Java运行时会挂起虚拟线程,直到可以恢复为止。与挂起的虚拟线程相关联的OS线程现在可以自由地执行其他虚拟线程的操作。
虚拟线程的实现方式与虚拟内存类似。为了模拟大量内存,操作系统将大量虚拟地址空间映射到有限的RAM。同样,为了模拟大量线程,Java运行时将大量虚拟线程映射到少量操作系统线程。
与平台线程不同,虚拟线程通常有一个浅调用堆栈,只执行一个HTTP客户端调用或一个JDBC查询。尽管虚拟线程支持线程本地变量和可继承的线程本地变量,但应该仔细考虑使用它们,因为单个JVM可能支持数百万个虚拟线程。
虚拟线程适用于运行大部分时间被阻塞的任务,这些任务通常等待I/O操作完成。然而,它们并不适用于长时间运行的CPU密集型操作。
4. 为什么使用虚拟线程
在高吞吐量并发应用程序中使用虚拟线程,尤其是那些由大量并发任务组成、花费大量时间等待的应用程序。服务器应用程序是高吞吐量应用程序的示例,因为它们通常处理许多执行阻塞I/O操作(如获取资源)的客户端请求。
虚拟线程不是更快的线程;它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。
5. 创建虚拟线程
Thread和Thread.Builder APIs提供了创建平台线程和虚拟线程的方法。java.util.concurrent.Executors类还定义了创建ExecutorService的方法,该方法为每个任务启动一个新的虚拟线程。
5.1 Thread & Thread.Builder创建虚拟线程
调用Thread.ofVirtual()方法创建一个Thread.Builder实例,用于创建虚拟线程。如下示例:
Thread t= Thread.ofVirtual().start(() -> System.out.println("Hello")) ;
t.join() ;
Thread.Builder接口允许创建具有公共线程属性(如线程名称)的线程。Thread.Builder.OfPlatform子接口创建平台线程,而Thread.Builder.OfVirtual创建虚拟线程。
下面的示例使用Thread.Builder接口创建一个名为T-VM的虚拟线程,如下示例:
Thread.Builder builder = Thread.ofVirtual().name("T-VM") ;
Runnable task = () -> {
System.out.println("执行任务") ;
} ;
Thread t = builder.start(task) ;
System.err.printf("线程名称: %s%n", t.getName()) ;
t.join() ;
输出结果:
执行任务
线程名称T-VM
下面的示例创建并启动两个具有Thread.Builder的虚拟线程:
Thread.Builder builder = Thread.ofVirtual().name("vm - worker - ", 0);
Runnable task = () -> {
System.out.printf("线程ID: %d%n", Thread.currentThread().threadId());
} ;
// 线程 "vm - worker - 0"
Thread t1 = builder.start(task) ;
t1.join();
System.out.println(t1.getName() + " terminated") ;
// 线程 "vm - worker - 1"
Thread t2 = builder.start(task) ;
t2.join() ;
System.out.println(t2.getName() + " terminated") ;
输出结果:
线程ID: 21
vm - worker - 0 terminated
线程ID: 24
vm - worker - 1 terminated
以上是通过Thread.Builder创建虚拟线程的简单示例。
5.2 Executors创建虚拟线程
Executors允许将线程管理和创建与应用程序的其余部分分离。
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor() ;
// submit Runnable任务
Future future = executor.submit(() -> System.out.println("Running thread")) ;
future.get() ;
System.out.println("Task completed") ;
上面示例每当调用ExecutorService.submit(Runnable)时,都会创建一个新的虚拟线程并开始运行该任务。
6. 虚拟线程调度
操作系统在平台线程运行时进行调度。然而,Java运行时会在虚拟线程运行时进行调度。当Java运行时调度虚拟线程时,它将虚拟线程分配或挂载到平台线程上,然后操作系统像往常一样调度该平台线程。这个平台线程称为载体(carrier)。运行一些代码后,虚拟线程可以从它的载体卸载。这通常发生在虚拟线程执行阻塞I/O操作时。当一个虚拟线程从其宿主中卸载后,宿主就处于空闲状态,这意味着Java运行时调度器可以在其上分配另一个虚拟线程。
虚拟线程被绑定到其宿主(平台线程)时,在阻塞操作期间无法将其卸载。虚拟线程在以下情况下会被绑定:
- 虚拟线程在同步块或方法内运行代码
- 虚拟线程运行本机方法或外部函数
7. 虚拟线程应用指南
虚拟线程是由Java运行时而不是操作系统实现的Java线程。虚拟线程和传统线程(平台线程)的主要区别在于,可以很容易地在同一个Java进程中运行大量甚至数百万个活动的虚拟线程。正是它们的数量赋予了虚拟线程强大的能力,通过允许服务器并发处理更多请求,它们可以更高效地运行以"thread-per-request"风格编写的服务器应用程序,从而提高吞吐量,减少硬件浪费。
虚拟线程可以显著提高以thread-per-request风格编写的服务器的吞吐量,而不是延迟。在这种风格中,服务器在整个持续时间内使用一个线程来处理每个传入的请求。它至少占用一个线程,因为在处理单个请求时,你可能希望使用更多线程并发地执行某些任务。
阻塞平台线程的代价是昂贵的,因为它会占用线程——这是一种相对稀缺的资源——而线程并没有做很多有意义的工作。因为虚拟线程可能很多,所以阻塞它们是廉价的,也是值得鼓励的。因此,应该使用简单的同步风格并使用阻塞I/O API编写代码。
如下代码以非阻塞异步风格编写,不会从虚拟线程中获得太多好处。
HttpClient client = ... ;
Executor pool = Executors.newVirtualThreadPerTaskExecutor() ;
CompletableFuture.supplyAsync(() -> {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:8088/users/info")).build() ;
BodyHandler bodyHandler = ... ;
try {
return client.send(request , bodyHandler) ;
}
}, pool)
.thenCompose(url -> getBodyAsync(url, HttpResponse.BodyHandlers.ofString()))
.thenApply(info::findImage)
.thenAccept(this::process)
.exceptionally(t -> { t.printStackTrace(); return null; });
相反,以下代码以同步风格编写,并使用简单的阻塞IO,将受益匪浅:
try {
String page = getBody(info.getUrl(), HttpResponse.BodyHandlers.ofString());
String imageUrl = info.findImage(page);
byte[] data = getBody(imageUrl, HttpResponse.BodyHandlers.ofByteArray());
info.setImageData(data);
process(info);
}
8. 不要池化虚拟线程
关于虚拟线程最难理解的是,虽然它们与平台线程具有相同的行为,但它们不应该表示相同的程序概念。
平台线程很少,因此是一种宝贵的资源。宝贵的资源需要管理,管理平台线程的最常见方法是使用线程池。接下来你需要回答的问题是,线程池中应该有多少个线程?
但是虚拟线程是很多的,因此每个线程不应该代表某种共享的、池化的资源,而应该代表一个任务。
将n个平台线程转换为n个虚拟线程几乎没有好处;相反,需要转换的是任务。
为了将每个应用任务表示为一个线程,不要像下面的例子那样使用共享线程池:
Future f1 = sharedThreadPoolExecutor.submit(task1);
Future f2 = sharedThreadPoolExecutor.submit(task2);
// ... use futures
相反,使用虚拟线程
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future f1 = executor.submit(task1);
Future f2 = executor.submit(task2);
// ... use futures
}
上面的代码仍然使用ExecutorService,但从Executors.newVirtualThreadPerTaskExecutor()返回的代码没有使用线程池。相反,它为每个提交的任务创建一个新的虚拟线程。
此外,ExecutorService本身是轻量级的,我们可以像创建任何简单对象一样创建一个新的对象。这使得我们可以依赖新添加的ExecutorService#close方法和try-with-resources构造。close方法会在try块结束时隐式调用,它会自动等待所有提交给ExecutorService的任务(即由ExecutorService生成的所有虚拟线程)结束。
对于扇出场景来说,这是一个特别有用的模式,在这种场景中,同时执行多个对不同服务调用,如下面的示例所示:
void handle() throws Exception {
URL url1 = URI.create("http://www.pack.com").toURL() ;
URL url2 = URI.create("http://www.akf.com").toURL() ;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
System.out.printf("result1: %s, result2: %s%n", future1.get(),future2.get()) ;
}
}
String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
你应该创建一个新的虚拟线程,如上所示,即使是小的、短期的并发任务。
9. 避免长时间频繁的Pinning
当前实现虚拟线程的一个限制是,在同步的块或方法内部执行阻塞操作会导致JDK的虚拟线程调度器阻塞一个操作系统线程,而在同步的块或方法外部执行阻塞操作则不会。这种情况称为“Pinning”。如果阻塞操作持续时间长且频繁,Pinning可能会对服务器的吞吐量产生负面影响。保护短期的操作,例如内存操作,或者使用同步块或方法的不频繁操作,应该不会有任何负面影响。
对于长时间又频繁的地方应该使用ReentrantLock替换synchronized 。
synchronized(lockObj) {
frequentIO() ;
}
// 替换为
lock.lock();
try {
frequentIO() ;
} finally {
lock.unlock() ;
}