Quarkus 与虚拟线程的邂逅
Java 21 提供了一项新功能,将重塑 Java 中并发应用程序的开发。两年多来,Quarkus 团队一直在探索集成这项新功能,以简化分布式应用程序(包括微服务和事件驱动应用程序)的开发。
这篇博文是一系列演示如何在 Quarkus 应用程序中使用虚拟线程的文章和视频的第一部分。该系列涵盖了 REST、消息传递、容器、原生编译以及我们的未来计划。但首先,让我们看看虚拟线程,它们带来了哪些改变,以及您应该了解它们哪些方面。
Java 21 之前的 Java 世界
在 Java 的早期,Java 有用户线程。用户线程是由 Java 虚拟机 (JVM) 调度的用户级线程,而不是由底层操作系统 (OS) 本机调度的。它们在不依赖本机 OS 功能的情况下模拟多线程环境。它们在用户空间而不是内核空间进行管理,这使得它们能够在不支持本机线程的环境中工作。用户线程在 Java 中于 1997 年至 2000 年间短暂可用。我用过用户线程;它们并没有给我留下美好的回忆。
在 2000 年发布的 Java 1.3 中,Java 迈出了重要一步,开始集成操作系统线程。因此,线程由操作系统管理。这仍然是我们今天使用的模型。每次 Java 应用程序创建一个线程时,都会创建一个平台线程,它包装一个 OS 线程。所以,创建平台线程就是创建 OS 线程,而阻塞平台线程就是阻塞 OS 线程。
当您使用 Java 应用程序框架时,您很少自己创建线程。它由框架为您完成。例如,当您的应用程序收到 HTTP 请求时,框架会创建或重用一个平台线程(以及一个 OS 线程),并在该线程上执行处理。整个处理在该线程上运行,直到处理完成(即响应发送回),该线程才能被重用。当处理执行阻塞 I/O 操作时,例如调用另一个服务、写入文件系统或与数据库交互,该线程就会被阻塞,等待响应。如上所述,OS 线程在等待时也会被阻塞。当收到此响应后,处理将继续。

这种模型的好处是易于编程。代码遵循命令式模型。代码按顺序执行。它简单易写,易于理解。例如,以下代码片段展示了如何使用 Quarkus 接收 HTTP 请求、调用另一个 HTTP 服务并返回响应。它遵循了上面的时序图。
@Path("/greetings")
public class ImperativeApp {
@RestClient RemoteService service;
@GET
public String process() {
// Runs on a worker (platform) thread because the
// method uses a synchronous signature
// `service` is a rest client, it executes an I/O operation
// (send an HTTP request and waits for the response)
var response = service.greetings(); // Blocking, it waits for the response
// The OS thread is also blocked
return response.toUpperCase();
}
}
但是,这种命令式模型有一个限制。您只能并发处理n个请求,其中n是框架可以创建的线程数。OS 线程成本高昂。它们消耗内存(每个线程约 1MB),创建成本高,使用 CPU 进行调度……框架使用线程池来允许重用空闲线程,但当并发级别超过您的线程数时,您就开始堆积请求,增加响应时间,最坏的情况下甚至拒绝请求。增加线程池大小,从而增加内存使用量,可能会导致您的云账单和部署密度爆炸。此外,正如利特尔法则所解释的那样,添加更多线程甚至可能无法提高并发性。
响应式方法提出了一种替代模型来解决这个问题。它提倡使用非阻塞 I/O 和异步开发模型来更有效地利用资源(CPU 和内存)。使用响应式模型,单个线程可以处理多个并发请求。因此,您不会拥有一个大型线程池,而是拥有最少数量的线程(通常等于 CPU 核心数)。这少量线程,通常称为事件循环,会处理您的所有请求。收到请求时,它会在其中一个线程上调用处理代码。当处理需要执行 I/O 操作时,它不会使用阻塞 I/O,而是安排操作并传递一个续调。这个续调是在 I/O 完成时要调用的代码,因此,基本上是处理的其余部分。

响应式模型效率很高,但有一个缺点。如前所述,您需要将代码编写为一系列续调。虽然有多种方法,例如回调、Future、响应式编程或协程,但这使得代码更难理解。代码的结构方式可能并非对所有开发人员都自然。这限制了该解决方案的采用。此外,代码不仅可以在 I/O 操作期间阻塞;它不能执行耗时的处理(我们称之为垄断)。该模型的效率来自同时处理许多请求的能力。如果线程被长时间使用,它就不允许处理其他请求,并且,就像命令式模型一样,您会开始堆积请求。
为了说明命令式模型和响应式模型之间的区别,以下代码片段与前面的片段等效:它接收一个 HTTP 请求,调用另一个 HTTP 服务,并返回响应。但这次,它使用了 Quarkus 的响应式模型。
@Path("/greetings")
public class ReactiveApp {
@RestClient RemoteService service;
@GET
public Uni<String> process() {
// Runs on an event loop (platform) thread because the
// method uses a asynchronous signature
// `service` is a rest client, it executes an I/O operation
// but this time it returns a Uni<String>, so it's not blocking
return service.asyncGreetings() // Non-blocking
.map(resp -> {
// This is the continuation, the code executed once the
// response is received
return resp.toUpperCase();
});
}
}
具有此代码的应用程序可以处理更多并发请求,并且使用的内存比命令式应用程序少,但是,开发模型不同。
大多数时候,响应式模型和命令式模型是相对立的。这不一定是这样。Quarkus 使用响应式核心,并允许您决定是使用响应式模型还是命令式模型。有关此功能的更多详细信息,请参阅“是阻塞还是不阻塞”文章。
虚拟线程带来了哪些改变?
Java 19 引入了一种新型线程:虚拟线程。在 Java 21 中,此 API 已正式发布。
但什么是虚拟线程?虚拟线程重用了响应式范式的思想,但允许命令式开发模型。您将获得响应式和命令式模型的优点,而没有缺点!
与用户线程一样,虚拟线程由 JVM 管理。虚拟线程在内存中占用的空间比平台线程少。因此,可以同时使用比平台线程更多的虚拟线程,而不会耗尽内存。虚拟线程被设计为可丢弃的实体,我们在需要时创建它们;不建议将它们用于不同的任务。
但这带来了什么改变?阻塞虚拟线程通常非常便宜!有一些魔力让虚拟线程非常有吸引力。当运行在虚拟线程上的代码需要执行 I/O 操作时,它会使用阻塞 API。所以,代码会等待结果,就像命令式模型一样。然而,由于 JVM 管理虚拟线程,当它们执行此阻塞操作时,不会阻塞底层的 OS 线程。虚拟线程的状态存储在堆中,并且另一个虚拟线程可以在同一个 Java 平台(载体)线程上执行,就像在响应式模型中一样。当 I/O 操作完成时,虚拟线程可以再次执行,并且当有可用的载体线程时,虚拟线程的状态将被恢复,并且执行将继续。对开发人员而言,这种魔力是不可见的!您只需编写同步代码,它就可以像真正的响应式代码一样执行,而不会阻塞 OS 线程。
您的代码运行在虚拟线程之上,但在底层,只有少数载体线程在执行它们。
总结一下,虚拟线程是:
-
轻量级 - 您可以拥有大量虚拟线程
-
创建成本低 - 无需再对它们进行池化
-
阻塞成本低,当使用阻塞操作时 - 阻塞虚拟线程不会阻塞底层 OS 线程,当执行 I/O 操作时
如何在 Quarkus 中使用虚拟线程?
在 Quarkus 中使用虚拟线程非常简单。您只需要使用@RunOnVirtualThread
注解。它指示 Quarkus 在虚拟线程而不是常规平台线程上调用带注解的方法。
这种新策略扩展了“是阻塞还是不阻塞”文章中解释的智能分派。除了签名之外,Quarkus 现在还查找此特定注解。如果您的 JVM 不支持虚拟线程,它将回退到平台线程。
让我们使用虚拟线程重写相同的示例(完整的代码可在此存储库中找到)。
@Path("/greetings")
public class VirtualThreadApp {
@RestClient RemoteService service;
@GET
@RunOnVirtualThread
public String process() {
// Runs on a virtual thread because the
// method uses the @RunOnVirtualThread annotation.
// `service` is a rest client, it executes an I/O operation
var response = service.greetings(); // Blocking, but this time, it
// does neither block the carrier thread
// nor the OS thread.
// Only the virtual thread is blocked.
return response.toUpperCase();
}
}
这是第一个代码片段(命令式代码),但其执行模型更接近响应式模型。

对于每个请求,都会创建一个虚拟线程。当载体线程空闲时,虚拟线程会挂载在该载体线程上并执行。当虚拟线程需要执行 I/O(调用远程服务)时,它只阻塞虚拟线程。载体线程被释放,并且可以挂载另一个虚拟线程(例如,在第一个虚拟线程的 I/O 挂起时处理第二个请求的线程)。当 I/O 完成时,载体线程(不一定是同一个线程)会恢复被阻塞的虚拟线程并继续其执行,直到响应准备好发送回客户端。代码片段如所述工作,因为 Quarkus REST 客户端对虚拟线程友好;我们将在下一节中看到异常。
Quarkus 中的虚拟线程不仅限于 HTTP 端点。以下代码片段展示了如何使用虚拟线程处理 Kafka/Pulsar/AMQP 消息。
@Incoming("events")
@Transactional
@RunOnVirtualThread
public void persistEventInDatabase(Event event) {
event.persist(); // Use Hibernate ORM with Panache
}
细心的读者可能已经注意到,虚拟线程集成依赖于响应式扩展。这些扩展提供了更大的灵活性(例如,对处理在哪个线程上执行的控制),以便正确有效地集成虚拟线程。重要的是要理解,对开发人员而言,它是不可见的(除了@RunOnVirtualThread
注解)。
在使用虚拟线程处理一切之前需要知道的五件事
是的,您可能已经预料到了。天下没有免费的午餐。在使用虚拟线程处理一切之前,您需要了解一些事情。这些就是为什么目前 Quarkus 中没有全局开关可以仅在虚拟线程上运行的原因。
1. 线程固定 (Pinning)
如上所述,当虚拟线程执行阻塞操作时,它会从载体线程上卸载,从而阻止载体线程被阻塞。但是,有时虚拟线程无法卸载,因为它的状态无法存储在堆中。当线程持有监视器锁或堆栈中包含本机调用时,就会发生这种情况。
Object monitor = new Object();
//...
public void aMethodThatPinTheCarrierThread() throws Exception {
synchronized(monitor) {
Thread.sleep(1000); // The virtual thread cannot be unmounted because it holds a lock,
// so the carrier thread is blocked.
}
}
在这种情况下,载体线程会被阻塞,因此 OS 线程也会被阻塞。

不幸的是,截至目前,许多 Java 库都会固定载体线程。Quarkus 团队和 Red Hat 总体上已经修补了许多库(例如 Narayana(Quarkus 的事务管理器)或 Hibernate ORM),以避免线程固定。但是,当您使用库时,请小心。直到所有代码都以更适合虚拟线程的方式重写还需要时间。
2. 线程垄断 (Monopolization)
与响应式模型一样,如果虚拟线程执行密集且耗时的计算,它就会垄断该载体。虚拟线程调度程序不是抢占式的。所以它无法中断正在运行的线程。它需要等待 I/O 或计算完成。在那之前,该载体线程无法执行其他虚拟线程。

当执行长时间计算时,使用专用的平台线程池可能会更明智。
3. 载体线程池弹性
当存在线程固定或垄断时,JVM 可能会创建新的载体线程(如上图所示)以避免出现过多未调度的虚拟线程。
这些创建正在创建平台/OS 线程。所以,这很昂贵且占用内存。您尤其需要注意第二点。如果您在资源有限的环境中运行,并且您的代码不适合虚拟线程,您可能会达到内存限制,这意味着您应该始终检查线程固定、线程垄断和内存使用情况。如果您不这样做,在内存受限的容器中,应用程序可能会被终止。
4. 对象池化
多年来,线程一直是稀缺资源。建议对它们进行池化和重用。这种良好的做法鼓励使用线程局部变量作为对象池化机制。像Jackson或 Netty 这样的许多库将昂贵的对象存储在线程局部变量中。这些对象只能由在存储对象的线程上运行的代码访问。由于线程数量有限,因此限制了创建数量。此外,由于线程被重用,对象被缓存和重用。不幸的是,这两个假设在虚拟线程下都不成立:您可以拥有很多虚拟线程,它们不会被重用。甚至不鼓励对它们进行池化。因此,利用这些池化模式的库在使用虚拟线程时可能会表现不佳。您将看到大量大型对象的分配,因为每个虚拟线程都会获得自己的对象实例。
替换这种模式并非易事。例如,Mario Fusco 的这个PR为 Jackson 提出了一个 SPI。Quarkus 将实现 SPI 以提供对虚拟线程友好的池化机制。
5. 强调线程安全
虚拟线程提供了一种在 Java 中构建并发应用程序的新方法。您不再受限于线程池中的线程数量。您不必使用异步开发模型。
但是,在重写您的应用程序以利用这种新机制之前,请确保代码是线程安全的。许多库和框架不允许对某些对象进行并发访问。例如,数据库连接不应被并发访问。当您有许多虚拟线程时,您必须格外小心,尤其是在使用结构化并发 API(Java 21 中仍处于预览状态)时。
在使用结构化并发时,可以轻松地并行运行任务。但是,您必须绝对确定这些任务不会访问不支持并发访问的共享状态。
@GET
@RunOnVirtualThread
public String structuredConcurrencyExample() throws InterruptedException, ExecutionException {
var someState = ... // Must be thread-safe, as multiple virtual thread will access
// it concurrently
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task1 = scope.fork(() -> {
// Run in another virtual thread
return someState.touch();
});
var task2 = scope.fork(() -> {
// Run in another virtual thread
return someState.touch();
});
scope.join().throwIfFailed();
return task1.get() + "/" + task2.get();
}
}
总结和后续
本文介绍了 Java 21 中提供的新类型线程以及如何在 Quarkus 中使用它们。虚拟线程并非万能药,虽然它们可以提高并发性,但您需要注意一些限制。
-
许多库都在固定载体线程;Java 世界变得对虚拟线程友好还需要时间。
-
必须谨慎分析耗时计算,以避免线程垄断问题。
-
载体线程池的弹性可能会导致内存使用量过高。
-
线程局部对象池化模式可能对分配和内存使用产生严重后果。
-
虚拟线程并不能消除线程安全问题。
这是多篇文章系列的第一部分(希望也是最枯燥乏味的部分)。接下来,我们将介绍:
-
我们正在探索哪些方面可以改进 Quarkus 中的虚拟线程支持(待发布)
要了解有关 Quarkus 中虚拟线程支持的更多信息,请参阅虚拟线程参考指南。