跳到主要内容

JEP 425:虚拟线程(预览版)

QWen Max 中英对照 JEP 425 Virtual Threads (Preview)

概述

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

目标

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

  • 使现有使用 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 的 流 API 在短管道中处理数据时,组合 lambda 表达式是可控的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台相冲突,因为应用程序的并发单元 —— 异步管道 —— 不再是平台的并发单元。

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

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

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

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

虚拟线程的影响

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

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

描述

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

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

虚拟线程是一种轻量级的线程实现,由 JDK 提供而非操作系统提供。它们是一种用户模式线程的形式,在其他多线程语言中已经取得了成功(例如,Go 语言中的 goroutines 和 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 来创建和管理虚拟线程,尤其是在类似于本服务器示例的代码中,通过它可以让平台及其工具了解线程之间的关系。

QWen Max 中英对照 JEP 425 Virtual Threads (Preview)

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

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

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

  • 当使用 源代码启动器 时,使用 java --source 19 --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 dump)是另一个广泛使用的工具,用于排查以每个请求一个线程风格编写的应用程序问题。然而,通过 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 模式运行。调度器的 并行度 是指可用于调度虚拟线程的平台线程数量。默认情况下,它等于 可用处理器 的数量,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism 进行调整。需要注意的是,这个 ForkJoinPool公共池 不同,后者例如在并行流的实现中使用,并且以 LIFO 模式运行。

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

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

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

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

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

调度器目前并未为虚拟线程实现时间共享。时间共享是指对已消耗分配的 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)的根对象,因此其中包含的引用不会在诸如 G1 这类执行并发堆扫描的垃圾回收器进行“全局暂停”时被遍历。这也意味着,如果一个虚拟线程阻塞在例如 BlockingQueue.take() 上,并且没有其他线程能够获取到该虚拟线程或队列的引用,那么这个线程是可以被垃圾回收的 —— 这没有问题,因为虚拟线程永远不会被中断或解除阻塞。当然,如果虚拟线程正在运行,或者它处于阻塞状态但有可能被解除阻塞,那么它就不会被垃圾回收。

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

详细变更

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

java.lang.Thread

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

java.lang.Thread API 其他方面保持不变。Thread 类定义的构造函数像以前一样创建平台线程。没有新的公共构造函数。

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

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

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

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

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

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

  • 虚拟线程不支持 stop()suspend()resume() 方法。在虚拟线程上调用这些方法会抛出异常。

线程本地变量

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

此外:

范围局部变量 对于某些用例来说,可能比线程局部变量是更好的替代方案。

java.util.concurrent

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

此外:

  • Executors.newThreadPerTaskExecutor(ThreadFactory)Executors.newVirtualThreadPerTaskExecutor() 创建一个为每个任务创建新线程的 ExecutorService。这些方法使得与使用线程池和 ExecutorService 的现有代码进行迁移和互操作成为可能。

  • ExecutorService 现在扩展了 AutoCloseable,因此可以像上面示例中展示的那样,将此 API 与 try-with-resource 构造一起使用。

  • Future 现在定义了获取已完成任务的结果或异常的方法,以及获取任务状态的方法。这些新增功能结合在一起,使得将 Future 对象用作流的元素变得容易,过滤出已完成任务的未来流,然后映射以获得结果流。这些方法对于 结构化并发 提议的 API 增强也将非常有用。

网络

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 飞行记录器 (JFR)

JFR 支持虚拟线程,并引入了几个新的事件:

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

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

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

Java 管理扩展(JMX)

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

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

java.lang.ThreadGroup

java.lang.ThreadGroup 是一个用于线程分组的遗留 API,在现代应用程序中很少使用,并且不适合用于虚拟线程的分组。我们现在将其标记为过时并降低其功能,未来预计会作为 结构化并发 的一部分引入一种新的线程组织结构。

作为背景,ThreadGroup API 起源于 Java 1.0。它最初旨在提供诸如停止组中所有线程之类的作业控制操作。现代代码更有可能使用 java.util.concurrent 包中的线程池 API,该 API 在 Java 5 中引入。ThreadGroup 在早期的 Java 版本中支持了小程序的隔离,但 Java 安全架构在 Java 1.2 中发生了显著演变,线程组不再扮演重要角色。ThreadGroup 还旨在用于诊断目的,但这一角色被 Java 5 中引入的监控和管理功能所取代,包括 java.lang.management API。

除了现在基本上无关紧要之外,ThreadGroup API 和实现还存在许多严重的问题:

  • 用于销毁线程组的 API 和机制存在缺陷。

  • 该 API 要求实现必须持有组内所有活动线程的引用。这为线程创建、线程启动和线程终止增加了同步和竞争开销。

  • 该 API 定义了本质上存在竞争风险的 enumerate() 方法。

  • 该 API 定义了本质上容易导致死锁且不安全的 suspend()resume()stop() 方法。

ThreadGroup 现在的指定、弃用和降级情况如下:

  • 明确销毁线程组的功能已被移除:最终弃用的 destroy() 方法不再执行任何操作。

  • 守护线程组的概念已被移除:通过最终弃用的 setDaemon(boolean)isDaemon() 方法设置和获取的守护状态将被忽略。

  • 实现中不再对子组保持强引用。当线程组中没有活跃线程且没有任何其他因素维持线程组的存活时,线程组现在可以被垃圾回收。

  • 最终弃用的 suspend()resume()stop() 方法始终会抛出异常。

替代方案

  • 继续依赖异步 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;他们主要通过 ExecutorService 等更高级别的 API 进行交互。随着时间的推移,我们将通过弃用和删除过时的方法,从 Thread 类及其相关类(如 ThreadGroup)中抛弃不需要的包袱。

测试

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

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

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

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

  • 新的微基准测试将聚焦于对性能至关重要的领域。

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

风险与假设

此提案的主要风险是由于现有 API 及其实现的更改而带来的兼容性问题:

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

  • java.lang.ThreadGroup 不再允许销毁线程组 (destroy),不再支持守护线程组 的概念,并且其 suspend()resume()stop() 方法始终抛出异常。

有一些与源代码不兼容的 API 变更,以及一个与二进制不兼容的变更,可能会影响扩展 java.lang.Thread 的代码:

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

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

  • 添加了 Thread.threadId() 作为一个最终方法,用于返回线程的标识符。如果现有源文件中的代码扩展了 Thread,并且子类声明了一个名为 threadId 且无参数的方法,则该文件将无法编译。如果存在已编译的代码扩展了 Thread,并且子类定义了一个名为 threadId、返回类型为 long 且无参数的方法,那么在运行时加载该子类时将抛出 IncompatibleClassChangeError

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

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

  • Thread.setDaemon(boolean) 方法对虚拟线程没有任何作用,虚拟线程始终是守护线程。

  • Thread.stop()suspend()resume() 方法在虚拟线程上调用时会抛出 UnsupportedOperationException

  • 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 标志对虚拟线程性能有极大的负面影响。

依赖项