虚拟线程支持参考
本指南解释了如何在 Quarkus 应用程序中受益于 Java 21+ 虚拟线程。
什么是虚拟线程?
虚拟线程和平台线程之间的差异
我们将在此处简要概述该主题;有关更多信息,请参阅 JEP 425。
虚拟线程是 Java 19 中提供的一项功能(Java 21 是第一个包含虚拟线程的 LTS 版本),旨在为 I/O 密集型工作负载提供一种廉价的平台线程替代方案。
到目前为止,平台线程是 JVM 的并发单元。它们是操作系统结构的包装器。创建一个 Java 平台线程会在您的操作系统中创建一个“类线程”结构。
另一方面,虚拟线程由 JVM 管理。为了执行,它们需要挂载到平台线程上(平台线程充当该虚拟线程的载体)。因此,它们的设计旨在提供以下特性
- 轻量级
-
虚拟线程在内存中占用的空间比平台线程少。因此,同时使用比平台线程更多的虚拟线程而不耗尽内存成为可能。默认情况下,平台线程的创建堆栈约为 1 MB,而虚拟线程的堆栈是“按需付费”。您可以在项目 Loom 的首席开发人员(该项目为 JVM 添加了虚拟线程支持)的演示文稿中找到这些数字以及虚拟线程的其他动机。
- 创建成本低廉
-
在 Java 中创建平台线程需要时间。目前,强烈建议使用诸如线程池之类的技术,线程池在创建线程后重复使用,以最大程度地减少启动线程所花费的时间(并限制最大线程数以降低内存消耗)。虚拟线程应该是可一次性使用的实体,我们在需要时创建它们,不鼓励将它们池化或将它们重复用于不同的任务。
- 阻塞成本低廉
-
当执行阻塞 I/O 时,由 Java 平台线程包装的底层操作系统线程被放入等待队列,并且会发生上下文切换以将新的线程上下文加载到 CPU 核心上。此操作需要时间。由于 JVM 管理虚拟线程,因此当虚拟线程执行阻塞操作时,不会阻塞底层操作系统线程。它们的状态存储在堆中,并且另一个虚拟线程在同一个 Java 平台(载体)线程上执行。
使用 @RunOnVirtualThread 在虚拟线程上运行代码
在 Quarkus 中,虚拟线程的支持是使用 @RunOnVirtualThread 注解实现的。本节简要概述其原理以及如何使用它。有专门的指南针对支持该注解的扩展,例如
为什么不全部在虚拟线程上运行?
如上所述,并非所有内容都可以在虚拟线程上安全运行。垄断的风险可能导致高内存使用率。此外,在某些情况下,虚拟线程无法从载体线程卸载。这称为绑定。最后,某些库使用 ThreadLocal
来存储和重用对象。将虚拟线程与这些库一起使用将导致大量分配,因为有意池化的对象将为每个(可一次性使用的且通常是短期的)虚拟线程实例化。
截至今天,不可能以一种无忧无虑的方式使用虚拟线程。遵循这种自由放任的方法可能会很快导致内存和资源饥饿问题。因此,Quarkus 使用显式模型,直到上述问题消失(随着 Java 生态系统的成熟)。这也是为什么反应式扩展具有虚拟线程支持,而很少有经典扩展。我们需要知道何时在虚拟线程上分派。
重要的是要理解,这些问题不是 Quarkus 的限制或错误,而是由于 Java 生态系统的当前状态造成的,Java 生态系统需要发展才能变得对虚拟线程友好。
要了解有关内部设计和选择的更多信息,请查看在 Java 框架中集成虚拟线程的注意事项:资源受限环境中的 Quarkus 示例论文。 |
垄断案例
垄断已在虚拟线程仅对 I/O 密集型工作负载有用部分中解释。当运行长时间的计算时,我们不允许 JVM 卸载并切换到另一个虚拟线程,直到虚拟线程终止。实际上,当前调度程序不支持抢占任务。
这种垄断可能导致创建新的载体线程来执行其他虚拟线程。创建载体线程会导致创建平台线程。因此,此创建过程存在内存成本。
假设您在受限环境中运行,例如容器。在这种情况下,垄断可能会很快成为一个问题,因为高内存使用率可能导致内存不足问题和容器终止。由于调度和虚拟线程的固有成本,内存使用率可能比使用常规工作线程更高。
绑定案例
“廉价阻塞”的承诺可能并不总是有效:虚拟线程可能会在某些情况下绑定其载体。在这种情况下,平台线程被阻塞,正如在典型的阻塞场景中一样。
根据JEP 425,这可能发生在两种情况下
-
当虚拟线程在
synchronized
块或方法中执行阻塞操作时 -
当它在本地方法或外部函数中执行阻塞操作时
在您的代码中避免这些情况可能很容易,但验证您使用的每个依赖项都很困难。通常,在试验虚拟线程时,我们意识到低于 42.6.0 版本的 postgresql-JDBC 驱动程序会导致频繁的绑定。大多数 JDBC 驱动程序仍然绑定载体线程。更糟糕的是,许多库需要代码更改。
有关更多信息,请参见Quarkus 遇到虚拟线程
有关绑定案例的信息适用于 PostgreSQL JDBC 驱动程序 42.5.4 及更早版本。对于 PostgreSQL JDBC 驱动程序 42.6.0 及更高版本,几乎所有同步方法都已替换为可重入锁。有关更多信息,请参见 PostgreSQL JDBC 驱动程序 42.6.0 的重大更改。 |
池化案例
某些库正在使用 ThreadLocal
作为对象池机制。像 Jackson 和 Netty 这样非常流行的库假设应用程序使用有限数量的线程,这些线程被回收(使用线程池)以运行多个(不相关但顺序)任务。
此模式具有多个优点,例如
-
分配优势:繁重的对象仅为每个线程分配一次,但是由于这些线程的数量旨在受到限制,因此它不会使用太多内存。
-
线程安全:只有一个线程可以访问存储在线程本地的对象 - 从而防止并发访问。
但是,当使用虚拟线程时,此模式会适得其反。虚拟线程不会被池化,并且通常是短期的。因此,与少数线程相比,我们现在有许多线程。对于每个线程,都会创建存储在 ThreadLocal
中的对象(通常很大且成本很高),并且不会被重用,因为虚拟线程不会被池化(并且在执行完成后不会用于运行另一个任务)。此问题导致高内存使用率。不幸的是,它需要在库本身中进行复杂的代码更改。
将 @RunOnVirtualThread 与 Quarkus REST(以前称为 RESTEasy Reactive)一起使用
本节显示了一个使用 @RunOnVirtualThread 注解的简短示例。它还解释了 Quarkus 提供的各种开发和执行模型。
@RunOnVirtualThread
注解指示 Quarkus 在新的虚拟线程而不是当前线程上调用带注解的方法。Quarkus 处理虚拟线程的创建和卸载。
由于虚拟线程是可一次性使用的实体,因此 @RunOnVirtualThread
的基本思想是将端点处理程序的执行卸载到新的虚拟线程上,而不是在事件循环或工作线程(在 Quarkus REST 的情况下)上运行它。
为此,只需将 @RunOnVirtualThread 注解添加到端点即可。如果用于运行应用程序的 Java 虚拟机提供虚拟线程支持(因此 Java 21 或更高版本),则端点执行将卸载到虚拟线程。然后可以执行阻塞操作,而不会阻塞挂载虚拟线程的平台线程。
在 Quarkus REST 的情况下,此注解只能用于使用 @Blocking 注解的端点,或者由于其签名而被认为是阻塞的。您可以访问执行模型,阻塞,非阻塞以获取更多信息。
开始使用 Quarkus REST 的虚拟线程
将以下依赖项添加到您的构建文件中
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest</artifactId>
</dependency>
implementation("io.quarkus:quarkus-rest")
然后,您还需要确保您使用的是 Java 21+,这可以在您的 pom.xml 文件中使用以下方法强制执行
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
</properties>
三种开发和执行模型
下面的示例显示了三个端点之间的差异,所有这些端点都查询数据库中的fortune,然后将其返回给客户端。
-
第一个使用传统的阻塞样式,由于其签名而被认为是阻塞的。
-
第二个使用 Mutiny,由于其签名而被认为是非阻塞的。
-
第三个使用 Mutiny,但以同步方式使用,由于它不返回“反应式类型”,因此被认为是阻塞的,并且可以使用 @RunOnVirtualThread 注解。
package org.acme.rest;
import org.acme.fortune.model.Fortune;
import org.acme.fortune.repository.FortuneRepository;
import io.smallrye.common.annotation.RunOnVirtualThread;
import io.smallrye.mutiny.Uni;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import java.util.List;
import java.util.Random;
@Path("")
public class FortuneResource {
@Inject FortuneRepository repository;
@GET
@Path("/blocking")
public Fortune blocking() {
// Runs on a worker (platform) thread
var list = repository.findAllBlocking();
return pickOne(list);
}
@GET
@Path("/reactive")
public Uni<Fortune> reactive() {
// Runs on the event loop
return repository.findAllAsync()
.map(this::pickOne);
}
@GET
@Path("/virtual")
@RunOnVirtualThread
public Fortune virtualThread() {
// Runs on a virtual thread
var list = repository.findAllAsyncAndAwait();
return pickOne(list);
}
}
下表总结了这些选项
模型 | 签名示例 | 优点 | 缺点 |
---|---|---|---|
工作线程上的同步代码 |
|
简单的代码 |
使用工作线程(限制并发) |
事件循环上的反应式代码 |
|
高并发和低资源使用率 |
更复杂的代码 |
虚拟线程上的同步代码 |
|
简单的代码 |
存在绑定,垄断和欠高效对象池化的风险 |
请注意,所有三种模型都可以在单个应用程序中使用。
使用虚拟线程友好的客户端
如为什么不全部在虚拟线程上运行?部分中所述,Java 生态系统尚未完全为虚拟线程做好准备。因此,您需要小心,尤其是在使用执行 I/O 的库时。
幸运的是,Quarkus 提供了一个庞大的生态系统,可以准备在虚拟线程中使用。Mutiny,Quarkus 中使用的反应式编程库,以及 Vert.x Mutiny 绑定提供了编写阻塞代码的能力(因此,不要害怕,没有学习曲线),这不会绑定载体线程。
因此
-
Quarkus 扩展在反应式 API 之上提供阻塞 API,可以在虚拟线程中使用。这包括 REST 客户端,Redis 客户端,邮件发送程序…
-
返回
Uni
的 API 可以直接使用uni.await().atMost(…)
。它会阻塞虚拟线程,而不会阻塞载体线程,并且还可以通过简单的(非阻塞)超时支持来提高应用程序的弹性。 -
如果您使用使用 Mutiny 绑定的 Vert.x 客户端,请使用
andAwait()
方法,该方法会阻塞直到您获得结果,而不会绑定载体线程。它包括所有反应式 SQL 驱动程序。
在测试中检测绑定的线程
我们建议在使用虚拟线程的应用程序中运行测试时使用以下配置。如果它不会使测试失败,但至少会在代码绑定载体线程时转储开始跟踪
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>${surefire-plugin.version}</version>
<configuration>
<systemPropertyVariables>
<java.util.logging.manager>org.jboss.logmanager.LogManager</java.util.logging.manager>
<maven.home>${maven.home}</maven.home>
</systemPropertyVariables>
<argLine>-Djdk.tracePinnedThreads</argLine>
</configuration>
</plugin>
使用虚拟线程运行应用程序
java -jar target/quarkus-app/quarkus-run.jar
在 Java 21 之前,虚拟线程仍然是一项实验性功能,您需要使用 --enable-preview 标志启动应用程序。 |
为使用虚拟线程的应用程序构建容器
在本节中,我们使用 JIB 来构建容器。请参阅容器化指南,以了解有关替代方案的更多信息。
要容器化使用 @RunOnVirtualThread
的 Quarkus 应用程序,请将以下属性添加到您的 application.properties
中
quarkus.container-image.build=true
quarkus.container-image.group=<your-group-name>
quarkus.container-image.name=<you-container-name>
quarkus.jib.base-jvm-image=registry.access.redhat.com/ubi9/openjdk-21-runtime (1)
quarkus.jib.platforms=linux/amd64,linux/arm64 (2)
1 | 确保您使用的基本镜像支持虚拟线程。在这里,我们使用提供 Java 21 的镜像。如果您不设置,Quarkus 会自动选择提供 Java 21+ 的镜像。 |
2 | 选择目标架构。您可以选择多个架构来构建多架构镜像。 |
然后,像通常一样构建您的容器。例如,如果您使用的是 Maven,请运行
mvn package
将使用虚拟线程的 Quarkus 应用程序编译为本机可执行文件
使用本地 GraalVM 安装
要将利用 @RunOnVirtualThread
的 Quarkus 应用程序编译为本机可执行文件,您必须确保使用支持虚拟线程的 GraalVM / Mandrel native-image
,因此至少提供 Java 21。
按照本机编译指南中的指示构建本机可执行文件。例如,使用 Maven,运行
mvn package -Dnative
使用容器内构建
容器内构建允许通过使用在容器中运行的 native-image
编译器来构建 Linux 64 可执行文件。它可以避免在您的机器上安装 native-image
,并且还可以配置您需要的 GraalVM 版本。请注意,要使用容器内构建,您必须在您的机器上安装 Docker 或 Podman。
然后,添加到您的 application.properties
文件
# In-container build to get a linux 64 executable
quarkus.native.container-build=true (1)
1 | 启用容器内构建 |
从 ARM/64 到 AMD/64
如果您使用的是 Mac M1 或 M2(使用 ARM64 CPU),您需要注意,您使用容器内构建获得的本机可执行文件将是 Linux 可执行文件,但使用的是您的主机(ARM 64)架构。您可以使用模拟在使用 Docker 时使用以下属性强制执行架构
请注意,这会增加编译时间…很多(> 10 分钟)。 |
使用虚拟线程容器化本机应用程序
要构建运行使用虚拟线程编译为本机可执行文件的 Quarkus 应用程序的容器,您必须确保具有 Linux/AMD64 可执行文件(如果是以 ARM 机器为目标,则为 ARM64)。
确保您的 application.properties
包含本机编译部分中解释的配置。
然后,像通常一样构建您的容器。例如,如果您使用的是 Maven,请运行
mvn package -Dnative
如果您想构建本机容器镜像并且已经有一个现有的本机镜像,您可以设置 -Dquarkus.native.reuse-existing=true ,本机镜像构建将不会重新运行。 |
在虚拟线程中使用复制的上下文
使用 @RunOnVirtualThread
注释的方法继承自原始复制的上下文(有关详细信息,请参见复制的上下文参考指南)。因此,由过滤器和拦截器写入复制的上下文中的数据(以及请求范围,因为请求范围存储在复制的上下文中)在方法执行期间可用(即使过滤器和拦截器未在虚拟线程上运行)。
但是,线程局部变量不会传播。
虚拟线程名称
默认情况下,虚拟线程在创建时没有线程名称,这对于调试和日志记录目的来说是不实用的。Quarkus 管理的虚拟线程被命名,并以 quarkus-virtual-thread-
为前缀。您可以自定义此前缀,或者完全禁用命名配置一个空值
quarkus.virtual-threads.name-prefix=
注入虚拟线程执行器
为了在虚拟线程上运行任务,Quarkus 管理一个内部的 ThreadPerTaskExecutor
。在极少数情况下,您需要直接访问此执行器,您可以使用 @VirtualThreads
CDI 限定符注入它
注入虚拟线程 ExecutorService 是实验性的,可能会在将来的版本中更改。 |
package org.acme;
import org.acme.fortune.repository.FortuneRepository;
import java.util.concurrent.ExecutorService;
import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import io.quarkus.logging.Log;
import io.quarkus.runtime.StartupEvent;
import io.quarkus.virtual.threads.VirtualThreads;
public class MyApplication {
@Inject
FortuneRepository repository;
@Inject
@VirtualThreads
ExecutorService vThreads;
void onEvent(@Observes StartupEvent event) {
vThreads.execute(this::findAll);
}
@Transactional
void findAll() {
Log.info(repository.findAllBlocking());
}
}
测试虚拟线程应用程序
如上所述,虚拟线程有一些限制,可能会严重影响您的应用程序性能和内存使用率。junit5-virtual-threads 扩展提供了一种在运行测试时检测绑定载体线程的方法。因此,您可以消除最重要的限制之一,或者了解问题。
要启用此检测
-
1) 将
junit5-virtual-threads
依赖项添加到您的项目中
<dependency> <groupId>io.quarkus.junit5</groupId> <artifactId>junit5-virtual-threads</artifactId> <scope>test</scope> </dependency>
-
2) 在您的测试用例中,添加
io.quarkus.test.junit5.virtual.VirtualThreadUnit
和io.quarkus.test.junit.virtual.ShouldNotPin
注解
@QuarkusTest @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @VirtualThreadUnit // Use the extension @ShouldNotPin // Detect pinned carrier thread class TodoResourceTest { // ... }
当您运行测试时(请记住使用 Java 21+),Quarkus 会检测到绑定的载体线程。发生这种情况时,测试失败。
@ShouldNotPin
也可以直接在方法上使用。
junit5-virtual-threads 还为不可避免的绑定情况提供了 @ShouldPin
注解。以下代码段演示了 @ShouldPin
注解的用法。
@VirtualThreadUnit // Use the extension
public class LoomUnitExampleTest {
CodeUnderTest codeUnderTest = new CodeUnderTest();
@Test
@ShouldNotPin
public void testThatShouldNotPin() {
// ...
}
@Test
@ShouldPin(atMost = 1)
public void testThatShouldPinAtMostOnce() {
codeUnderTest.pin();
}
}
虚拟线程指标
您可以通过将以下构件添加到您的应用程序来启用 Micrometer 虚拟线程绑定器
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-java21</artifactId>
</dependency>
此绑定器会跟踪绑定事件的数量以及未能启动或取消停放的虚拟线程的数量。有关更多信息,请参见MicroMeter 文档。
您可以通过在 application.properties
中设置以下属性来显式禁用绑定器
# The binder is automatically enabled if the micrometer-java21 dependency is present
quarkus.micrometer.binder.virtual-threads.enabled=false
此外,如果应用程序在不支持虚拟线程的 JVM 上运行(Java 21 之前),则绑定器会自动禁用。
您可以通过在 application.properties
中设置以下属性将标签与收集的指标相关联
quarkus.micrometer.binder.virtual-threads.tags=tag_1=value_1, tag_2=value_2