JEP 444:虚拟线程
总结
向 Java 平台引入 虚拟线程。虚拟线程是一种轻量级线程,可以显著降低编写、维护和观察高吞吐量并发应用程序的难度。
历史
-
虚拟线程现在始终支持线程局部变量。在之前的预览版本中,可以创建不带线程局部变量的虚拟线程,但现在已不再可能。对线程局部变量的支持保证了更多现有的库可以与虚拟线程一起使用而无需更改,也有助于将面向任务的代码迁移到使用虚拟线程。
-
直接通过
Thread.Builder
API 创建的虚拟线程(相对于通过Executors.newVirtualThreadPerTaskExecutor()
创建的虚拟线程)现在也默认在其整个生命周期内被监控,并且可以通过观察虚拟线程一节中描述的新线程转储进行观测。
目标
-
使以简单的每个请求一个线程风格编写的服务端应用程序能够以接近最优的硬件利用率进行扩展。
-
使使用
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,同时不牺牲可扩展性。
描述
如今,JDK 中的每个 java.lang.Thread
实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期中占用该操作系统线程。平台线程的数量受限于操作系统线程的数量。
虚拟线程 是 java.lang.Thread
的一个实例,它在底层操作系统线程上运行 Java 代码,但不会在整个代码生命周期内捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行它们的 Java 代码,从而有效地共享该线程。平台线程会独占宝贵的 OS 线程,而虚拟线程则不会。虚拟线程的数量可以远大于操作系统线程的数量。
虚拟线程是一种轻量级的线程实现,由 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 代码将很容易在虚拟线程中运行。许多服务器框架将选择自动执行此操作,为每个传入请求启动一个新的虚拟线程,并在其内运行应用程序的业务逻辑。
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 来创建和管理虚拟线程,特别是在类似于本服务器示例的代码中,线程之间的关系会被平台及其工具所知悉。
不要对虚拟线程进行池化
开发者通常会将应用程序代码从传统的基于线程池的 ExecutorService
迁移到每个任务使用虚拟线程的 ExecutorService
。线程池与任何资源池一样,旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远不需要对它们进行池化。
开发者有时会使用线程池来限制对有限资源的并发访问。例如,如果一个服务无法处理超过 20 个并发请求,那么通过提交任务到大小为 20 的线程池来发起对该服务的所有请求,就可以确保这一点。这种惯用法之所以无处不在,是因为平台线程的高成本使得线程池变得普遍,但不要为了限制并发而去尝试对虚拟线程进行池化。相反,应该使用专门为该目的设计的结构,例如信号量(semaphores)。
结合线程池,开发者有时会使用线程本地变量(thread-local variables)在线程内的多个任务之间共享昂贵的资源。例如,如果创建数据库连接的成本很高,那么你可以打开一次连接并将其存储在 ThreadLocal
变量中,以便同一线程中的其他任务稍后使用。如果你将代码从使用线程池迁移到为每个任务使用虚拟线程,请注意这种用法,因为为每个虚拟线程创建昂贵的资源可能会显著降低性能。修改此类代码以使用替代的缓存策略,从而确保昂贵的资源可以在大量虚拟线程之间高效共享。
观察虚拟线程
编写清晰的代码并不是全部。对于故障排除、维护和优化而言,清晰地展示运行中程序的状态同样至关重要。JDK 长期以来提供了调试、分析和监控线程的机制。此类工具也应对虚拟线程提供相同的支持 —— 可能需要考虑到它们的数量较多 —— 毕竟它们也是 java.lang.Thread
的实例。
Java 调试器可以逐步执行虚拟线程,显示调用栈,并检查栈帧中的变量。JDK Flight Recorder(JFR)是 JDK 的低开销性能分析和监控机制,它可以将来自应用程序代码的事件(如对象分配和 I/O 操作)与正确的虚拟线程关联起来。这些工具无法对以异步风格编写的应用程序执行这些操作。在这种风格中,任务与线程无关,因此调试器无法显示或操作任务的状态,性能分析器也无法判断任务花费多少时间等待 I/O。
线程转储是另一个流行的工具,用于排查以每个请求一个线程风格编写的应用程序。不幸的是,使用 jstack
或 jcmd
获取的 JDK 传统线程转储展示的是一个扁平的线程列表。这适用于数十或数百个平台线程,但不适用于数千或数百万个虚拟线程。因此,我们不会扩展传统的线程转储以包含虚拟线程;相反,我们将在 jcmd
中引入一种新型的线程转储,以有意义的方式将虚拟线程与平台线程一起分组展示。当程序使用结构化并发时,可以展示线程之间更丰富的关系。
由于可视化和分析大量线程能够从工具中获益,jcmd
除了可以以纯文本格式输出新的线程转储外,还可以将其以 JSON 格式输出:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
新的线程转储格式不包括对象地址、锁、JNI 统计信息、堆统计信息以及传统线程转储中出现的其他信息。此外,由于可能需要列出非常多的线程,因此生成新的线程转储不会暂停应用程序。
如果系统属性 jdk.trackAllThreads
被设置为 false
,即通过命令行选项 -Djdk.trackAllThreads=false
,那么直接使用 Thread.Builder
API 创建的虚拟线程将不会始终被运行时跟踪,也可能不会出现在新的线程转储中。在这种情况下,新的线程转储会列出那些在网络 I/O 操作中被阻塞的虚拟线程,以及由上述每任务新建线程的 ExecutorService
创建的虚拟线程。
下面是一个线程转储的示例,取自一个类似于上面的第二个示例的应用程序,并在 JSON 查看器中呈现(点击可放大):
由于虚拟线程是在 JDK 中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统是不可见的,操作系统也感知不到它们的存在。操作系统级别的监控会观察到,JDK 进程使用的操作系统线程数少于虚拟线程的数量。
调度虚拟线程
要完成有用的工作,线程需要被调度,即分配到处理器核心上执行。对于作为操作系统线程实现的平台线程(platform threads),JDK 依赖于操作系统中的调度器。相比之下,对于虚拟线程(virtual threads),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
进行调整。
- 当它执行
synchronized
块或方法中的代码时,或者 - 当它执行一个
native
方法或 foreign function 时。
固定不会使应用程序出错,但可能会阻碍其可扩展性。如果一个虚拟线程在被固定的情况下执行了阻塞操作(例如 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
运行时,当线程在被固定时阻塞,将打印完整的堆栈跟踪,突出显示本地帧(native frames)和持有监视器的帧。使用-Djdk.tracePinnedThreads=short
运行时,输出将仅限于有问题的帧。
在将来的版本中,我们可能能够消除上述第一个限制,即在 synchronized
中的固定操作。第二个限制是为了与原生代码正确交互所必需的。
内存使用与垃圾回收的交互
虚拟线程的栈以 stack chunk 对象的形式存储在 Java 的垃圾回收堆中。随着应用程序的运行,这些栈会动态增长和收缩,这既是为了提高内存效率,也为了适应深度达到 JVM 配置的平台线程栈大小的栈。这种效率使得大量虚拟线程成为可能,从而保证了在服务器应用中每个请求一个线程(thread-per-request)模式的持续可行性。
在上面的第二个示例中,回想一下一个假设的框架通过创建一个新的虚拟线程并调用 handle
方法来处理每个请求。即使它在深度调用栈的末尾调用 handle
(在认证、事务等之后),handle
本身也会生成多个只执行短时任务的虚拟线程。因此,对于每个具有深度调用栈的虚拟线程,将会有多个具有浅调用栈的虚拟线程,消耗极少的内存。
通常来说,很难将虚拟线程所需的堆空间和垃圾收集器活动量与异步代码进行比较。一百万个虚拟线程至少需要一百万个对象,但共享平台线程池的一百万个任务同样如此。此外,处理请求的应用程序代码通常会在 I/O 操作之间保持数据。每个请求一个线程的代码可以将这些数据保存在局部变量中,这些变量存储在堆中的虚拟线程栈上,而异步代码则必须将相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的栈帧布局比紧凑的对象更浪费;另一方面,虚拟线程在许多情况下可以修改并重用它们的栈(取决于底层 GC 交互),而异步管道总是需要分配新对象,因此虚拟线程可能需要更少的分配操作。总体而言,每个请求一个线程与异步代码的堆消耗和垃圾收集器活动量应该大致相似。随着时间的推移,我们期望使虚拟线程栈的内部表示显著更加紧凑。
与平台线程栈不同,虚拟线程栈不是垃圾回收(GC)的根对象。因此,它们所包含的引用不会被像 G1 这样执行并发堆扫描的垃圾回收器在“世界停止”暂停中遍历。这也意味着,如果一个虚拟线程阻塞在例如 BlockingQueue.take()
上,并且没有其他线程可以获得该虚拟线程或队列的引用,那么该线程就可以被垃圾回收 —— 这是完全可以的,因为虚拟线程永远无法被中断或解除阻塞。当然,如果虚拟线程正在运行,或者它虽然被阻塞但有可能被解除阻塞,那么它是不会被垃圾回收的。
虚拟线程的一个当前限制是 G1 垃圾回收器不支持超大的栈块对象。如果一个虚拟线程的栈达到区域大小的一半(可能小到 512 KB),就可能会抛出 StackOverflowError
。
详细更改
其余小节详细描述了我们针对 Java 平台及其实施提出的变更:
java.lang.Thread
我们对 java.lang.Thread
API 进行了如下更新:
-
Thread.Builder
、Thread.ofVirtual()
和Thread.ofPlatform()
是用于创建虚拟线程和平台线程的新 API。例如,Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
创建了一个名为
"duke"
的未启动虚拟线程。 -
Thread.startVirtualThread(Runnable)
是一种方便的方法,用于创建并启动虚拟线程。 -
Thread.Builder
可以创建一个线程或一个ThreadFactory
,然后可以用它来创建具有相同属性的多个线程。 -
Thread.isVirtual()
用于测试一个线程是否为虚拟线程。 -
Thread.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程的映射。
java.lang.Thread
API 除了此 JEP 之外没有其他变化。Thread
类定义的构造函数像以前一样创建平台线程。没有新的公共构造函数。
(Thread
类中有三种方法对虚拟线程抛出 UnsupportedOperationException
异常 —— stop()
、suspend()
和 resume()
—— 在 JDK 20 中进行了更改,针对平台线程同样抛出 UnsupportedOperationException
。)
虚拟线程与平台线程之间的主要 API 差异包括:
-
公共的
Thread
构造函数无法创建虚拟线程。 -
虚拟线程始终是守护线程。
Thread.setDaemon(boolean)
方法不能将虚拟线程更改为非守护线程。 -
虚拟线程具有固定的优先级
Thread.NORM_PRIORITY
。Thread.setPriority(int)
方法对虚拟线程无效。此限制可能会在未来的版本中重新审视。 -
虚拟线程不是线程组的活跃成员。当在虚拟线程上调用
Thread.getThreadGroup()
时,返回一个名为"VirtualThreads"
的占位线程组。Thread.Builder
API 没有定义设置虚拟线程组的方法。 -
在设置了
SecurityManager
的情况下运行时,虚拟线程没有任何权限。
线程局部变量
虚拟线程支持线程局部变量(ThreadLocal
)和可继承的线程局部变量(InheritableThreadLocal
),与平台线程相同,因此它们可以运行使用线程局部变量的现有代码。然而,由于虚拟线程的数量可能非常多,在使用线程局部变量时需要仔细权衡。特别是,不要在线程池中使用线程局部变量来在共享同一线程的多个任务之间共享昂贵的资源。虚拟线程绝不应该被池化,因为每个虚拟线程在其生命周期内只应运行单个任务。为了准备支持虚拟线程并减少在运行数百万线程时的内存占用,我们已经从 JDK 的 java.base
模块中移除了许多线程局部变量的使用。
系统属性 jdk.traceVirtualThreadLocals
可用于在虚拟线程设置任何线程局部变量的值时触发堆栈跟踪。此诊断输出可能有助于在将代码迁移到使用虚拟线程时移除线程局部变量。将系统属性设置为 true
以触发堆栈跟踪;默认值为 false
。
作用域值(JEP 429)在某些使用场景下可能被证明是比线程局部变量更好的替代方案。
java.util.concurrent
支持锁定的原始 API java.util.concurrent.LockSupport
现在支持虚拟线程:停放虚拟线程会释放底层的平台线程以执行其他工作,而取消停放虚拟线程则会安排它继续执行。对 LockSupport
的这一更改使得所有使用它的 API(Lock
、Semaphore
、阻塞队列等)在虚拟线程中调用时能够优雅地停放。
此外,Executors.newThreadPerTaskExecutor(ThreadFactory)
和 Executors.newVirtualThreadPerTaskExecutor()
创建一个为每个任务创建新线程的 ExecutorService
。这些方法支持与使用线程池和 ExecutorService
的现有代码进行迁移和互操作。
网络
java.net
和 java.nio.channels
包中的网络 API 实现现在可以与虚拟线程一起工作:在虚拟线程上执行阻塞操作时(例如,建立网络连接或从套接字读取数据),底层的平台线程会被释放以执行其他任务。
为了允许中断和取消操作,java.net.Socket
、ServerSocket
和 DatagramSocket
定义的阻塞 I/O 方法现在被指定为在虚拟线程中调用时是可中断的:当中断一个阻塞在套接字上的虚拟线程时,将解除该线程的阻塞状态并关闭套接字。当从 InterruptibleChannel
获取这些类型的套接字时,其阻塞 I/O 操作一直是可中断的,因此这一更改使得通过构造函数创建的这些 API 的行为与其从通道获取时的行为保持一致。
java.io
java.io
包为字节和字符流提供了 API。这些 API 的实现大量使用了同步机制,因此在虚拟线程中使用时需要进行更改以避免固定。
作为背景,面向字节的输入/输出流并未被指定为线程安全的,并且当某个线程在读取或写入方法中被阻塞时调用 close()
并未指定预期的行为。在大多数情况下,从多个并发线程使用特定的输入或输出流是没有意义的。面向字符的读取器/写入器也未被指定为线程安全的,但它们确实为子类暴露了一个锁对象。除了固定(pinning)之外,这些类中的同步是有问题且不一致的;例如,InputStreamReader
和 OutputStreamWriter
使用的流解码器和编码器在流对象上同步,而不是在锁对象上同步。
为了防止固定,这些实现现在的工作方式如下:
-
BufferedInputStream
、BufferedOutputStream
、BufferedReader
、BufferedWriter
、PrintStream
和PrintWriter
在直接使用时现在使用显式锁而不是监视器。当这些类被子类化时,它们像以前一样同步。 -
InputStreamReader
和OutputStreamWriter
使用的流解码器和编码器现在使用与外围的InputStreamReader
或OutputStreamWriter
相同的锁。
进一步深入并消除所有这些经常不必要的锁定超出了此 JEP 的范围。
此外,BufferedOutputStream
、BufferedWriter
以及 OutputStreamWriter
的流编码器所使用的缓冲区初始大小现在变得更小,以便在堆中存在大量流或写入器时减少内存使用——这种情况可能会出现在有百万个虚拟线程时,每个线程都在一个套接字连接上拥有一个缓冲流。
Java 本地接口 (JNI)
JNI 定义了一个新的函数 IsVirtualThread
,用于测试对象是否为虚拟线程。
JNI 规范的其他部分没有变化。
调试
调试架构由三个接口组成:JVM 工具接口(JVM TI)、Java 调试线协议(JDWP)和 Java 调试接口(JDI)。这三个接口现在都支持虚拟线程。
JVM TI 的更新包括:
-
大多数使用
jthread
(即,指向Thread
对象的 JNI 引用)调用的函数可以使用对虚拟线程的引用来调用。少数函数,即AgentStartFunction
、PopFrame
、ForceEarlyReturn*
、StopThread
和GetThreadCpuTime
,在虚拟线程上不被支持或选择性支持。SetLocal*
函数仅限于在因断点或单步事件而挂起的虚拟线程的最顶层帧中设置局部变量。 -
GetAllThreads
和GetAllStackTraces
函数现在被指定为返回所有平台线程,而不是所有线程。 -
所有事件,除了在早期 VM 启动期间或堆迭代期间发布的事件,都可以在虚拟线程的上下文中调用事件回调。
-
挂起/恢复实现允许调试器挂起和恢复虚拟线程,并且它允许在虚拟线程挂载时挂起平台线程。
-
一个新的能力
can_support_virtual_threads
使代理能够更精细地控制虚拟线程的线程启动和结束事件。 -
新函数支持批量挂起和恢复虚拟线程;这些函数需要
can_support_virtual_threads
能力。
现有的 JVM TI 代理大多会像以前一样工作,但如果调用了不支持虚拟线程的函数,可能会遇到错误。当一个不了解虚拟线程的代理与使用虚拟线程的应用程序一起使用时,就会出现这些问题。对于某些代理来说,GetAllThreads
的更改可能会返回仅包含平台线程的数组,这可能是一个问题。现有的启用 ThreadStart
和 ThreadEnd
事件的代理可能会遇到性能问题,因为它们无法将这些事件限制为仅平台线程。
JDWP 的更新内容包括:
-
一条新命令允许调试器测试某个线程是否为虚拟线程。
-
EventRequest
命令中的一个新的修饰符允许调试器将线程启动和结束事件限制为平台线程。
JDI 的更新包括:
-
com.sun.jdi.ThreadReference
中的新方法用于测试线程是否为虚拟线程。 -
com.sun.jdi.request.ThreadStartRequest
和com.sun.jdi.request.ThreadDeathRequest
中的新方法将为请求生成的事件限制为平台线程。
如上所述,虚拟线程不被视为线程组中的活动线程。因此,由 JVM TI 函数 GetThreadGroupChildren
、JDWP 命令 ThreadGroupReference/Children
和 JDI 方法 com.sun.jdi.ThreadGroupReference.threads()
返回的线程列表仅包含平台线程。
JDK Flight Recorder (JFR)
JFR 支持虚拟线程,并包含以下几个新事件:
-
jdk.VirtualThreadStart
和jdk.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;他们主要通过ExecutorService
等更高级别的 API 进行交互。随着时间的推移,我们将通过弃用和删除过时的方法,从Thread
类及其相关类(如ThreadGroup
)中抛弃不需要的包袱。
测试
风险与假设
此提案的主要风险是由于现有 API 及其实现的更改而带来的兼容性风险:
- 对
java.io.BufferedInputStream
、BufferedOutputStream
、BufferedReader
、BufferedWriter
、PrintStream
和PrintWriter
类中使用的内部(且未文档化)锁定协议的修订可能会影响假设 I/O 方法在调用时同步流的代码。这些更改不会影响扩展这些类并假设由超类进行锁定的代码,也不会影响扩展java.io.Reader
或java.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.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程的映射。 -
在虚拟线程上下文中调用时,由
java.net.Socket
、ServerSocket
和DatagramSocket
定义的阻塞 I/O 方法现在可以被中断。当线程在套接字操作上被阻塞并被中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。 -
虚拟线程不是
ThreadGroup
的活跃成员。在虚拟线程上调用Thread.getThreadGroup()
将返回一个名为"VirtualThreads"
的虚拟组,该组为空。 -
当使用设置了安全管理器的情况下运行时,虚拟线程没有任何权限。有关在 Java 17 及更高版本中使用安全管理器的信息,请参见 JEP 411(弃用并移除安全管理器)。
-
在 JVM TI 中,
GetAllThreads
和GetAllStackTraces
函数不会返回虚拟线程。现有的启用ThreadStart
和ThreadEnd
事件的代理可能会遇到性能问题,因为它们无法将事件限制为仅针对平台线程。 -
java.lang.management.ThreadMXBean
API 支持对平台线程的监控和管理,但不支持虚拟线程。 -
-XX:+PreserveFramePointer
标志对虚拟线程性能有极大的负面影响。
依赖
-
JDK 18 中的 JEP 416(使用方法句柄重新实现核心反射) 移除了基于 VM 原生的反射实现。这使得在通过反射调用方法时,虚拟线程能够优雅地进行 park 操作。
-
JDK 13 中的 JEP 353(重新实现旧版 Socket API) 和 JDK 15 中的 JEP 373(重新实现旧版 DatagramSocket API) 使用专为虚拟线程设计的新实现替换了
java.net.Socket
、ServerSocket
和DatagramSocket
的实现。 -
JDK 18 中的 JEP 418(互联网地址解析 SPI) 定义了一个用于主机名和地址查找的服务提供者接口 (SPI)。这将允许第三方库实现替代的
java.net.InetAddress
解析器,在主机查找期间不会阻塞线程。