虚拟线程,如你在本书中所学,是与 Java 之前的线程完全不同的一种线程类型。它们有自己的优点和缺点。在本章中,我们将展示一种理解这些线程的方法。我们首先从将虚拟线程视作任务来进行分析。随后,我们会展示如何在你的 Spring 和 Quarkus 应用中使用这些线程。最后,我们通过实现一些常见算法,给你展示如何自己使用虚拟线程。
如何理解虚拟线程
如你所知,你可以在功能强大的笔记本电脑上创建数百万个线程。这是一个巨大的优势,但也带来了新的挑战,因为每一个线程都有自己的生命周期、资源和上下文需要管理。因此,我们希望向你介绍另一种思考线程的方法------每任务一个线程模型(thread-per-task model)。顾名思义,你为每个任务创建一个线程。
任务是一个独立的工作单元。它包含执行所需的一切。那么,什么样的工作应该定义为任务呢?理想情况下,任务应当是独立的、可以并行执行的工作。例如,一个 Web 服务器处理传入请求,每个请求都可以作为一个独立的任务,在自己的线程中运行。将工作负载按任务结构化的好处包括:
- 任务边界清晰明确
- 代码结构更清晰
- 错误恢复可以在任务内部明确处理
过去,由于资源消耗大,每任务一个线程模型并不推荐使用。但虚拟线程和作用域值(scoped values)大大改善了资源消耗,使这种模型成为可能。
在应用中使用虚拟线程
很可能你已有一个正在开发或维护的应用,想尝试虚拟线程。本节将展示如何在应用代码中使用虚拟线程、在 Spring 中启用虚拟线程,以及如何在 Quarkus 中使用虚拟线程处理请求。
在切换线程类型之前,最好先测量应用性能。这些性能指标可以帮助你判断在代码中哪里切换线程类型最有意义,以及切换的影响最大。性能测量时需要关注 CPU 和内存使用情况、应用能处理的请求数量以及响应时间。这些指标可以帮助你判断是否值得切换为虚拟线程。
在切换之前,请再次查看第 1 章的图表,它显示了虚拟线程和平台线程的内存使用情况。如果你的应用不会创建 1000 个以上的线程,那么切换到虚拟线程可能不值得。

图 4-1 虚拟线程与平台线程的总内存使用量
切换到虚拟线程最简单的方法是用新的每任务虚拟线程执行器(virtual thread per task executor)替换现有的执行器服务。这通常是直接可替换的,应用可以立即开始使用虚拟线程。但如果执行器服务被用作锁,则可能出现问题。例如,有些执行器服务用于限制数据库连接数,通过一个小线程池来执行数据库操作。虚拟线程设计为大量创建,因此不能直接使用小线程池来限制。解决方法是将这些小线程池执行器替换为虚拟线程执行器并配合信号量(Semaphore)使用。
示例:限制数据库连接数
假设我们有一个应用,任务是向数据库发送消息。应用使用线程池将连接数限制为 5。任务是一个 Runnable,如下所示:
csharp
Runnable sendToDatabase = () -> {
System.out.println("Send message to a database...");
try {
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread());
};
这个任务假装向数据库发送消息,但实际上只是打印两行:一行表示发送消息,另一行显示执行任务的线程名称。
执行器如下:
ini
try(var exec = Executors.newFixedThreadPool(5)) {
for (int i = 0; i < 10; i++) {
exec.submit(sendToDatabase);
}
}
固定线程池有 5 个线程,这意味着同一时间最多只有 5 个任务在运行。输出如下:
css
Send message to a database...
Send message to a database...
...
Thread[#33,pool-1-thread-4,5,main]
Thread[#30,pool-1-thread-1,5,main]
...
只有五个任务同时活跃,这在平台线程中可行,但虚拟线程的优势在于可以创建大量线程。为了限制虚拟线程发送数据库消息的数量,我们使用信号量:
ini
Semaphore s = new Semaphore(5);
信号量允许最多 5 个线程同时执行。修改任务方法如下:
csharp
static void sendMessageToDatabase(Semaphore s) {
try {
s.acquire();
System.out.println("Send message to a database...");
Thread.sleep(Duration.ofSeconds(1));
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
s.release();
}
System.out.println(Thread.currentThread());
}
修改执行器:
ini
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
Semaphore s = new Semaphore(5);
for (int i = 0; i < 10; i++) {
exec.submit(() -> sendMessageToDatabase(s));
}
}
区别在于,我们创建了一个信号量并将其传递给任务方法。输出结果:
css
Send message to a database...
VirtualThread[#32]/runnable@ForkJoinPool-1-worker-8
Send message to a database...
VirtualThread[#34]/runnable@ForkJoinPool-1-worker-1
...
结果与之前相同,但现在使用了虚拟线程,线程创建不再受线程池限制。通过这种方式开发代码,你可以创建大量虚拟线程,同时仍然限制数据库等资源的连接数量。
面向 Web 应用的虚拟线程
如果你在开发 Web 应用,很可能会使用 Spring 或 Quarkus。这两个框架都支持虚拟线程。在应用中自己创建虚拟线程固然不错,但如果框架本身能够使用虚拟线程处理请求,那就更方便了。本节我们先介绍 Spring Boot 的实现方式,然后再介绍 Quarkus 的实现。
Spring 应用示例
我们创建了一个简单的 REST 控制器,返回 "hello world" 并打印线程名以显示使用的线程类型:
less
@RestController
@RequestMapping(value = "/v1/hello/")
public class HelloWorldController {
@GetMapping("world")
String helloWorld(){
System.out.println(Thread.currentThread());
return "hello, world!";
}
}
调用该接口时,控制台输出类似:
bash
Thread[#52,http-nio-8080-exec-1,5,main]
Thread[#54,http-nio-8080-exec-3,5,main]
Thread[#56,http-nio-8080-exec-5,5,main]
Thread[#57,http-nio-8080-exec-6,5,main]
默认情况下,Spring 使用 平台线程 处理请求。要改为使用虚拟线程,需要在 src/main/resources
目录下创建 application.properties
文件,并添加:
ini
spring.threads.virtual.enabled=true
重启 Spring 应用后,控制台输出将显示虚拟线程:
ruby
VirtualThread[#62,tomcat-handler-0]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#69,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#70,tomcat-handler-2]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#71,tomcat-handler-3]/runnable@ForkJoinPool-1-worker-1
可以看到,请求现在由虚拟线程处理。Spring 是通过修改默认属性文件来全局启用虚拟线程的。
Quarkus 应用示例
Quarkus 中的接口如下:
less
@Path("/hello")
public class ExampleResource {
@GET
@Produces(MediaType.TEXT_PLAIN)
public String hello() {
System.out.println(Thread.currentThread());
return "Hello from Quarkus";
}
}
默认使用平台线程,输出如下:
arduino
Thread[#134,executor-thread-1,5,main]
Thread[#134,executor-thread-1,5,main]
要使用虚拟线程,需要在方法上添加 @RunOnVirtualThread
注解:
less
@GET
@Produces(MediaType.TEXT_PLAIN)
@RunOnVirtualThread
public String hello() {
System.out.println(Thread.currentThread());
return "Hello from Quarkus";
}
调用时,控制台输出显示虚拟线程:
arduino
VirtualThread[#154,quarkus-virtual-thread-0]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#161,quarkus-virtual-thread-1]/runnable@ForkJoinPool-1-worker-1
总结:Spring 全局启用虚拟线程,而 Quarkus 通过注解按接口启用。这在你不确定每个接口是否都适合虚拟线程时非常有用。
在虚拟线程中使用 CompletableFuture<T>
使用 CompletableFuture
搭配虚拟线程,最简单的方法是将 ExecutorService
传给 supplyAsync
或 runAsync
:
ini
Supplier<String> supplier = () -> "Hello, World!";
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
CompletableFuture<String> completableFuture = CompletableFuture.supplyAsync(supplier, executorService);
CompletableFuture<Void> lastCompatibleFuture = completableFuture
.thenAccept(s -> System.out.println("Computer says: " + s));
lastCompatibleFuture.get();
executorService.shutdown();
输出:
yaml
Computer says: Hello, World!
虽然可行,但代码不够简洁。可以改进:
- 使用 try-with-resources:
csharp
try(var executor = Executors.newVirtualThreadPerTaskExecutor()){
Future<String> stringFuture = executor.submit(() -> "Computer says: " + supplier.get());
System.out.println("stringFuture = " + stringFuture.get());
}
输出:
ini
stringFuture = Computer says: Hello, World!
- 无需返回值时,可以直接启动虚拟线程:
ini
Thread thread = Thread.startVirtualThread(() -> System.out.println("Computer says: " + supplier.get()));
thread.join();
输出:
yaml
Computer says: Hello, World!
这种方式简洁明了,清晰地表达了代码意图。
小结
本章介绍了如何在应用中使用虚拟线程:
- 将线程视作任务的新思路
- 替换作为锁的线程池为实际锁和虚拟线程执行器
- 在 Spring 和 Quarkus 应用中使用虚拟线程处理请求
- 搭配
CompletableFuture
使用虚拟线程
通过这些方法,你可以在保持高并发能力的同时,写出更清晰、更高效的 Java 代码。