1. 前言
虚拟线程长期以来一直是 Java 中最重要的创新之一。它们是在Project Loom中开发的,从Java 19开始作为预览功能包含在 JDK 中,从Java 21开始作为最终版本(JEP 444)包含在 JDK 中。
Java Virtual Threads: Project Loom
在本文中,您将了解:
- 为什么需要虚拟线程?
- 什么是虚拟线程?它们如何工作?
- 如何使用虚拟线程?
- 如何创建虚拟线程,以及可以启动多少个虚拟线程?
- 如何在 Spring 和 Jakarta EE 中使用虚拟线程?
- 虚拟线程有什么优点?
- 虚拟线程不是什么,它们的局限性是什么?
2. 为什么我们需要虚拟线程?
任何曾经在高负载下维护过后端应用程序的人都知道,线程通常是瓶颈。对于每个传入请求,都需要一个线程来处理该请求。一个 Java 线程对应一个操作系统线程,这些线程非常耗资源:
- 操作系统线程为堆栈保留 1 MB,并预先提交其中的 32 或 64 KB,具体取决于操作系统。
- 启动一个OS线程大约需要1ms。
- 上下文切换发生在内核空间,并且非常耗费 CPU 资源。
您不应该启动超过几千个;否则,您将危及整个系统的稳定性。
然而,几千个并不总是足够的——特别是如果由于需要等待阻塞数据结构(例如队列、锁或外部服务(如数据库、微服务或云 API))而需要更长时间才能处理请求。
例如,如果一个请求需要两秒钟,并且我们将线程池限制为 1,000 个线程,那么每秒最多可以响应 500 个请求。但是,即使每个 CPU 核心有多个线程提供服务,CPU 也远未得到利用,因为它大部分时间都在等待外部服务的响应。
到目前为止,我们只能通过异步编程来克服这个问题——例如,使用CompletableFuture或RxJava和Project Reactor等反应式框架。
但是,任何必须维护如下代码的人都知道,反应式代码比顺序代码复杂很多倍——而且绝对没有什么乐趣。
public CompletionStage<Response> getProduct(String productId) {
return productService
.getProductAsync(productId)
.thenCompose(
product -> {
if (product.isEmpty()) {
return CompletableFuture.completedFuture(
Response.status(Status.NOT_FOUND).build());
}
return warehouseService
.isAvailableAsync(productId)
.thenCompose(
available ->
available
? CompletableFuture.completedFuture(0)
: supplierService.getDeliveryTimeAsync(
product.get().supplier(), productId))
.thenApply(
daysUntilShippable ->
Response.ok(
new ProductPageResponse(
product.get(), daysUntilShippable))
.build());
});
}
这段代码不仅难以阅读和维护,而且调试起来也非常困难。例如,在这里设置断点是没有意义的,因为代码只定义了异步流程,但没有执行它。业务代码稍后将在单独的线程池中执行。
此外,数据库驱动程序和其他外部服务的驱动程序也必须支持异步、非阻塞模型。
3. 什么是虚拟线程?
虚拟线程解决了这个问题,让我们能够编写易于阅读和维护的代码。从 Java 代码的角度来看,虚拟线程就像普通线程一样,但它们并不是 1:1 映射到操作系统线程。
相反,存在一个所谓的载体线程池,虚拟线程被临时映射(“挂载”)到该池中。一旦虚拟线程遇到阻塞操作,虚拟线程就会从载体线程中移除(“卸载”),载体线程可以执行另一个虚拟线程(新线程或先前被阻塞的线程)。
下图描述了从虚拟线程到载体线程再到操作系统线程的 M:N 映射:
从虚拟线程到载体线程再到操作系统线程的映射
载体线程池是一个ForkJoinPool
– 也就是说,每个线程都有自己的队列,如果自己的队列为空,则从其他线程的队列中“窃取”任务。其大小默认设置为Runtime.getRuntime().availableProcessors()
,可以使用 VM 选项进行调整jdk.virtualThreadScheduler.parallelism
。
随着时间的推移,三个任务的 CPU 活动(例如,每个任务执行代码四次,并在相对较长的时间内阻塞三次)可以映射到单个载体线程,如下所示:
将三个虚拟线程映射到一个载体线程
因此,阻塞操作不再阻塞正在执行的载体线程,我们可以使用小型载体线程池同时处理大量请求。
然后我们可以非常简单地实现上面的示例用例,如下所示:
public ProductPageResponse getProduct(String productId) {
Product product = productService.getProduct(productId)
.orElseThrow(NotFoundException::new);
boolean available = warehouseService.isAvailable(productId);
int shipsInDays =
available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);
return new ProductPageResponse(product, shipsInDays);
}
该代码不仅更易于编写和阅读,而且像任何顺序代码一样,可以通过常规方法进行调试。
如果您的代码已经看起来像这样 - 即您从未切换到异步编程,那么我有个好消息:您可以继续使用带有虚拟线程且不经更改的代码。
4. 虚拟线程 – 示例
我们还可以演示虚拟线程在没有后端框架的情况下的强大功能。为此,我们模拟了一个类似于上面描述的场景:我们启动 1,000 个任务,每个任务等待一秒钟(以模拟对外部 API 的访问),然后返回一个结果(示例中为随机数)。
首先我们实现任务:
public class Task implements Callable<Integer> {
private final int number;
public Task(int number) {
this.number = number;
}
@Override
public Integer call() {
System.out.printf(
"Thread %s - Task %d waiting...%n", Thread.currentThread().getName(), number);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.printf(
"Thread %s - Task %d canceled.%n", Thread.currentThread().getName(), number);
return -1;
}
System.out.printf(
"Thread %s - Task %d finished.%n", Thread.currentThread().getName(), number);
return ThreadLocalRandom.current().nextInt(100);
}
}
现在我们测量一下 100 个平台线程(非虚拟线程)池处理所有 1,000 个任务需要多长时间:
try (ExecutorService executor = Executors.newFixedThreadPool(100)) {
List<Task> tasks = new ArrayList<>();
for (int i = 0; i < 1_000; i++) {
tasks.add(new Task(i));
}
long time = System.currentTimeMillis();
List<Future<Integer>> futures = executor.invokeAll(tasks);
long sum = 0;
for (Future<Integer> future : futures) {
sum += future.get();
}
time = System.currentTimeMillis() - time;
System.out.println("sum = " + sum + "; time = " + time + " ms");
}
ExecutorService
自 Java 19 起,它可自动关闭,即,它可以被 try-with-resources 块包围。在块的末尾,ExecutorService.close()
调用,这反过来又调用shutdown()
和awaitTermination()
——并且可能shutdownNow()
在期间中断线程awaitTermination()
。
程序运行时间略长于 10 秒。
这是意料之中的:1,000 个任务除以 100 个线程 = 每个线程 10 个任务。
每个平台线程必须按顺序处理十个任务,每个任务持续大约一秒钟。
接下来,我们用虚拟线程测试整个过程。因此,我们只需要替换语句:
Executors.newFixedThreadPool(100)
替换为:
Executors.newVirtualThreadPerTaskExecutor()
该执行器不使用线程池,而是为每个任务创建一个新的虚拟线程。
此后,程序不再需要 10 秒,而只需要 1 秒多一点。由于每个任务都等待 1 秒,因此程序几乎不可能更快。
令人印象深刻的是:我们的小程序甚至可以在一秒多的时间内处理 10,000 个任务。
只有在 100,000 个任务时,虚拟线程吞吐量才会明显下降:我的笔记本电脑需要大约四秒钟才能完成此操作 - 与线程池相比,这仍然非常快,而线程池需要近 17 分钟才能完成此操作。
5. 如何创建虚拟线程?
我们已经了解了一种创建虚拟线程的方法:我们创建的执行器服务为Executors.newVirtualThreadPerTaskExecutor()
每个任务创建一个新的虚拟线程。
使用Thread.startVirtualThread()
或Thread.ofVirtual().start()
,我们还可以明确启动虚拟线程:
Thread.startVirtualThread(() -> {
// code to run in thread
});
Thread.ofVirtual().start(() -> {
// code to run in thread
});
在第二种变体中,Thread.ofVirtual()
返回一个VirtualThreadBuilder
,该start()
方法启动虚拟线程。另一种方法Thread.ofPlatform()
返回一个PlatformThreadBuilder
,我们可以通过它启动平台线程。
两个构建器都实现了该Thread.Builder
接口。这使我们能够编写灵活的代码,在运行时决定它是否应该在虚拟线程或平台线程中运行:
Thread.Builder threadBuilder = createThreadBuilder();
threadBuilder.start(() -> {
// code to run in thread
});
顺便说一句,您可以使用来查明代码是否在虚拟线程中运行Thread.currentThread().isVirtual()
。
6. 可以启动多少个虚拟线程?
在这个GitHub 存储库中,您可以找到几个演示虚拟线程功能的演示程序。
使用类HowManyVirtualThreadsDoingSomething,您可以测试在系统上可以运行多少个虚拟线程。应用程序启动越来越多的线程,并Thread.sleep()
在这些线程中以无限循环执行操作,以模拟等待数据库或外部 API 的响应。尝试使用 VM 选项为程序提供尽可能多的堆内存-Xmx
。
在我的 64 GB 机器上,可以毫无问题地启动 20,000,000 个虚拟线程,只要有一点耐心,甚至可以启动 30,000,000 个。从那时起,垃圾收集器就会不停地尝试执行完整的 GC,因为StackChunk
一旦虚拟线程阻塞,虚拟线程堆栈就会“停放”在堆上,即所谓的对象中。不久之后,应用程序就会以OutOfMemoryError
.
使用HowManyPlatformThreadsDoingSomething类,您还可以测试您的系统支持多少个平台线程。但请注意:大多数情况下,程序会OutOfMemoryError
在某个时间点结束(对我来说是 80,000 到 90,000 个线程)——但它也可能使您的计算机崩溃。
7. 如何在 Jakarta EE 中使用虚拟线程?
本文开头的示例方法作为 Jakarta RESTful Webservices 控制器看起来如下所示 - 首先没有虚拟线程:
@GET
@Path("/product/{productId}")
public ProductPageResponse getProduct(@PathParam("productId") String productId) {
Product product = productService.getProduct(productId)
.orElseThrow(NotFoundException::new);
boolean available = warehouseService.isAvailable(productId);
int shipsInDays =
available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);
return new ProductPageResponse(product, shipsInDays);
}
现在,要在虚拟线程上运行此控制器,我们只需添加一行带有注释的内容@RunOnVirtualThread
:
@GET
@Path("/product/{productId}")
@RunOnVirtualThread
public ProductPageResponse getProduct(@PathParam("productId") String productId) {
Product product = productService.getProduct(productId)
.orElseThrow(NotFoundException::new);
boolean available = warehouseService.isAvailable(productId);
int shipsInDays =
available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);
return new ProductPageResponse(product, shipsInDays);
}
我们不需要改变方法主体中的任何一个字符。
@RunOnVirtualThread
在Jakarta EE 11中定义,计划于 2024 年第一季度发布。
7.1 如何在 Quarkus 中使用虚拟线程?
Quarkus自2.10 版@RunOnVirtualThread
(即自 2022 年 6 月起)起就已经支持Jakarta EE 11 中定义的注释。因此,使用当前的 Quarkus 版本,您已经可以使用上面显示的代码。
在此GitHub 存储库中,您将找到一个带有上面显示的控制器的示例 Quarkus 应用程序 - 一个带有平台线程,一个带有虚拟线程,还有一个带有异步变体CompletableFuture
。 README 解释了如何启动应用程序以及如何调用三个控制器。
8. 如何在 Spring 中使用虚拟线程?
在 Spring 中,控制器看起来像这样:
@GetMapping("/stage1-seq/product/{productId}")
public ProductPageResponse getProduct(@PathVariable("productId") String productId) {
Product product =
productService
.getProduct(productId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND));
boolean available = warehouseService.isAvailable(productId);
int shipsInDays =
available ? 0 : supplierService.getDeliveryTime(product.supplier(), productId);
return new ProductPageResponse(product, shipsInDays);
}
但是,要切换到虚拟线程,您需要做一些不同的事情。根据Spring 文档,您必须定义以下两个 bean:
@Bean(TaskExecutionAutoConfiguration.APPLICATION_TASK_EXECUTOR_BEAN_NAME)
public AsyncTaskExecutor asyncTaskExecutor() {
return new TaskExecutorAdapter(Executors.newVirtualThreadPerTaskExecutor());
}
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler -> {
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
};
}
然而,这会导致所有控制器都在虚拟线程上运行,这对于大多数用例来说可能没问题,但对于 CPU 密集型任务则不然——这些任务应该始终在平台线程上运行。
在此GitHub 存储库中,您可以找到一个包含上述控制器的示例 Spring 应用程序。README 解释了如何启动应用程序以及如何将控制器从平台线程切换到虚拟线程。
9. 虚拟线程的优点
虚拟线程具有显著的优势:
首先,它们价格便宜:
- 它们的创建速度比平台线程快得多:创建平台线程大约需要 1 毫秒,创建虚拟线程则需要不到 1 微秒。
- 它们需要的内存较少:平台线程为堆栈保留 1 MB,并预先提交 32 到 64 KB,具体取决于操作系统。虚拟线程以大约 1 KB 开始。但是,这仅适用于平面调用堆栈。大小为半兆字节的调用堆栈在两种线程变体中都需要半兆字节。
- 阻止虚拟线程的成本很低,因为阻止的虚拟线程不会阻止 OS 线程。但是,它的成本并不低,因为需要将其堆栈复制到堆中。
- 上下文切换速度很快,因为它们是在用户空间而不是内核空间中执行的,并且 JVM 中进行了大量优化以提高其速度。
其次,我们可以用熟悉的方式使用虚拟线程:
Thread
仅对和API进行了最小的更改ExecutorService
。- 我们不用使用回调来编写异步代码,而是可以采用传统的每个请求一个阻塞线程的风格来编写代码。
- 我们可以使用现有工具调试、观察和分析虚拟线程。
10. 虚拟线程不是什么?
当然,虚拟线程并非只有优点。首先让我们看看虚拟线程不是什么,以及我们不能或不应该用它们做什么:
- 虚拟线程并不是更快的线程——它们无法在相同时间内执行比平台线程更多的 CPU 指令。如果任务没有阻塞,由于挂载/卸载的开销,它在虚拟线程上的运行速度甚至会比在现有平台线程上的运行速度慢
ExecutorService
。 - 它们不是抢占式的:当虚拟线程执行 CPU 密集型任务时,它不会从载体线程中卸载。因此,如果您有 20 个载体线程和 20 个虚拟线程占用 CPU 而不阻塞,则不会执行其他虚拟线程。
- 它们不提供比平台线程更高级别的抽象。您需要注意使用常规线程时也需要注意的所有细微事项。也就是说,如果虚拟线程访问共享数据,您必须注意可见性问题,您必须同步原子操作,等等。
11. 虚拟线程的局限性是什么?
您应该了解以下限制。其中许多限制将在未来的 Java 版本中删除:
11.1 不支持的阻塞操作
尽管 JDK 中的绝大多数阻塞操作都已被重写以支持虚拟线程,但仍然存在不会从载体线程中卸载虚拟线程的操作:
- 文件 I/O – 这也将在不久的将来进行调整
Object.wait()
在这两种情况下,被阻塞的虚拟线程也会阻塞载体线程。为了弥补这一点,两种操作都会暂时增加载体线程的数量——最多可达 256 个线程,可通过 VM 选项进行更改jdk.virtualThreadScheduler.maxPoolSize
。
11.2 固定
固定意味着通常会从其载体线程卸载虚拟线程的阻塞操作不会这样做,因为虚拟线程已被“固定”到其载体线程 - 这意味着不允许更改载体线程。这发生在两种情况下:
- 在
synchronized
块内 - 如果调用堆栈包含对本机代码的调用
原因是在这两种情况下,指向堆栈上内存地址的指针都可能存在。如果堆栈在卸载时停留在堆上,并在安装时移回堆栈,则它最终可能会位于不同的内存地址。这会使这些指针无效。
使用 VM 选项, -Djdk.tracePinnedThread=full/short
当虚拟线程被固定时,您可以获得完整/短堆栈跟踪。
您可以用 替换synchronized
阻塞操作周围的块ReentrantLock
。
11.3 线程转储中没有锁
线程转储当前不包含有关虚拟线程所持有的锁或阻塞锁的数据。因此,它们不显示虚拟线程之间或虚拟线程与平台线程之间的死锁。
12. 具有虚拟线程的线程转储
通过 打印的常规线程转储jcmd <pid> Thread.print
不包含虚拟线程。原因是此命令会停止 VM 以创建正在运行的线程的快照。这对于几百甚至几千个线程是可行的,但对于数百万个线程则不行。
因此,已经实现了线程转储的新变体,它不会停止 VM(因此,线程转储本身可能不一致),但会返回虚拟线程。可以使用以下两个命令之一创建此新线程转储:
jcmd <pid> Thread.dump_to_file -format=plain <file>
jcmd <pid> Thread.dump_to_file -format=json <file>
第一个命令生成类似于传统线程转储的线程转储,其中包含线程名称、ID 和堆栈跟踪。第二个命令生成 JSON 格式的文件,其中还包含有关线程容器、父容器和所有者线程的信息。
13. 何时应使用虚拟线程?
如果您有许多需要同时处理的任务,则应该使用虚拟线程,这些任务主要包含阻塞操作。
对于大多数服务器应用程序来说都是如此。但是,如果您的服务器应用程序处理 CPU 密集型任务,则应该为它们使用平台线程。
14. 还有哪些重要事项需要考虑?
以下是使用和迁移到虚拟线程的一些技巧:
- 虚拟线程是新技术,与异步或反应式框架相比,我们对此还没有太多经验。因此,在将应用程序部署到生产环境之前,您应该对使用虚拟线程的应用程序进行深入测试。
- 尽管许多关于虚拟线程的文章都让我们相信这一点:它们本质上并不比平台线程占用更少的内存。只有当调用堆栈较浅时才会出现这种情况。对于较深的调用堆栈,两种类型的线程都消耗相同数量的内存。所以这里也同样适用:集中测试!
- 虚拟线程不需要池化。池用于共享昂贵的资源。另一方面,虚拟线程非常便宜,因此最好在需要时创建一个,在不再需要时让它终止。
- 如果您需要限制对资源的访问,例如有多少个线程可以同时访问数据库或 API,请使用信号量而不是线程池。
- 大部分虚拟线程代码都是用 Java 编写的。因此,在运行性能测试之前,您必须预热 JVM,以便在测量开始之前编译和优化所有字节码。
15. 总结
虚拟线程兑现了它们的承诺:它们允许我们编写可读且可维护的顺序代码,这些代码在等待锁、阻塞数据结构或来自文件系统或外部服务的响应时不会阻塞操作系统线程。
可以创建数百万个虚拟线程。
Spring 和 Quarkus 等常见后端框架已经可以处理虚拟线程。不过,在切换到虚拟线程时,您应该对应用程序进行深入测试。例如,请确保不要在应用程序上执行 CPU 密集型计算任务,确保它们未被框架池化,并且其中未存储任何 ThreadLocals。
我是真的已经将虚拟线程彻底应用到我们的文学网站(点击这里可进入试试),访问速度贼快啦,大家可以进来试试,一试便知!!!
我希望您和我一样兴奋并且迫不及待地想在您的项目中使用虚拟线程!