跳到主要内容

JEP 436:虚拟线程(第二次预览)

QWen Max 中英对照 JEP 436: Virtual Threads (Second Preview)

总结

向 Java 平台引入 虚拟线程。虚拟线程是一种轻量级线程,能够显著降低编写、维护和观察高吞吐量并发应用程序的难度。这是一个 预览 API

历史

虚拟线程作为预览功能由 JEP 425 提出,并在 JDK 19 中交付。本 JEP 提议进行第二次预览,以便有更多时间收集反馈并获取有关此功能的更多经验。

自第一个预览版以来的细微更改:

目标

  • 使以简单的每个请求一个线程风格编写的服务端应用程序能够以接近最优的硬件利用率进行扩展。

  • 使使用 java.lang.Thread API 的现有代码能够通过最少的修改来采用虚拟线程。

  • 使用现有的 JDK 工具轻松对虚拟线程进行故障排查、调试和性能分析。

非目标

  • 目标并非移除线程的传统实现,也不是静默地将现有应用程序迁移到使用虚拟线程。

  • 目标并非改变 Java 的基本并发模型。

  • 目标并非在 Java 语言或 Java 库中提供新的数据并行构造。Stream API 仍然是处理大规模数据集的首选方式。

动机

近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每条语句都是在线程内执行的,由于 Java 是多线程的,因此多个执行线程会同时发生。线程是 Java 的并发单元:一段顺序代码,与其他此类单元同时运行,并且在很大程度上独立于它们。每个线程都提供一个栈,用于存储局部变量和协调方法调用,以及在出现问题时提供上下文:异常由同一线程中的方法抛出和捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,而性能分析器则可视化多个线程的行为,以帮助理解它们的性能。

每请求一线程风格

服务器应用程序通常会处理相互独立的并发用户请求,因此,应用程序通过在请求的整个持续时间内专用于该请求的线程来处理请求是合理的。这种每个请求一个线程的风格易于理解、易于编程、易于调试和分析,因为它使用平台的并发单元来表示应用程序的并发单元。

服务器应用程序的可扩展性受 Little's Law 的约束,该定律将延迟、并发性和吞吐量联系起来:对于给定的请求处理时长(即延迟),应用程序同时处理的请求数量(即并发性)必须与到达率(即吞吐量)成比例增长。例如,假设一个平均延迟为 50 毫秒的应用程序通过同时处理 10 个请求实现了每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它需要同时处理 100 个请求。如果每个请求在其持续时间内都在一个线程中处理,那么为了跟上,线程的数量必须随着吞吐量的增长而增长。

遗憾的是,可用线程的数量是有限的,因为 JDK 将线程实现为围绕操作系统 (OS) 线程的包装器。操作系统线程成本很高,因此我们不能拥有太多线程,这使得该实现不适合每个请求一个线程的风格。如果每个请求在其持续时间内都占用一个线程,从而占用一个操作系统线程,那么线程的数量往往会在其他资源(例如 CPU 或网络连接)耗尽之前很久就成为限制因素。JDK 当前的线程实现将应用程序的吞吐量限制在硬件所能支持的水平之下。即使在线程池化的情况下也会发生这种情况,因为池化有助于避免启动新线程的高成本,但不会增加线程的总数。

使用异步风格提高可扩展性

一些希望充分利用硬件的开发人员放弃了每个请求一个线程的风格,转而采用线程共享的风格。请求处理代码在等待 I/O 操作完成时,不会从头到尾占用一个线程,而是将线程返回到池中,以便该线程可以服务其他请求。这种细粒度的线程共享——即代码仅在进行计算时持有线程,而在等待 I/O 时则不持有线程——允许大量的并发操作,而无需消耗大量线程。虽然这种方式消除了由于操作系统线程稀缺而对吞吐量施加的限制,但其代价高昂:它需要所谓的 异步 编程风格,使用一组独立的 I/O 方法,这些方法不会等待 I/O 操作完成,而是在稍后通过回调信号通知完成。没有专用线程的情况下,开发人员必须将他们的请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成一个顺序流水线(例如,参见 CompletableFuture,或所谓的“响应式”框架)。因此,他们放弃了语言的基本顺序组合操作符,例如循环和 try/catch 块。

在异步风格中,请求的每个阶段可能会在不同的线程上执行,并且每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为有着深远的影响:堆栈跟踪无法提供可用的上下文,调试器无法逐步执行请求处理逻辑,分析器也无法将操作的成本与其调用者关联起来。当使用 Java 的 stream API 在短管道中处理数据时,组合 lambda 表达式是可控的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台相冲突,因为应用程序的并发单元 —— 异步管道 —— 不再是平台的并发单元。

使用虚拟线程保留每个请求一个线程的风格

为了使应用程序在扩展的同时仍能与平台保持协调,我们应该通过更高效地实现线程来努力保留每个请求一个线程的风格,从而使线程更加丰富。操作系统无法更高效地实现操作系统线程,因为不同的语言和运行时以不同的方式使用线程栈。然而,Java 运行时可以通过实现 Java 线程的方式来切断它们与操作系统线程的一对一对应关系。正如操作系统通过将较大的虚拟地址空间映射到有限的物理 RAM 来营造出内存充足的假象一样,Java 运行时也可以通过将大量的虚拟线程映射到少量的操作系统线程来营造出线程充足的假象。

虚拟线程java.lang.Thread 的一个实例,它不绑定到特定的操作系统线程。相比之下,平台线程 是以传统方式实现的 java.lang.Thread 的一个实例,作为操作系统线程的一个轻量级包装器。

采用线程每请求(thread-per-request)风格的应用代码可以在整个请求期间运行在一个虚拟线程中,但该虚拟线程仅在 CPU 上执行计算时才会占用一个操作系统线程。其结果是实现了与异步风格相同的可扩展性,只不过这种方式是透明完成的:当运行在虚拟线程中的代码调用 java.* API 中的阻塞 I/O 操作时,运行时会执行一个非阻塞的操作系统调用,并自动挂起该虚拟线程,直到稍后可以恢复为止。对于 Java 开发者来说,虚拟线程就是创建成本低廉且几乎无限丰富的线程。硬件利用率接近最优水平,从而允许高度并发并实现高吞吐量,同时应用程序仍然与 Java 平台及其工具链的多线程设计保持和谐一致。

虚拟线程的影响

虚拟线程便宜且数量众多,因此绝不应该被池化:每个应用程序任务都应该创建一个新的虚拟线程。因此,大多数虚拟线程的生命周期都很短,并且调用栈较浅,可能仅执行单个 HTTP 客户端调用或单个 JDBC 查询这样的少量操作。相比之下,平台线程较为重量级且成本高昂,因此通常需要池化。它们往往具有较长的生命周期、较深的调用栈,并且会被多个任务共享。

总之,虚拟线程保留了与 Java 平台设计相协调的可靠的每个请求一个线程的风格,同时还能最优地利用硬件。使用虚拟线程不需要学习新的概念,但可能需要摒弃为应对当前线程高成本而养成的习惯。虚拟线程不仅会帮助应用程序开发者 —— 它们还会帮助框架设计者提供易于使用的 API,这些 API 与平台的设计兼容,同时不牺牲可扩展性。

描述

如今,JDK 中的每个 java.lang.Thread 实例都是平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内捕获该操作系统线程。平台线程的数量受限于操作系统线程的数量。

虚拟线程java.lang.Thread 的一个实例,它在底层操作系统线程上运行 Java 代码,但不会在整个代码生命周期内独占该操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行它们的 Java 代码,从而有效地共享该线程。平台线程会独占宝贵的操作系统线程,而虚拟线程则不会。虚拟线程的数量可以远远超过操作系统线程的数量。

虚拟线程是一种轻量级的线程实现,由 JDK 提供而非操作系统提供。它们是一种 用户模式线程,在其他多线程语言中已经取得了成功(例如,Go 语言中的 goroutine 和 Erlang 中的进程)。用户模式线程甚至在早期版本的 Java 中以所谓的 "绿色线程" 出现过,那时操作系统的线程还不成熟和普及。然而,Java 的绿色线程都共享一个操作系统线程(M:1 调度),最终被平台线程超越,后者是作为操作系统线程的包装器实现的(1:1 调度)。虚拟线程采用 M:N 调度,其中大量的虚拟线程(M)被调度到较少的操作系统线程(N)上运行。

使用虚拟线程与平台线程

开发者可以选择使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。该程序首先获取一个 ExecutorService,它会为每个提交的任务创建一个新的虚拟线程。然后,程序提交 10,000 个任务,并等待所有任务完成:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1));
return i;
});
});
} // executor.close() is called implicitly, and waits

此示例中的任务很简单 —— 代码休眠一秒 —— 现代硬件可以轻松支持 10,000 个运行此类代码的虚拟线程并发执行。在幕后,JDK 在少量的操作系统线程上运行该代码,可能只有一个。

如果这个程序使用为每个任务创建一个新平台线程的 ExecutorService,例如 Executors.newCachedThreadPool(),情况将会大不相同。ExecutorService 将尝试创建 10,000 个平台线程,从而创建 10,000 个操作系统线程,具体取决于机器和操作系统,程序可能会崩溃。

如果程序转而使用从池中获取平台线程的 ExecutorService,例如 Executors.newFixedThreadPool(200),情况也不会好多少。ExecutorService 会创建 200 个平台线程供所有 10,000 个任务共享,因此许多任务将按顺序运行而非并发执行,导致程序需要很长时间才能完成。对于这个程序,拥有 200 个平台线程的线程池只能实现每秒 200 个任务的吞吐量,而虚拟线程则可以实现大约每秒 10,000 个任务的吞吐量(在充分预热后)。此外,如果示例程序中的 10_000 改为 1_000_000,那么程序将会提交 1,000,000 个任务,创建 1,000,000 个并发运行的虚拟线程,并且(在充分预热后)实现大约每秒 1,000,000 个任务的吞吐量。

如果此程序中的任务进行了耗时一秒的计算(例如,对一个巨大的数组进行排序),而不仅仅是休眠,那么将线程数量增加到超过处理器核心的数量并不会带来帮助,无论这些线程是虚拟线程还是平台线程。虚拟线程并不是更快的线程——它们运行代码的速度并不比平台线程快。它们的存在是为了提供扩展性(更高的吞吐量),而不是速度(更低的延迟)。根据 Little's Law,虚拟线程的数量可以远远多于平台线程,因此它们能够实现更高并发,从而提高吞吐量。

换句话说,虚拟线程可以在以下情况下显著提高应用程序的吞吐量

  • 并发任务的数量较多(超过几千个),并且
  • 工作负载不是 CPU 密集型的,因为如果线程数远超处理器核心数,是无法提高吞吐量的。

虚拟线程有助于提高典型服务器应用程序的吞吐量,正是因为此类应用程序由大量并发任务组成,而这些任务大部分时间都处于等待状态。

虚拟线程可以运行平台线程能够运行的任何代码。特别是,虚拟线程支持线程局部变量和线程中断,这一点与平台线程相同。这意味着处理请求的现有 Java 代码将很容易在虚拟线程中运行。许多服务器框架会选择自动执行此操作,为每个传入请求启动一个新的虚拟线程,并在其内运行应用程序的业务逻辑。

以下是一个服务器应用程序的示例,该程序聚合了另外两个服务的结果。一个假设的服务器框架(未显示)会为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序的 handle 代码。而应用程序代码则会依次创建两个新的虚拟线程,通过与第一个示例相同的 ExecutorService 并发获取资源:

void handle(Request request, Response response) {
var url1 = ...
var url2 = ...

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future1 = executor.submit(() -> fetchURL(url1));
var future2 = executor.submit(() -> fetchURL(url2));
response.send(future1.get() + future2.get());
} catch (ExecutionException | InterruptedException e) {
response.fail(e);
}
}

String fetchURL(URL url) throws IOException {
try (var in = url.openStream()) {
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}

像这样具有简单阻塞代码的服务器应用程序之所以能够很好地扩展,是因为它可以利用大量的虚拟线程。

Executor.newVirtualThreadPerTaskExecutor() 并不是创建虚拟线程的唯一方法。新的 java.lang.Thread.Builder API(将在下文 讨论)可以创建并启动虚拟线程。此外,结构化并发 提供了一个更强大的 API 来创建和管理虚拟线程,尤其是在类似于本服务器示例的代码中,通过它可以让平台及其工具了解线程之间的关系。

虚拟线程是一个 预览 API,默认情况下处于禁用状态

上面的程序使用了 Executors.newVirtualThreadPerTaskExecutor() 方法,因此要在 JDK 20 上运行它们,必须按如下方式启用预览 API:

  • 使用 javac --release 20 --enable-preview Main.java 编译程序,并使用 java --enable-preview Main 运行它;或者,

  • 当使用 源代码启动器 时,使用 java --source 20 --enable-preview Main.java 运行程序;或者,

  • 当使用 jshell 时,使用 jshell --enable-preview 启动它。

不要对虚拟线程进行池化

开发者通常会将应用程序代码从基于线程池的传统 ExecutorService 迁移到每个任务使用虚拟线程的 ExecutorService。线程池和其他资源池一样,旨在共享昂贵的资源,但虚拟线程并不昂贵,也永远不需要对它们进行池化。

开发者有时会使用线程池来限制对有限资源的并发访问。例如,如果一项服务无法处理超过 20 个并发请求,那么通过提交到大小为 20 的线程池中的任务来执行对该服务的所有访问,就可以确保这一点。由于平台线程的高成本使得线程池无处不在,这种惯用模式也变得非常普遍,但开发者不应为了限制并发而试图对虚拟线程进行池化。应该使用专门为该目的设计的构造(例如信号量)来保护对有限资源的访问。这比线程池更有效、更方便,而且也更安全,因为不存在线程本地数据意外从一个任务泄漏到另一个任务的风险。

观察虚拟线程

编写清晰的代码并不是全部。对于故障排除、维护和优化而言,对运行中程序状态的清晰呈现同样至关重要。JDK 长期以来一直提供调试、分析和监控线程的机制。这类工具也应对虚拟线程提供相同的支持 —— 可能需要考虑到它们的数量较多 —— 毕竟,它们也是 java.lang.Thread 的实例。

Java 调试器可以逐步执行虚拟线程,显示调用栈,并检查栈帧中的变量。JDK Flight Recorder(JFR),作为 JDK 的低开销性能分析和监控机制,能够将来自应用程序代码的事件(如对象分配和 I/O 操作)与正确的虚拟线程关联起来。这些工具无法对以异步风格编写的应用程序执行这些操作。在这种风格中,任务与线程无关,因此调试器无法显示或操作任务的状态,性能分析器也无法得知任务花费多少时间等待 I/O。

线程转储是另一个用于排查以线程每请求(thread-per-request)风格编写的应用程序的流行工具。不幸的是,使用 jstackjcmd 获取的 JDK 传统线程转储展示的是一个扁平的线程列表。这种方式适用于几十或几百个平台线程,但不适用于成千上万甚至数百万的虚拟线程。因此,我们不会扩展传统的线程转储以包含虚拟线程,而是会在 jcmd 中引入一种新型的线程转储,将虚拟线程与平台线程一起呈现,并以有意义的方式分组。当程序使用结构化并发时,可以展示线程之间更丰富的关系。

因为可视化和分析大量线程能够从工具中获益,jcmd 除了可以以纯文本格式输出新的线程转储外,还可以以 JSON 格式输出:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新的线程转储格式列出了阻塞在网络 I/O 操作中的虚拟线程,以及由上述每任务新建线程的 ExecutorService 创建的虚拟线程。它不包括传统线程转储中出现的对象地址、锁、JNI 统计信息、堆统计信息和其他信息。此外,由于可能需要列出大量线程,生成新的线程转储不会暂停应用程序。

以下是此类线程转储的一个示例,取自一个类似于上面的第二个示例的应用程序,并在 JSON 查看器中呈现(点击放大):

由于虚拟线程是在 JDK 中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统是不可见的,操作系统也感知不到它们的存在。操作系统级别的监控会观察到,JDK 进程使用的操作系统线程数比虚拟线程的数量要少。

调度虚拟线程

线程要执行有用的工作,就需要被调度,即分配到处理器核心上执行。对于平台线程(作为操作系统线程实现),JDK 依赖于操作系统中的调度器。相比之下,对于虚拟线程,JDK 拥有自己的调度器。JDK 的调度器并不会直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的 M:N 调度)。随后,平台线程会像往常一样由操作系统进行调度。

JDK 的虚拟线程调度器是一个工作窃取型的 ForkJoinPool,它以 FIFO 模式运行。调度器的 parallelism(并行度)是指可用于调度虚拟线程的平台线程数量。默认情况下,它等于 可用处理器 的数量,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism 进行调整。需要注意的是,这个 ForkJoinPool公共池 是不同的,后者例如在并行流的实现中使用,并且以 LIFO 模式运行。

调度器分配给虚拟线程的平台线程被称为该虚拟线程的载体(carrier)。在虚拟线程的生命周期内,它可以被调度到不同的载体上;换句话说,调度器不会在虚拟线程和任何特定的平台线程之间保持亲和性(affinity)。从 Java 代码的角度来看,一个正在运行的虚拟线程在逻辑上独立于其当前的载体:

  • 虚拟线程无法获取载体的身份。Thread.currentThread() 返回的值始终是虚拟线程本身。

  • 载体和虚拟线程的堆栈跟踪是分开的。在虚拟线程中抛出的异常不会包含载体的堆栈帧。线程转储不会在虚拟线程的堆栈中显示载体的堆栈帧,反之亦然。

  • 载体的线程局部变量对虚拟线程不可用,反之亦然。

此外,从 Java 代码的角度来看,虚拟线程与其载体暂时共享一个 OS 线程这一事实是不可见的。然而,从原生代码的角度来看,虚拟线程及其载体都运行在同一个原生线程上。因此,在同一个虚拟线程上多次调用的原生代码可能会在每次调用时观察到不同的 OS 线程标识符。

调度器目前没有为虚拟线程实现时间共享。时间共享是指强制抢占已消耗分配的 CPU 时间的线程。尽管当平台线程数量相对较少且 CPU 利用率达到 100% 时,时间共享可以有效减少某些任务的延迟,但尚不清楚在存在一百万条虚拟线程的情况下,时间共享是否同样有效。

执行虚拟线程

要利用虚拟线程,并不需要重写你的程序。虚拟线程不需要也不期望应用程序代码显式地将控制权交还给调度器;换句话说,虚拟线程不是协作式的。用户代码不得假设虚拟线程如何或何时被分配给平台线程,就像它不应对平台线程如何或何时被分配给处理器核心做出假设一样。

要在线程中运行代码,JDK 的虚拟线程调度器会通过将虚拟线程 挂载 到平台线程上来分配虚拟线程以在平台线程上执行。这使得平台线程成为虚拟线程的载体。之后,在运行一些代码后,虚拟线程可以从其载体 卸载。此时,平台线程被释放,因此调度器可以将另一个虚拟线程挂载到该平台线程上,从而再次使其成为载体。

通常,虚拟线程在因 I/O 或 JDK 中的其他阻塞操作(例如 BlockingQueue.take())而阻塞时会卸载。当阻塞操作准备完成时(例如,套接字上已接收到字节),它会将虚拟线程提交回调度器,调度器会将虚拟线程挂载到一个载体上以恢复执行。

虚拟线程的挂载与卸载频繁且透明地发生,并且不会阻塞任何操作系统线程。例如,前面展示的服务器应用程序包含以下一行代码,其中包含对阻塞操作的调用:

response.send(future1.get() + future2.get());

这些操作将导致虚拟线程多次挂载和卸载,通常每次调用 get() 时都会进行一次,而在 send(...) 中执行 I/O 操作时可能还会进行多次。

JDK 中的绝大多数阻塞操作都会卸载虚拟线程,从而释放其载体以及底层的操作系统线程以接受新的任务。然而,JDK 中的一些阻塞操作并不会卸载虚拟线程,因此会同时阻塞其载体和底层的操作系统线程。这是由于操作系统层面(例如,许多文件系统操作)或 JDK 层面(例如,Object.wait())的限制所致。这些阻塞操作的实现将通过临时增加调度器的并行度来补偿对操作系统线程的占用。因此,调度器的 ForkJoinPool 中的平台线程数量可能会暂时超过可用处理器的数量。调度器可用的最大平台线程数量可以通过系统属性 jdk.virtualThreadScheduler.maxPoolSize 进行调整。

有两种情况,虚拟线程在阻塞操作期间无法从其载体卸载,因为它被“固定”到了载体上:

  1. 当它执行 synchronized 块或方法中的代码时,或者
  2. 当它执行一个 native 方法或 外部函数 时。

固定不会使应用程序出错,但可能会妨碍其可扩展性。如果一个虚拟线程在被固定时执行了阻塞操作(如 I/O 或 BlockingQueue.take()),那么它的载体以及底层的操作系统线程将在操作期间被阻塞。长时间频繁的固定会通过捕获载体而损害应用程序的可扩展性。

调度器不会通过扩展其并行性来补偿线程绑定。相反,应该通过修改频繁运行且保护潜在长时间 I/O 操作的 synchronized 块或方法,改用 java.util.concurrent.locks.ReentrantLock,以避免频繁和长时间的线程绑定。对于不常使用的(例如,仅在启动时执行)或保护内存操作的 synchronized 块和方法,则无需替换。一如既往,应努力保持锁定策略的简单和清晰。

新的诊断功能有助于将代码迁移到虚拟线程,并帮助评估是否应该用 java.util.concurrent 锁替换某个特定的 synchronized 用法:

  • 当线程在固定(pinned)状态下阻塞时,会发出一个 JDK Flight Recorder (JFR) 事件(参见 JDK Flight Recorder)。

  • 系统属性 jdk.tracePinnedThreads 会在某个线程在固定状态下阻塞时触发堆栈跟踪。使用 -Djdk.tracePinnedThreads=full 运行时,当线程在固定状态下阻塞时会打印完整的堆栈跟踪,并突出显示本地帧和持有监视器的帧。使用 -Djdk.tracePinnedThreads=short 运行时,则将输出限制为仅包含有问题的帧。

在未来的版本中,我们或许能够消除上述第一个限制(在 synchronized 内部固定)。为了与原生代码正确交互,第二个限制是必需的。

内存使用与垃圾回收的交互

虚拟线程的栈以 stack chunk 对象的形式存储在 Java 的垃圾回收堆中。随着应用程序的运行,这些栈会动态增长和收缩,这既是为了提高内存效率,也为了适应任意深度的栈(最高可达 JVM 配置的平台线程栈大小)。这种效率使得大量虚拟线程成为可能,从而保证了线程-per-请求模式在服务器应用中的持续可行性。

上面的第二个示例中,回想一下一个假设的框架通过创建一个新的虚拟线程并调用 handle 方法来处理每个请求;即使它在深度调用栈的末尾调用 handle(在认证、事务等之后),handle 本身也会生成多个仅执行短时任务的虚拟线程。因此,对于每个具有深度调用栈的虚拟线程,将会有多个具有浅调用栈的虚拟线程,消耗极少的内存。

一般来说,虚拟线程所需的堆空间和垃圾收集器活动量与异步代码的比较是困难的。一百万个虚拟线程至少需要一百万个对象,但共享平台线程池的一百万个任务也是如此。此外,处理请求的应用程序代码通常会在 I/O 操作之间保持数据。每个请求一个线程的代码可以将这些数据保存在局部变量中,这些变量存储在堆中的虚拟线程栈上,而异步代码则必须将相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的栈帧布局比紧凑的对象更浪费;另一方面,虚拟线程在许多情况下(取决于低级别的 GC 交互)可以改变和重用它们的栈,而异步管道总是需要分配新对象,因此虚拟线程可能需要更少的分配。总体而言,每个请求一个线程与异步代码的堆消耗和垃圾收集器活动应该大致相似。随着时间的推移,我们期望使虚拟线程栈的内部表示显著更加紧凑。

与平台线程栈不同,虚拟线程栈不是 GC(垃圾回收)根对象,因此其中包含的引用不会被执行并发堆扫描的垃圾回收器(如 G1)在“世界停止”暂停中遍历。这也意味着,如果一个虚拟线程阻塞在例如 BlockingQueue.take() 上,并且没有其他线程可以获得对该虚拟线程或队列的引用,那么该线程就可以被垃圾回收 —— 这是完全可以的,因为虚拟线程永远无法被中断或解除阻塞。当然,如果虚拟线程正在运行,或者它处于阻塞状态并且有可能被解除阻塞,那么它是不会被垃圾回收的。

虚拟线程目前的一个限制是 G1 垃圾回收器不支持超大的栈块对象。如果一个虚拟线程的栈达到区域大小的一半(可能小到 512 KB),就可能会抛出 StackOverflowError

详细更改

其余小节详细描述了我们针对 Java 平台及其实施提出的变更:

java.lang.Thread

我们对 java.lang.Thread API 进行了如下更新:

java.lang.Thread API 在这个 JEP 中没有其他变化。Thread 类定义的构造函数像以前一样创建平台线程。没有新的公共构造函数。

Thread 类中有三种方法对虚拟线程抛出 UnsupportedOperationException 异常 —— stop()suspend()resume() —— 在 JDK 20 中将进行更改,针对平台线程同样抛出 UnsupportedOperationException 异常。这一变更与本 JEP 无关。)

虚拟线程与平台线程之间的主要 API 差异包括:

  • 公共的 Thread 构造函数无法创建虚拟线程。

  • 虚拟线程始终是守护线程。Thread.setDaemon(boolean) 方法不能将虚拟线程更改为非守护线程。

  • 虚拟线程具有固定的优先级 Thread.NORM_PRIORITYThread.setPriority(int) 方法对虚拟线程无效。此限制可能会在未来的版本中重新考虑。

  • 虚拟线程不是线程组的活动成员。当在虚拟线程上调用 Thread.getThreadGroup() 时,返回一个名为 "VirtualThreads" 的占位符线程组。Thread.Builder API 没有定义设置虚拟线程组的方法。

  • 在设置了 SecurityManager 的情况下运行时,虚拟线程没有任何权限。

线程局部变量

虚拟线程支持线程局部变量(ThreadLocal)和可继承的线程局部变量(InheritableThreadLocal),与平台线程相同,因此它们可以运行使用线程局部变量的现有代码。然而,由于虚拟线程的数量可能非常庞大,使用线程局部变量时需要仔细权衡。特别是,不要在线程池中通过线程局部变量在线程共享的多个任务之间池化昂贵的资源。虚拟线程绝不应该被池化,因为每个虚拟线程在其生命周期内只应运行单个任务。为了准备支持虚拟线程并减少在运行数百万线程时的内存占用,我们已经从 java.base 模块中移除了许多线程局部变量的使用。

此外:

作用域值(JEP 429)在某些使用场景下可能证明是比线程局部变量更好的替代方案。

java.util.concurrent

支持锁定的原始 API java.util.concurrent.LockSupport 现在支持虚拟线程:停放虚拟线程会释放底层的平台线程以执行其他工作,而取消停放虚拟线程则会安排它继续执行。对 LockSupport 的这一更改使得所有使用它的 API(LockSemaphore、阻塞队列等)在虚拟线程中调用时能够优雅地停放。

此外:

网络

java.netjava.nio.channels 包中的网络 API 实现现在可以与虚拟线程一起工作:在虚拟线程上执行阻塞操作时(例如,建立网络连接或从套接字读取数据),底层的平台线程会被释放以执行其他任务。

为了实现中断和取消操作,现在规定在虚拟线程中调用由 java.net.SocketServerSocketDatagramSocket 定义的阻塞 I/O 方法时是可中断的:当中断一个在套接字上阻塞的虚拟线程时,将解除该线程的阻塞状态并关闭套接字。当从 InterruptibleChannel 获取这些类型的套接字时,其阻塞 I/O 操作一直是可中断的,因此这一更改使得通过构造函数创建这些 API 时的行为与其从通道获取时的行为保持一致。

java.io

java.io 包为字节和字符流提供了 API。这些 API 的实现大量使用了同步,因此在虚拟线程中使用时需要进行更改以避免固定。

作为背景,面向字节的输入/输出流并未规定为线程安全,并且当一个线程在读或写方法中被阻塞时调用 close() 的预期行为也未定义。在大多数情况下,从多个并发线程使用特定的输入或输出流是没有意义的。面向字符的读取器/写入器同样未规定为线程安全,但它们确实为子类暴露了一个锁对象。除了固定(pinning)之外,这些类中的同步是有问题且不一致的;例如,InputStreamReaderOutputStreamWriter 使用的流解码器和编码器会在流对象上进行同步,而不是在锁对象上。

为了防止固定,这些实现现在的工作方式如下:

  • BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriterPrintStreamPrintWriter 在直接使用时现在使用显式锁而不是监视器。当这些类被子类化时,它们像以前一样进行同步。

  • InputStreamReaderOutputStreamWriter 使用的流解码器和编码器现在使用与外围的 InputStreamReaderOutputStreamWriter 相同的锁。

进一步深入并消除所有这些通常不必要的锁定超出了此 JEP 的范围。

此外,BufferedOutputStreamBufferedWriter 以及 OutputStreamWriter 的流编码器所使用的缓冲区初始大小现在变得更小,以减少在堆中存在大量流或写入器时的内存使用量 —— 这种情况可能出现在有一百万个虚拟线程时,每个线程都在套接字连接上带有一个缓冲流。

Java 本地接口 (JNI)

JNI 定义了一个新的函数 IsVirtualThread,用于测试对象是否为虚拟线程。

JNI 规范的其他部分没有变化。

调试

调试架构由三个接口组成:JVM 工具接口(JVM TI)、Java 调试线协议(JDWP)和 Java 调试接口(JDI)。这三个接口现在都支持虚拟线程。

JVM TI 的更新包括:

  • 大多数使用 jthread(即,对 Thread 对象的 JNI 引用)调用的函数可以使用对虚拟线程的引用来调用。少数函数,即 PopFrameForceEarlyReturnStopThreadAgentStartFunctionGetThreadCpuTime,不支持虚拟线程。SetLocal* 函数仅限于在因断点或单步事件而挂起的虚拟线程的最顶层帧中设置局部变量。

  • 现在指定 GetAllThreadsGetAllStackTraces 函数返回所有平台线程,而不是所有线程。

  • 所有事件,除了在早期 VM 启动期间或堆迭代期间发布的事件,都可以在虚拟线程的上下文中调用事件回调。

  • 挂起/恢复实现允许调试器挂起和恢复虚拟线程,并且当虚拟线程挂载时允许平台线程被挂起。

  • 一个新的功能 can_support_virtual_threads,使代理能够更精细地控制虚拟线程的线程启动和结束事件。

  • 新函数支持批量挂起和恢复虚拟线程;这些需要 can_support_virtual_threads 功能。

现有的 JVM TI 代理大多会像以前一样工作,但如果调用不支持虚拟线程的函数,可能会遇到错误。当一个不了解虚拟线程的代理与使用虚拟线程的应用程序一起使用时,就会出现这些问题。对于某些代理来说,GetAllThreads 的更改可能会返回一个仅包含平台线程的数组,这可能是一个问题。现有的启用了 ThreadStartThreadEnd 事件的代理可能会遇到性能问题,因为它们无法将这些事件限制为仅限平台线程。

JDWP 的更新包括:

  • 一个新的命令允许调试器测试线程是否为虚拟线程。

  • EventRequest 命令中的一个新的修饰符允许调试器将线程启动和结束事件限制为平台线程。

JDI 的更新内容包括:

如上所述,虚拟线程不被视为线程组中的活动线程。因此,由 JVM TI 函数 GetThreadGroupChildren、JDWP 命令 ThreadGroupReference/Children 以及 JDI 方法 com.sun.jdi.ThreadGroupReference.threads() 返回的线程列表仅包含平台线程。

JDK Flight Recorder (JFR)

JFR 支持虚拟线程,并具有以下几个新事件:

  • jdk.VirtualThreadStartjdk.VirtualThreadEnd 表示虚拟线程的启动和结束。这些事件默认是禁用的。

  • jdk.VirtualThreadPinned 表示一个虚拟线程在被固定(pinned)时被停放(parked),即没有释放其平台线程(参见 讨论)。此事件默认启用,阈值为 20 ms。

  • jdk.VirtualThreadSubmitFailed 表示启动或解除停放虚拟线程失败,可能是由于资源问题。此事件默认启用。

Java 管理扩展 (JMX)

java.lang.management.ThreadMXBean 仅支持对平台线程的监控和管理。findDeadlockedThreads() 方法可以找到陷入死锁的平台线程的循环,但它无法找到陷入死锁的虚拟线程的循环。

com.sun.management.HotSpotDiagnosticsMXBean 中的新方法生成了上述 observability 中描述的新式线程转储。此方法还可以通过平台 MBeanServer 从本地或远程 JMX 工具间接调用。

替代方案

  • 继续依赖异步 API。异步 API 难以与同步 API 集成,创建了同一个 I/O 操作的两种表示形式的分裂世界,并且没有提供平台可以用于故障排除、监控、调试和分析目的的操作序列的统一概念。

  • 向 Java 语言添加语法无栈协程(即 async/await)。这些比用户模式线程更容易实现,并且会提供一个统一的结构,表示操作序列的上下文。

    然而,这种结构是新的,与线程分开,虽然在许多方面与线程相似,但在某些细微之处有所不同。它将在为线程设计的 API 和为协程设计的 API 之间分裂世界,并需要将新的类似线程的结构引入平台及其工具的所有层。这将需要更长的时间让生态系统采用,并且不如用户模式线程那样优雅并与平台和谐。

    大多数采用语法协程的语言之所以这样做,是由于无法实现用户模式线程(例如 Kotlin)、遗留语义保证(例如本质上单线程的 JavaScript)或语言特定的技术限制(例如 C++)。这些限制不适用于 Java。

  • 引入一个新的公共类来表示用户模式线程,与 java.lang.Thread 无关。这将是一个机会,可以抛弃 Thread 类在 25 年中积累的不需要的包袱。我们探索并原型化了这种方法的几个变体,但在每种情况下都遇到了如何运行现有代码的问题。

    主要问题是 Thread.currentThread() 在现有代码中被直接或间接地普遍使用(例如,在确定锁所有权或线程局部变量时)。此方法必须返回表示当前执行线程的对象。如果我们引入了一个新类来表示用户模式线程,那么 currentThread() 将不得不返回某种包装对象,看起来像 Thread,但委托给用户模式线程对象。

    让两个对象表示当前执行线程会令人困惑,因此我们最终得出结论,保留旧的 Thread API 并不是一个重大障碍。除了 currentThread() 等少数方法外,开发人员很少直接使用 Thread API;他们主要使用更高级别的 API,如 ExecutorService。随着时间的推移,我们将通过弃用和删除过时的方法,从 Thread 类及其相关类(如 ThreadGroup)中抛弃不需要的包袱。

测试

  • 现有的测试将确保我们在此处提出的更改不会导致它们在运行的众多配置和执行模式中出现任何意外的退化。

  • 我们将扩展 jtreg 测试工具,以允许在虚拟线程的上下文中运行现有的测试。这将避免需要对许多测试维护两个版本。

  • 新的测试将涵盖所有新的和修订的 API,以及为支持虚拟线程而更改的所有领域。

  • 新的压力测试将针对对可靠性与性能至关重要的领域。

  • 新的微基准测试将针对性能关键领域。

  • 我们将使用一些现有的服务器,包括 HelidonJetty,进行更大规模的测试。

风险与假设

该提案的主要风险在于现有 API 及其实现的变化可能引发的兼容性问题:

  • java.io.BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriterPrintStreamPrintWriter 类中使用的内部(且未文档化)锁定协议的修订可能会影响假定 I/O 方法在调用时同步流的代码。这些更改不会影响扩展这些类并假定由超类进行锁定的代码,也不会影响扩展 java.io.Readerjava.io.Writer 并使用这些 API 暴露的锁对象的代码。

一些源代码和二进制不兼容的变更可能会对扩展 java.lang.Thread 的代码产生影响:

  • Thread 定义了几个新方法。如果现有源文件中的代码扩展了 Thread,并且子类中的某个方法与新的 Thread 方法冲突,那么该文件在不修改的情况下将无法编译。

  • Thread.Builder 是一个新的嵌套接口。如果现有源文件中的代码扩展了 Thread,导入了一个名为 Builder 的类,并且子类中的代码以简单名称引用 Builder,那么该文件在不修改的情况下将无法编译。

  • Thread.isVirtual() 是一个新的 final 方法。如果存在扩展了 Thread 的已编译代码,并且子类声明了具有相同名称和返回类型的方法,那么在运行时加载子类时将会抛出 IncompatibleClassChangeError

在将现有代码与利用虚拟线程或新 API 的较新代码混合时,可以观察到平台线程和虚拟线程之间的一些行为差异:

  • Thread.setPriority(int) 方法对虚拟线程没有任何效果,虚拟线程的优先级始终为 Thread.NORM_PRIORITY

  • Thread.setDaemon(boolean) 方法对虚拟线程没有影响,虚拟线程始终是守护线程。

  • Thread API 支持创建不支持线程局部变量的线程。在不支持线程局部变量的线程上下文中调用 ThreadLocal.set(T)Thread.setContextClassLoader(ClassLoader) 时会抛出 UnsupportedOperationException

  • Thread.getAllStackTraces() 现在返回所有平台线程的映射,而不是所有线程的映射。

  • 在虚拟线程上下文中调用由 java.net.SocketServerSocketDatagramSocket 定义的阻塞 I/O 方法现在是可以中断的。当一个线程在套接字操作上被阻塞时被中断,现有的代码可能会中断,这将唤醒线程并关闭套接字。

  • 虚拟线程不是 ThreadGroup 的活跃成员。在虚拟线程上调用 Thread.getThreadGroup() 将返回一个空的虚拟 "VirtualThreads" 组。

  • 当使用设置了 SecurityManager 的情况下运行时,虚拟线程没有任何权限。

  • 在 JVM TI 中,GetAllThreadsGetAllStackTraces 函数不会返回虚拟线程。现有的启用 ThreadStartThreadEnd 事件的代理可能会遇到性能问题,因为它们无法将事件限制为仅限平台线程。

  • java.lang.management.ThreadMXBean API 支持对平台线程的监控和管理,但不支持虚拟线程。

  • -XX:+PreserveFramePointer 标志对虚拟线程性能有极大的负面影响。

依赖