JEP 444: Virtual Threads | 虚拟线程
摘要
向 Java 平台引入 虚拟线程。虚拟线程是轻量级线程,可显著减少编写、维护和观察高吞吐量并发应用程序的工作量。
历史
虚拟线程作为预览功能由 JEP 425 提出,并在 JDK 19 中提供。为了留出时间收集反馈并获得更多经验,它们再次作为预览功能由 JEP 436 提出,并在 JDK 20 中提供。本 JEP 提议在 JDK 21 中最终确定虚拟线程,并根据开发人员反馈,在 JDK 20 的基础上进行了以下更改:
虚拟线程现在始终支持 线程局部变量。与预览版本不同,现在不可能创建无法拥有线程局部变量的虚拟线程。对线程局部变量的保证支持可确保更多现有库可以未经修改地与虚拟线程一起使用,并有助于将面向任务的代码迁移到使用虚拟线程。
直接使用
Thread.Builder
API(而不是通过Executors.newVirtualThreadPerTaskExecutor()
创建的)创建的虚拟线程现在也在其整个生命周期内默认受到监控,并可通过 观察虚拟线程 部分中描述的新线程转储进行观察。
目标
使以简单的每请求一线程风格编写的服务器应用程序能够以接近最优的硬件利用率进行扩展。
使使用
java.lang.Thread
API 的现有代码能够以最小的更改采用虚拟线程。使用现有的 JDK 工具轻松地对虚拟线程进行故障排除、调试和性能分析。
非目标
不旨在移除线程的传统实现,或静默地将现有应用程序迁移到使用虚拟线程。
不旨在更改 Java 的基本并发模型。
不旨在在 Java 语言或 Java 库中提供新的数据并行性构造。Stream API 仍然是处理大型数据集并行的首选方式。
动机
近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每条语句都在一个线程内执行,由于 Java 是多线程的,因此多个执行线程会同时发生。线程是 Java 的 并发单元:一段与其他此类单元并发运行且在很大程度上独立于它们的顺序代码。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及在出现问题时的上下文:异常由同一线程中的方法抛出并捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程中方法的语句,而分析器则可视化多个线程的行为以帮助理解它们的性能。
每请求一线程风格
服务器应用程序通常处理彼此独立的并发用户请求,因此应用程序通过在整个请求持续时间内为该请求分配一个线程来处理请求是有意义的。这种 每请求一线程风格 易于理解、编程、调试和分析,因为它使用平台的并发单元来表示应用程序的并发单元。
服务器应用程序的可扩展性受 利特尔定律 的支配,该定律将延迟、并发和吞吐量联系起来:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发)必须按到达率(即吞吐量)的比例增长。例如,假设一个平均延迟为 50 毫秒的应用程序通过并发处理 10 个请求来实现每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它将需要并发处理 100 个请求。如果每个请求在其持续时间内都在一个线程中处理,那么为了跟上处理速度,线程的数量必须随着吞吐量的增长而增长。
不幸的是,可用线程的数量是有限的,因为 JDK 将线程实现为操作系统(OS)线程的包装器。OS 线程成本高昂,因此我们不能拥有太多,这使得该实现不适合每请求一线程风格。如果每个请求在其持续时间内都占用一个线程(因此也占用一个 OS 线程),那么在其他资源(如 CPU 或网络连接)耗尽之前很久,线程的数量往往就会成为限制因素。JDK 当前对线程的实现将应用程序的吞吐量限制在远低于硬件可以支持的水平。即使线程被池化,也会出现这种情况,因为池化有助于避免启动新线程的高昂成本,但并不会增加线程的总数。
使用异步风格提高可扩展性
一些希望充分利用硬件的开发人员放弃了每请求一线程风格,转而采用线程共享风格。不是从头到尾在一个线程上处理请求,而是在等待另一个 I/O 操作完成时,请求处理代码会将其线程返回给池,以便该线程可以为其他请求提供服务。这种细粒度的线程共享(即代码仅在执行计算时持有线程,而在等待 I/O 时不持有线程)允许进行大量并发操作而不会消耗大量线程。虽然它消除了由 OS 线程稀缺性导致的吞吐量限制,但代价高昂:它要求采用所谓的 异步 编程风格,使用一组单独的 I/O 方法,这些方法不会等待 I/O 操作完成,而是在稍后向回调发出完成信号。没有专用线程,开发人员必须将请求处理逻辑分解为小阶段,通常编写为 lambda 表达式,然后使用 API 将它们组合成顺序管道(例如,参见 CompletableFuture 或所谓的“响应式”框架)。因此,他们放弃了语言的基本顺序组合运算符,如循环和 try/catch
块。
在异步风格中,请求的每个阶段可能都在不同的线程上执行,并且每个线程都以交错的方式运行属于不同请求的阶段。这对理解程序行为有着深远的影响:堆栈跟踪不提供有用的上下文,调试器无法逐步执行请求处理逻辑,而分析器也无法将操作的成本与其调用者相关联。当使用 Java 的 流 API 在短管道中处理数据时,组合 lambda 表达式是可管理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台格格不入,因为应用程序的并发单元(异步管道)不再是平台的并发单元。
使用虚拟线程保持每请求一线程的风格
为了使应用程序能够扩展的同时与平台保持和谐,我们应努力保持每请求一线程的风格。我们可以通过更高效地实现线程来做到这一点,从而使其更加丰富。操作系统无法更高效地实现操作系统线程,因为不同的语言和运行时环境以不同的方式使用线程堆栈。然而,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 中的 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() 被隐式调用,并等待
此示例中的任务很简单——休眠一秒——现代硬件可以轻松地支持 10,000 个虚拟线程同时运行此类代码。在幕后,JDK 会在少数几个操作系统线程上运行代码,甚至可能只有一个。
如果此程序使用了为每个任务创建新平台线程的 ExecutorService
(如 Executors.newCachedThreadPool()
),情况将大不相同。ExecutorService
将尝试创建 10,000 个平台线程,因此会创建 10,000 个操作系统线程,这取决于机器和操作系统,程序可能会崩溃。
如果程序改为使用从池中获取平台线程的 ExecutorService
(如 Executors.newFixedThreadPool(200)
),情况也不会好太多。ExecutorService
将为所有 10,000 个任务创建 200 个平台线程,因此许多任务将顺序执行而不是并发执行,程序将需要很长时间才能完成。对于这个程序,拥有 200 个平台线程的池每秒只能完成 200 个任务,而虚拟线程在充分预热后每秒可以完成约 10,000 个任务。此外,如果示例程序中的 10_000
更改为 1_000_000
,则程序将提交 1,000,000 个任务,创建 1,000,000 个并发运行的虚拟线程,并在充分预热后每秒完成约 1,000,000 个任务。
如果此程序中的任务执行了一秒钟的计算(例如,对大型数组进行排序),而不是仅仅休眠,那么无论它们是虚拟线程还是平台线程,将线程数增加到超过处理器核心数都不会有任何帮助。虚拟线程并不是更快的线程——它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。与平台线程相比,它们的数量可以更多,因此它们能够根据利特尔定律实现更高的并发性,从而提供更高的吞吐量。
换句话说,虚拟线程可以显著提高应用程序的吞吐量,当
- 并发任务数量很高(超过几千个),并且
- 工作负载不是 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(将在下文讨论 #java-lang-Thread)可以创建和启动虚拟线程。此外,结构化并发 提供了一个更强大的 API 来创建和管理虚拟线程,尤其是在类似于本服务器示例的代码中,在该示例中,线程之间的关系对于平台和其工具来说是已知的。
不要对虚拟线程进行池化
开发人员通常会将应用程序代码从基于传统线程池的 ExecutorService
迁移到每任务一个虚拟线程的 ExecutorService
。线程池,就像任何资源池一样,旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远没有必要对它们进行池化。
开发人员有时使用线程池来限制对有限资源的并发访问。例如,如果某项服务无法处理超过 20 个并发请求,那么通过向大小为 20 的线程池提交任务来向该服务发送所有请求将确保这一点。由于平台线程的高成本使得线程池变得无处不在,因此这种用法已经变得非常普遍,但请不要试图对虚拟线程进行池化以限制并发。相反,应使用专为该目的设计的结构,如信号量。
结合线程池,开发人员有时会使用线程局部变量来在多个共享同一线程的任务之间共享昂贵的资源。例如,如果创建数据库连接的成本很高,则可以打开一次连接并将其存储在线程局部变量中,以便同一线程中的其他任务稍后使用。如果将代码从使用线程池迁移到每任务使用一个虚拟线程,请注意这种用法的使用,因为为每个虚拟线程创建昂贵的资源可能会显著降低性能。应更改此类代码,使用其他缓存策略,以便在大量虚拟线程之间高效地共享昂贵的资源。
观察虚拟线程
编写清晰的代码并非故事的全部。清晰展示运行程序的状态对于故障排除、维护和优化也至关重要,而 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 中实现的,并不绑定到任何特定的 OS 线程,因此它们对操作系统是不可见的,操作系统不知道它们的存在。在 OS 级别进行监控时,会观察到 JDK 进程使用的 OS 线程数少于虚拟线程数。
调度虚拟线程
为了执行有用的工作,线程需要被调度,即被分配给处理器核心执行。对于作为操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度器。相比之下,对于虚拟线程,JDK 拥有自己的调度器。JDK 的调度器不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是之前提到的虚拟线程的 M:N 调度)。然后,平台线程像往常一样由操作系统进行调度。
JDK 的虚拟线程调度器是一个采用先进先出(FIFO)模式的“工作窃取”ForkJoinPool
。调度器的“并行度”是指可用于调度虚拟线程的平台线程的数量。默认情况下,它等于 可用处理器的数量,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism
进行调整。这个 ForkJoinPool
与用于实现并行流等功能的 公共池 不同,后者采用后进先出(LIFO)模式。
调度器分配给虚拟线程的平台线程被称为该虚拟线程的“载体”。在虚拟线程的生命周期内,它可以被调度到不同的载体上;换句话说,调度器不会维护虚拟线程与任何特定平台线程之间的“亲和性”。从 Java 代码的角度来看,正在运行的虚拟线程在逻辑上是独立于其当前载体的:
虚拟线程无法获取载体的身份。
Thread.currentThread()
返回的值始终是虚拟线程本身。载体和虚拟线程的堆栈跟踪是分开的。在虚拟线程中抛出的异常不会包含载体的堆栈帧。线程转储不会在虚拟线程的堆栈中显示载体的堆栈帧,反之亦然。
载体的线程局部变量对虚拟线程不可见,反之亦然。
此外,从 Java 代码的角度来看,虚拟线程及其载体暂时共享一个操作系统线程的事实是不可见的。相比之下,从本地代码的角度来看,虚拟线程及其载体都在同一个本地线程上运行。因此,在相同虚拟线程上多次调用的本地代码可能会在每次调用时观察到不同的操作系统线程标识符。
目前,调度器没有为虚拟线程实现“时间片轮转”。时间片轮转是指强制抢占已消耗指定量 CPU 时间的线程。虽然当平台线程数量相对较少且 CPU 利用率达到 100% 时,时间片轮转可以有效地减少某些任务的延迟,但尚不清楚在时间片轮转下使用一百万个虚拟线程是否会同样有效。
执行虚拟线程
为了利用虚拟线程,你无需重写程序。虚拟线程不要求也不期望应用程序代码显式地将控制权交还给调度器;换句话说,虚拟线程不是“协作式”的。用户代码不应对虚拟线程如何或何时被分配给平台线程做出假设,正如它对平台线程如何或何时被分配给处理器核心不做假设一样。
要在虚拟线程中运行代码,JDK 的虚拟线程调度器通过将虚拟线程“挂载”到平台线程上来为该虚拟线程分配执行的平台线程。这使得平台线程成为虚拟线程的载体。之后,在运行了一些代码后,虚拟线程可以从其载体上“卸载”。此时,平台线程处于空闲状态,调度器可以在其上挂载另一个虚拟线程,从而使其再次成为载体。
通常,当虚拟线程在 JDK 中进行 I/O 或其他阻塞操作时(如 BlockingQueue.take()
),它会卸载。当阻塞操作准备完成时(例如,套接字上已接收到字节),它会将虚拟线程提交回调度器,调度器将虚拟线程挂载到载体上以恢复执行。
虚拟线程的挂载和卸载操作频繁且透明,且不会阻塞任何 OS 线程。例如,之前展示的服务器应用程序中包含以下代码行,其中包含了对阻塞操作的调用:
response.send(future1.get() + future2.get());
这些操作将导致虚拟线程多次挂载和卸载,通常每个 get()
调用一次,以及在执行 send(...)
中的 I/O 时可能多次。
JDK 中的绝大多数阻塞操作都会卸载虚拟线程,释放其载体和底层的 OS 线程以执行新工作。然而,JDK 中的一些阻塞操作不会卸载虚拟线程,因此会阻塞其载体和底层的 OS 线程。这是由于操作系统级别(如许多文件系统操作)或 JDK 级别(如 Object.wait()
)的限制造成的。这些阻塞操作的实现通过暂时扩展调度器的并行度来补偿 OS 线程的占用。因此,调度器 ForkJoinPool
中的平台线程数量可能会暂时超过可用处理器的数量。可以通过系统属性 jdk.virtualThreadScheduler.maxPoolSize
调整调度器可用的最大平台线程数。
在以下两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被“固定”到其载体上:
- 当它在
synchronized
块或方法中执行代码时, - 当它执行
native
方法或 外部函数 时。
固定不会使应用程序出错,但可能会阻碍其可伸缩性。如果虚拟线程在执行阻塞操作(如 I/O 或 BlockingQueue.take()
)时被固定,那么其载体和底层的 OS 线程将在整个操作期间被阻塞。长时间的频繁固定会通过占用载体来损害应用程序的可伸缩性。
调度器不会通过扩展其并行度来补偿固定。相反,应通过修改频繁运行且保护可能长时间 I/O 操作的 synchronized
块或方法,使用 java.util.concurrent.locks.ReentrantLock
来避免频繁和长期的固定。无需替换不常使用(例如,仅在启动时执行)或保护内存操作的 synchronized
块和方法。一如既往地,努力保持锁定策略的简单和清晰。
新的诊断工具可帮助将代码迁移到虚拟线程,并评估是否应将特定的 synchronized
用法替换为 java.util.concurrent
锁:
当线程在固定时阻塞时,会发出 JDK Flight Recorder (JFR) 事件(请参阅 JDK Flight Recorder)。
系统属性
jdk.tracePinnedThreads
在线程在固定时阻塞时会触发堆栈跟踪。使用-Djdk.tracePinnedThreads=full
运行会在线程在固定时阻塞时打印完整的堆栈跟踪,突出显示本地帧和持有监视器的帧。使用-Djdk.tracePinnedThreads=short
运行会限制输出,仅显示有问题的帧。
在未来的版本中,我们可能会能够消除上述第一个限制,即在 synchronized
内部进行固定。第二个限制是与本地代码正确交互所必需的。
内存使用与垃圾收集交互
虚拟线程的栈作为 栈块 对象存储在 Java 的垃圾收集堆中。随着应用程序的运行,栈会增长和缩小,以实现内存效率并容纳深度达到 JVM 配置的平台线程栈大小的栈。这种效率使得能够创建大量的虚拟线程,从而保持服务器应用程序中每个请求一个线程的风格的持续可行性。
在 上面的第二个示例 中,请回想一下,一个假设的框架通过创建新的虚拟线程并调用 handle
方法来处理每个请求。即使它在深层调用栈的末尾(在身份验证、事务等之后)调用 handle
,handle
本身也会生成多个只执行短生命周期任务的虚拟线程。因此,对于每个具有深层调用栈的虚拟线程,都会有多个具有浅层调用栈且消耗较少内存的虚拟线程。
通常,很难将虚拟线程所需的堆空间和垃圾收集器活动量与异步代码相比较。一百万个虚拟线程至少需要一百万个对象,但一百万个共享平台线程池的任务也是如此。此外,处理请求的应用程序代码通常在 I/O 操作之间维护数据。每个请求一个线程的代码可以将这些数据保存在局部变量中,这些局部变量存储在堆中的虚拟线程栈上,而异步代码则必须将这些相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的栈帧布局比紧凑对象更浪费;另一方面,虚拟线程可以在许多情况下(取决于低级别的垃圾收集交互)修改和重用其栈,而异步管道则总是需要分配新对象,因此虚拟线程可能需要的分配次数更少。总体而言,每个请求一个线程与异步代码在堆消耗和垃圾收集器活动方面应该大致相似。随着时间的推移,我们期望使虚拟线程栈的内部表示更加紧凑。
与平台线程栈不同,虚拟线程栈不是垃圾收集根。因此,它们所包含的引用不会在垃圾收集器(如执行并发堆扫描的 G1)的停止世界暂停中被遍历。
虚拟线程的一个当前限制是 G1 垃圾收集器不支持 巨大 栈块对象。如果虚拟线程的栈达到区域大小的一半,这可能小至 512KB,则可能会抛出 StackOverflowError
。
详细更改
以下各小节详细描述了我们在 Java 平台及其实现中提出的更改:
java.lang.Thread
- 线程局部变量
java.util.concurrent
- 网络
java.io
- Java 本地接口(JNI)
- 调试(JVM TI、JDWP 和 JDI)
- JDK Flight Recorder(JFR)
- Java 管理扩展(JMX)
java.lang.Thread
我们对 java.lang.Thread
API 进行了如下更新:
Thread.Builder
、Thread.ofVirtual()
和Thread.ofPlatform()
是创建虚拟线程和平台线程的新 API。例如,javaThread 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
中的三个方法——stop()
、suspend()
和 resume()
——对于虚拟线程会抛出 UnsupportedOperationException
,这些方法在 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 的实现大量使用了同步机制,并需要在虚拟线程中使用时进行更改,以避免钉住(pinning)问题。
作为背景,面向字节的输入 / 输出流并未指定为线程安全的,也没有在线程被阻塞在读或写方法时调用 close()
时指定预期行为。在大多数情况下,从多个并发线程中使用特定的输入或输出流是没有意义的。面向字符的读取器 / 写入器也未指定为线程安全的,但它们确实为子类公开了一个锁对象。除了钉住问题外,这些类中的同步也存在问题和不一致性;例如,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
(即 JNI 对Thread
对象的引用)调用的函数现在也可以使用对虚拟线程的引用来调用。少数函数,即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 毫秒的阈值。jdk.VirtualThreadSubmitFailed
表示启动或唤醒虚拟线程失败,这可能是由于资源问题导致的。此事件默认启用。
Java Management Extensions (JMX)
java.lang.management.ThreadMXBean
仅支持平台线程的监控和管理。findDeadlockedThreads()
方法用于查找处于死锁状态的平台线程循环;它不查找处于死锁状态的虚拟线程循环。
com.sun.management.HotSpotDiagnosticsMXBean
中的新方法生成了上文描述的 新型线程转储。此方法也可以通过平台的 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
)中不需要的包袱。
测试
现有的测试将确保我们在此处提出的更改不会在其运行的众多配置和执行模式中引起意外的回归。
我们将扩展
jtreg
测试框架,以允许在虚拟线程的上下文中运行现有测试。这将避免需要许多测试的两个版本。新测试将涵盖所有新的和修订后的 API,以及所有为支持虚拟线程而更改的区域。
新的压力测试将针对可靠性和性能至关重要的领域。
新的微基准测试将针对性能关键领域。
风险和假设
本提案的主要风险是由于现有 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()
是一个新的最终方法。如果存在已编译的代码扩展了Thread
,并且子类声明了一个具有相同名称和返回类型的方法,则在加载子类时,如果子类被加载,则将在运行时抛出IncompatibleClassChangeError
。
在将现有代码与利用虚拟线程或新 API 的新代码混合使用时,可能会观察到平台线程和虚拟线程之间的一些行为差异:
Thread.setPriority(int)
方法对虚拟线程没有影响,虚拟线程始终具有Thread.NORM_PRIORITY
优先级。Thread.setDaemon(boolean)
方法对虚拟线程没有影响,虚拟线程始终是守护线程。Thread.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程的映射。java.net.Socket
、ServerSocket
和DatagramSocket
定义的阻塞 I/O 方法现在在虚拟线程的上下文中调用时可被中断。当在套接字操作上阻塞的线程被中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。虚拟线程不是
ThreadGroup
的活跃成员。在虚拟线程上调用Thread.getThreadGroup()
将返回一个空的虚拟线程组。在设置了安全管理器的情况下运行时,虚拟线程没有权限。有关在 Java 17 及更高版本上使用安全管理器的信息,请参阅 JEP 411(弃用安全管理器以进行删除)。
在 JVM TI 中,
GetAllThreads
和GetAllStackTraces
函数不返回虚拟线程。启用ThreadStart
和ThreadEnd
事件的现有代理可能会遇到性能问题,因为它们无法将事件限制在平台线程上。java.lang.management.ThreadMXBean
API 支持平台线程的监视和管理,但不支持虚拟线程。-XX:+PreserveFramePointer
标志对虚拟线程性能有极大的负面影响。
依赖项
JDK 18 中的 JEP 416(使用方法句柄重新实现核心反射) 移除了 VM 原生反射实现。这允许在通过反射调用方法时,虚拟线程能够优雅地挂起。
JDK 13 中的 JEP 353(重新实现传统套接字 API) 和 JDK 15 中的 JEP 373(重新实现传统 DatagramSocket API) 使用专为虚拟线程设计的新实现替换了
java.net.Socket
、ServerSocket
和DatagramSocket
的实现。JDK 18 中的 JEP 418(互联网地址解析 SPI) 定义了一个用于主机名和地址查找的服务提供程序接口。这将允许第三方库实现替代的
java.net.InetAddress
解析器,这些解析器在主机查找过程中不会锁定线程。