JEP 436: Virtual Threads (Second Preview) | 虚拟线程(第二次预览)
摘要
将“虚拟线程”引入 Java 平台。虚拟线程是轻量级线程,极大地减少了编写、维护和观察高吞吐量并发应用程序的工作量。这是一个 预览 API。
历史
虚拟线程作为一个预览特性由 JEP 425 提出,并在 JDK 19 中交付。这个 JEP 提出了第二次预览,以便有更多时间获取反馈并获得更多关于这个特性的经验。
自第一次预览以来的小变化:
JEP 425 中描述的一小部分 API 变化在 JDK 19 中被永久化,因此这里不提议进行预览。这些变化被永久化是因为它们涉及广泛有用且并非特定于虚拟线程的功能。它们包括
Thread
中的新方法(join(Duration)
、sleep(Duration)
和threadId()
)、Future
中的新方法(用于检查任务状态和结果),以及使ExecutorService
扩展AutoCloseable
的变化。JEP 425 中描述的对
ThreadGroup
的降级 在 JDK 19 中被永久化。
目标
- 使以简单的每个请求一个线程的风格编写的服务器应用程序能够以接近最优的硬件利用率进行扩展。
- 使使用
java.lang.Thread
API 的现有代码能够以最小的更改采用虚拟线程。 - 使现有 JDK 工具能够轻松对虚拟线程进行故障排除、调试和分析。
非目标
- 移除传统的线程实现或默默地将现有应用程序迁移到使用虚拟线程不是目标。
- 改变 Java 的基本并发模型不是目标。
- 在 Java 语言或 Java 库中提供新的数据并行构造不是目标。Stream API 仍然是并行处理大型数据集的首选方式。
动机
近三十年来,Java 开发者一直将线程作为并发服务器应用程序的构建块。每个方法中的每一条语句都是在一个线程中执行的,并且由于 Java 是多线程的,所以多个执行线程会同时发生。线程是 Java 的“并发单元”:一段顺序代码,与其他这样的单元同时运行,并且在很大程度上相互独立。每个线程提供一个栈来存储局部变量和协调方法调用,以及在出现问题时的上下文:异常由同一线程中的方法抛出和捕获,因此开发者可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,分析器可视化多个线程的行为以帮助理解它们的性能。
每个请求一个线程的风格
服务器应用程序通常处理相互独立的并发用户请求,因此应用程序在请求的整个持续时间内为该请求分配一个线程来处理是有意义的。这种“每个请求一个线程的风格”易于理解、易于编程,并且易于调试和分析,因为它使用平台的并发单元来表示应用程序的并发单元。
服务器应用程序的可扩展性由 利特尔定律 控制,该定律涉及延迟、并发和吞吐量:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数量(即并发)必须与到达速率(即吞吐量)成比例增长。例如,假设一个平均延迟为 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 线程与操作系统线程的一对一对应关系的方式实现 Java 线程。就像操作系统通过将大量虚拟地址空间映射到有限数量的物理 RAM 来给人以丰富内存的错觉一样,Java 运行时可以通过将大量“虚拟”线程映射到少量操作系统线程来给人以丰富线程的错觉。
“虚拟线程”是 java.lang.Thread
的一个实例,它不与特定的操作系统线程绑定。相比之下,“平台线程”是 java.lang.Thread
的一个实例,以传统方式实现,作为操作系统线程的一个薄包装器。
每个请求一个线程风格的应用程序代码可以在一个虚拟线程中运行请求的整个持续时间,但虚拟线程仅在 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 个任务的吞吐量。
如果这个程序中的任务执行一秒钟的计算(例如,对一个巨大的数组进行排序)而不是仅仅睡眠,那么增加线程数量超过处理器核心数量将无济于事,无论是虚拟线程还是平台线程。虚拟线程不是更快的线程——它们不会比平台线程更快地运行代码。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。它们的数量可以比平台线程多很多,因此根据利特尔定律,它们能够实现更高的吞吐量所需的更高并发度。
换句话说,虚拟线程可以在以下情况下显著提高应用程序的吞吐量:
- 并发任务数量很高(超过几千个),并且
- 工作负载不是 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 上花费了多少时间。
线程转储是另一个用于对以每个请求一个线程的风格编写的应用程序进行故障排除的流行工具。不幸的是,使用 jstack
或 jcmd
获得的 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 的虚拟线程调度程序是一个以先进先出(FIFO)模式运行的工作窃取 ForkJoinPool
。调度程序的“并行度”是可用于调度虚拟线程的平台线程的数量。默认情况下,它等于 可用处理器的数量,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism
进行调整。请注意,这个 ForkJoinPool
与 公共池 不同,公共池例如在并行流的实现中使用,并且以后进先出(LIFO)模式运行。
调度程序分配给虚拟线程的平台线程被称为虚拟线程的“载体”。一个虚拟线程在其生命周期中可以在不同的载体上被调度;换句话说,调度程序不会在虚拟线程和任何特定的平台线程之间保持“亲和性”。从 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
进行调整。
在两种情况下,虚拟线程在阻塞操作期间不能卸载,因为它被“固定”在其载体上:
- 当它在
synchronized
块或方法中执行代码时,或者 - 当它执行一个
native
方法或一个 外部函数 时。
固定不会使应用程序不正确,但可能会阻碍其可扩展性。如果一个虚拟线程在被固定时执行一个阻塞操作,如 I/O 或 BlockingQueue.take()
,那么在操作期间,它的载体和底层的操作系统线程将被阻塞。频繁的长时间固定可能会通过占用载体而损害应用程序的可扩展性。
调度程序不会通过扩展其并行度来补偿固定。相反,通过修改频繁运行且保护可能长时间的 I/O 操作的 synchronized
块或方法,使用 java.util.concurrent.locks.ReentrantLock
来避免频繁和长时间的固定。对于不经常使用的 synchronized
块和方法(例如,仅在启动时执行)或保护内存中操作的情况,不需要替换。一如既往,努力保持锁定策略简单明了。
新的诊断工具有助于将代码迁移到虚拟线程,并评估是否应该用 java.util.concurrent
锁替换特定的 synchronized
的使用:
当一个线程在被固定时阻塞时,会发出一个 JDK Flight Recorder(JFR)事件(见 JDK Flight Recorder)。
系统属性
jdk.tracePinnedThreads
在一个线程在被固定时阻塞时触发一个堆栈跟踪。使用-Djdk.tracePinnedThreads=full
运行时,当一个线程在被固定时阻塞时,会打印一个完整的堆栈跟踪,其中本地帧和持有监视器的帧会突出显示。使用-Djdk.tracePinnedThreads=short
运行时,将输出限制为仅问题帧。
在未来的版本中,我们可能能够消除上面的第一个限制(在 synchronized
内部固定)。第二个限制是与本地代码正确交互所必需的。
内存使用和与垃圾回收的交互
虚拟线程的栈存储在 Java 的垃圾回收堆中,作为“栈块”对象。栈在应用程序运行时会增长和收缩,既为了内存高效,也为了适应任意深度的栈(最多达到 JVM 配置的平台线程栈大小)。这种效率使得大量虚拟线程成为可能,从而使服务器应用程序中每个请求一个线程的风格得以持续可行。
在 上面的第二个示例 中,回想一下,一个假设的框架通过创建一个新的虚拟线程并调用 handle
方法来处理每个请求;即使它在一个深度调用栈的末尾(在认证、事务等之后)调用 handle
,handle
本身也会生成多个只执行短时间任务的虚拟线程。因此,对于每个具有深度调用栈的虚拟线程,会有多个具有浅调用栈的虚拟线程,消耗很少的内存。
一般来说,很难将虚拟线程所需的堆空间和垃圾回收器活动与异步代码进行比较。一百万个虚拟线程需要至少一百万个对象,但一百万个共享平台线程池的任务也需要。此外,处理请求的应用程序代码通常在 I/O 操作之间维护数据。每个请求一个线程的代码可以将这些数据保存在局部变量中,这些局部变量存储在堆中的虚拟线程栈上,而异步代码必须将相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的栈帧布局比紧凑对象更浪费空间;另一方面,虚拟线程在许多情况下可以修改和重用它们的栈(取决于底层垃圾回收器的交互),而异步管道总是需要分配新的对象,所以虚拟线程可能需要更少的分配。总体而言,每个请求一个线程与异步代码的堆消耗和垃圾回收器活动应该大致相似。随着时间的推移,我们期望使虚拟线程栈的内部表示更加紧凑。
与平台线程栈不同,虚拟线程栈不是垃圾回收根,所以垃圾回收器(如执行并发堆扫描的 G1)在停止世界的暂停中不会遍历它们所包含的引用。这也意味着,如果一个虚拟线程被阻塞在例如 BlockingQueue.take()
上,并且没有其他线程可以获得对虚拟线程或队列的引用,那么该线程可以被垃圾回收——这是可以的,因为虚拟线程永远不会被中断或解除阻塞。当然,如果虚拟线程正在运行或者被阻塞但可能被解除阻塞,那么它就不会被垃圾回收。
虚拟线程目前的一个限制是 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。例如,
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
创建一个名为“duke”的新未启动虚拟线程。
“Thread.startVirtualThread(Runnable)” 是一种方便的方式来创建并启动一个虚拟线程。
一个“Thread.Builder”可以创建一个线程或一个 “ThreadFactory”,然后它可以创建具有相同属性的多个线程。
“Thread.isVirtual()” 测试一个线程是否是虚拟线程。
“Thread.getAllStackTraces()” 现在返回所有平台线程的映射而不是所有线程的映射。
这个 JEP 对“java.lang.Thread” API 的其他部分没有改变。“Thread”类定义的构造函数像以前一样创建平台线程。没有新的公共构造函数。
(“Thread”中的三个方法在虚拟线程中抛出“UnsupportedOperationException”——“stop()”、“suspend()” 和 “resume()”——在 JDK 20 中将被 改变 为在平台线程中也抛出“UnsupportedOperationException”。这个改变与这个 JEP 无关。)
虚拟线程和平台线程之间的主要 API 差异是:
公共的“Thread”构造函数不能创建虚拟线程。
虚拟线程总是守护线程。“Thread.setDaemon(boolean)” 方法不能将虚拟线程改为非守护线程。
虚拟线程具有固定的优先级为 “Thread.NORM_PRIORITY”。“Thread.setPriority(int)” 方法对虚拟线程没有效果。这个限制可能在未来的版本中被重新考虑。
虚拟线程不是线程组的活跃成员。当在虚拟线程上调用时,“Thread.getThreadGroup()” 返回一个名为“VirtualThreads”的占位线程组。“Thread.Builder” API 没有定义设置虚拟线程的线程组的方法。
当设置了“SecurityManager”时,虚拟线程没有权限。
线程局部变量
虚拟线程像平台线程一样支持线程局部变量(“ThreadLocal”)和可继承的线程局部变量(“InheritableThreadLocal”),所以它们可以运行使用线程局部变量的现有代码。然而,由于虚拟线程可能非常多,所以在使用线程局部变量时要仔细考虑。特别是,不要使用线程局部变量在线程池中多个共享同一线程的任务之间池化昂贵的资源。虚拟线程永远不应该被池化,因为每个虚拟线程在其生命周期内只打算运行一个任务。我们已经从“java.base”模块中删除了许多线程局部变量的使用,以便在运行数百万个线程时减少内存占用。
此外:
“Thread.Builder” API 定义了 一个在创建线程时选择不使用线程局部变量的方法。它还定义了 一个在创建线程时选择不继承可继承线程局部变量的初始值的方法。当从一个不支持线程局部变量的线程中调用时,“ThreadLocal.get()” 返回初始值,并且 “ThreadLocal.set(T)” 抛出异常。
传统的 上下文类加载器 现在被指定为像一个可继承的线程局部变量一样工作。如果在一个不支持线程局部变量的线程上调用 “Thread.setContextClassLoader(ClassLoader)”,那么它会抛出异常。
作用域值(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()”的预期行为。在大多数情况下,从多个并发线程中使用特定的输入或输出流是没有意义的。面向字符的读取器 / 写入器也没有被指定为线程安全的,但它们确实为子类暴露了一个锁对象。除了固定之外,这些类中的同步是有问题和不一致的;例如,“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 引用)作为参数调用的函数都可以用对虚拟线程的引用调用。少数函数,即“PopFrame”、“ForceEarlyReturn”、“StopThread”、“AgentStartFunction”和“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”表示一个虚拟线程在被固定时被暂停,即没有释放其平台线程(见 讨论)。这个事件默认是启用的,阈值为 20 毫秒。
“jdk.VirtualThreadSubmitFailed”表示启动或恢复一个虚拟线程失败,可能是由于资源问题。这个事件默认是启用的。
Java 管理扩展(JMX)
“java.lang.management.ThreadMXBean” 仅支持对平台线程的监控和管理。“findDeadlockedThreads()”方法找到处于死锁状态的平台线程的循环;它不会找到处于死锁状态的虚拟线程的循环。
“com.sun.management.HotSpotDiagnosticsMXBean” 中的一个新方法生成上述 可观察性 中描述的新样式线程转储。这个方法也可以通过本地或远程 JMX 工具从平台的 “MBeanServer” 间接调用。
替代方案
继续依赖异步 API。异步 API 难以与同步 API 集成,创建了同一 I/O 操作的两个表示的分裂世界,并且没有为平台提供一个用于故障排除、监控、调试和分析目的的操作序列的统一概念。
将“语法无栈协程”(即 async/await)添加到 Java 语言中。这些比用户模式线程更容易实现,并且将提供一个统一的构造来表示操作序列的上下文。
然而,那个构造将是新的,并且与线程分离,在许多方面与它们相似,但在一些细微之处有所不同。它将在为线程设计的 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,以及所有为支持虚拟线程而更改的区域。
新的压力测试将针对对可靠性和性能至关重要的区域。
新的微基准测试将针对性能关键区域。
风险和假设
这个提案的主要风险是由于现有 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”API 支持创建不支持线程局部变量的线程。当在不支持线程局部变量的线程上下文中调用时,“ThreadLocal.set(T)” 和 “Thread.setContextClassLoader(ClassLoader)” 会抛出“UnsupportedOperationException”。
“Thread.getAllStackTraces()” 现在返回所有平台线程的映射而不是所有线程的映射。
当在虚拟线程的上下文中调用时,由“java.net.Socket”、“ServerSocket”和“DatagramSocket”定义的阻塞 I/O 方法现在是可中断的。当一个阻塞在套接字操作上的线程被中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。
虚拟线程不是“ThreadGroup”的活跃成员。在虚拟线程上调用 “Thread.getThreadGroup()” 将返回一个空的虚拟“VirtualThreads”组。
当设置了“SecurityManager”时,虚拟线程没有权限。
在 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(重新实现传统数据报套接字 API),用为与虚拟线程一起使用而设计的新实现替换了“java.net.Socket”、“ServerSocket”和“DatagramSocket”的实现。
JDK 18 中的 JEP 418(互联网地址解析 SPI) 定义了一个用于主机名和地址查找的服务提供者接口。这将允许第三方库实现替代的“java.net.InetAddress”解析器,这些解析器在主机查找期间不会固定线程。