JEP 425: Virtual Threads (Preview) | 虚拟线程(预览)
摘要
向 Java 平台引入 虚拟线程。虚拟线程是轻量级的线程,能够极大地减少编写、维护和观察高吞吐量并发应用程序的工作量。这是一个 预览 API。
目标
- 使以简单的每请求一线程风格编写的服务器应用程序能够以接近最优的硬件利用率进行扩展。
- 使使用
java.lang.Thread
API 的现有代码能够以最小的更改采用虚拟线程。 - 允许使用现有的 JDK 工具轻松地对虚拟线程进行故障排除、调试和性能分析。
非目标
- 不打算移除线程的传统实现,也不打算默默地迁移现有应用程序以使用虚拟线程。
- 不打算改变 Java 的基本并发模型。
- 不打算在 Java 语言或 Java 库中提供新的数据并行性构造。处理大数据集的并行处理的首选方式仍然是 Stream API。
动机
近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每条语句都在一个线程内执行,并且由于 Java 是多线程的,因此多个执行线程会同时发生。线程是 Java 的 并发单元:它是与其他此类单元并发运行且在很大程度上独立于其他单元的顺序代码段。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及在出现问题时的上下文:异常由同一线程中的方法抛出并捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,而分析器可视化多个线程的行为以帮助理解它们的性能。
每请求一线程风格
服务器应用程序通常处理相互独立的并发用户请求,因此,应用程序通过为整个请求周期分配一个线程来处理请求是有意义的。这种 每请求一线程风格 易于理解、编程、调试和性能分析,因为它使用平台的并发单元来表示应用程序的并发单元。
服务器应用程序的可扩展性受 Little 定律 的制约,该定律将延迟、并发和吞吐量联系起来:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发数)必须与到达率(即吞吐量)成比例增长。例如,假设一个平均延迟为 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 中的 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() 被隐式调用,并等待
此示例中的任务很简单——休眠一秒——现代硬件可以轻松支持 10,000 个虚拟线程同时运行此类代码。在幕后,JDK 在少数几个 OS 线程上运行代码,可能只有一个。
如果此程序使用为每个任务创建新平台线程的 ExecutorService
(如 Executors.newCachedThreadPool()
),情况将大不相同。ExecutorService
将尝试创建 10,000 个平台线程,即 10,000 个 OS 线程,具体取决于机器和操作系统,程序可能会崩溃。
如果程序改用从池中获取平台线程的 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 个任务。
如果此程序中的任务执行一秒的计算(例如,对大型数组进行排序)而不是仅仅休眠,那么无论它们是虚拟线程还是平台线程,增加线程数超过处理器核心数都不会有帮助。虚拟线程并不是更快的线程——它们运行代码的速度并不比平台线程快。它们存在的目的是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。虚拟线程的数量可以远远超过平台线程,因此它们能够根据利特尔法则(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 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 时花费了多少时间。
线程转储是另一种用于排查按请求分配线程风格编写的应用程序的流行工具。不幸的是,使用 jstack
或 jcmd
获取的 JDK 传统线程转储仅提供了一个平面的线程列表。这适用于数十个或数百个平台线程,但不适用于成千上万个或数百万个虚拟线程。因此,我们不会扩展传统的线程转储以包含虚拟线程,而是将在 jcmd
中引入一种新型的线程转储,以有意义的方式将虚拟线程与平台线程一起呈现。当程序使用 结构化并发 时,可以显示线程之间更丰富的关系。
因为可视化和分析大量线程可以受益于工具的使用,jcmd
除了可以生成纯文本格式的线程转储外,还可以以 JSON 格式输出新的线程转储:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
新的线程转储格式列出了在网络 I/O 操作中阻塞的虚拟线程,以及由上述每个任务一个新线程的 ExecutorService
创建的虚拟线程。它不包含传统线程转储中出现的对象地址、锁、JNI 统计信息、堆统计信息和其他信息。此外,由于可能需要列出大量线程,因此生成新的线程转储不会暂停应用程序。
以下是一个来自与 上面第二个示例 类似的应用程序的线程转储示例,以 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 线程。这是因为 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 操作之间维护数据。每个请求一个线程的代码可以将这些数据保存在局部变量中,这些局部变量存储在堆中的虚拟线程栈上,而异步代码则必须将这些相同的数据保存在从管道的一个阶段传递到下一个阶段的堆对象中。一方面,虚拟线程所需的栈帧布局比紧凑对象更浪费;另一方面,虚拟线程可以在许多情况下修改和重用其栈(取决于低级 GC 交互),而异步管道则总是需要分配新对象,因此虚拟线程可能需要更少的分配。总体而言,每个请求一个线程与异步代码在堆消耗和垃圾回收器活动方面应该大致相似。随着时间的推移,我们期望使虚拟线程栈的内部表示更加紧凑。
与平台线程栈不同,虚拟线程栈不是 GC 根,因此垃圾回收器(如执行并发堆扫描的 G1)在停止世界暂停期间不会遍历其中包含的引用。这也意味着,如果虚拟线程被阻塞在例如 BlockingQueue.take()
上,并且没有其他线程可以获得对虚拟线程或队列的引用,则该线程可以被垃圾回收——这是可以的,因为虚拟线程永远不会被中断或取消阻塞。当然,如果虚拟线程正在运行或被阻塞且可能会被取消阻塞,则它不会被垃圾回收。
虚拟线程的一个当前限制是 G1 GC 不支持 巨大的 栈块对象。如果虚拟线程的栈达到区域大小的一半,这可能小到 512KB,则可能会抛出 StackOverflowError
。
详细更改
以下各小节详细描述了我们在 Java 平台及其实现中提出的更改:
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.join
和Thread.sleep
的新重载方法接受java.time.Duration
实例作为等待和睡眠时间。新的最终方法
Thread.threadId()
返回线程的标识符。现有的非最终方法Thread.getId()
现已弃用。Thread.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程的映射。
java.lang.Thread
API 的其余部分保持不变。Thread
类定义的构造函数仍用于创建平台线程,且没有新增公共构造函数。
虚拟线程和平台线程之间的主要 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)
,则会抛出异常。
对于某些用例,作用域局部变量 可能证明是线程局部变量的更好替代方案。
java.util.concurrent
支持锁定的原始 API,java.util.concurrent.LockSupport
,现在支持虚拟线程:挂起虚拟线程会释放底层平台线程以执行其他工作,而非挂起虚拟线程会安排其继续执行。对 LockSupport
的这一更改使得所有使用它的 API(如 Lock
、Semaphore
、阻塞队列等)在虚拟线程中调用时能够优雅地挂起。
此外:
Executors.newThreadPerTaskExecutor(ThreadFactory)
和Executors.newVirtualThreadPerTaskExecutor()
创建一个ExecutorService
,该服务为每个任务创建一个新线程。这些方法支持与使用线程池和ExecutorService
的现有代码的迁移和互操作性。ExecutorService
现在扩展了AutoCloseable
,因此允许将此 API 与 try-with-resource 构造一起使用,如上面的示例所示。Future
现在定义了获取已完成任务的结果或异常以及获取任务状态的方法。这些新增功能结合起来,使得Future
对象易于用作流中的元素,可以通过过滤未来的流来查找已完成的任务,然后映射它以获取结果流。这些方法还将有助于为 结构化并发 提出的 API 新增功能。
网络
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 Native Interface (JNI)
JNI 定义了一个新函数 IsVirtualThread
,用于测试一个对象是否为虚拟线程。
JNI 规范的其他部分保持不变。
调试
调试架构由三个接口组成:JVM 工具接口(JVM TI)、Java 调试线协议(JDWP)和 Java 调试接口(JDI)。现在,这三个接口都支持虚拟线程。
JVM TI 的更新包括:
大多数与
jthread
(即 JNI 对Thread
对象的引用)一起调用的函数现在也可以使用对虚拟线程的引用来调用。但是,少数函数,包括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 Management Extensions (JMX)
java.lang.management.ThreadMXBean
仅支持平台线程的监控和管理。findDeadlockedThreads()
方法查找处于死锁状态的平台线程循环;它不查找处于死锁状态的虚拟线程循环。
com.sun.management.HotSpotDiagnosticsMXBean
中添加了一个新方法,用于生成上面描述的 新样式线程转储。此方法也可以通过本地或远程 JMX 工具的平台 MBeanServer
间接调用。
java.lang.ThreadGroup
java.lang.ThreadGroup
是一个用于分组线程的遗留 API,在现代应用程序中很少使用,且不适合用于分组虚拟线程。我们现在将其弃用并降级,并期望在未来作为 结构化并发 的一部分引入新的线程组织构造。
作为背景,ThreadGroup
API 源自 Java 1.0。它最初旨在提供作业控制操作,如停止组中的所有线程。现代代码更可能使用 Java 5 中引入的 java.util.concurrent
包的线程池 API。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()
方法设置的守护状态现在被忽略。实现不再对子组保持强引用。现在,当组中没有活动线程且没有其他内容使线程组保持活动状态时,线程组有资格进行垃圾回收。
备选方案
继续依赖异步 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,以及所有为支持虚拟线程而更改的区域。
新的压力测试将针对可靠性和性能至关重要的区域。
新的微基准测试将针对性能关键区域。
风险和假设
本提案的主要风险是由于对现有 API 及其实现的更改而导致的兼容性问题:
对
java.io.BufferedInputStream
、BufferedOutputStream
、BufferedReader
、BufferedWriter
、PrintStream
和PrintWriter
类中使用的内部(且未记录)锁定协议的修订可能会影响假设 I/O 方法在调用它们的流上同步的代码。这些更改不会影响扩展这些类并假设由超类进行锁定的代码,也不会影响扩展java.io.Reader
或java.io.Writer
并使用这些 API 公开的锁对象的代码。java.lang.ThreadGroup
不再允许销毁线程组,不再支持 守护线程组 的概念,并且其suspend()
、resume()
和stop()
方法总是抛出异常。
存在少数源代码不兼容的 API 更改和一个二进制不兼容的更改,这些更改可能会影响扩展 java.lang.Thread
的代码:
如果现有源文件中的代码扩展了
Thread
,并且子类中的方法与任何新的Thread
方法冲突,则该文件在未经更改的情况下将无法编译。Thread.Builder
被添加为一个嵌套接口。如果现有源文件中的代码扩展了Thread
,导入了名为Builder
的类,并且子类中的代码以简单名称引用 “Builder”,则该文件在未经更改的情况下将无法编译。Thread.threadId()
被添加为一个最终方法,用于返回线程的标识符。如果现有源文件中的代码扩展了Thread
,并且子类声明了一个没有参数的名为threadId
的方法,则它无法编译。如果存在已编译的代码扩展了Thread
,并且子类定义了一个返回类型为long
且没有参数的名为threadId
的方法,则在加载子类时,如果子类被加载,则将在运行时抛出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.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(重新实现传统 DatagramSocket API) 替换了
java.net.Socket
、ServerSocket
和DatagramSocket
的实现,采用了专为虚拟线程设计的新实现。JDK 18 中的 JEP 418(Internet 地址解析 SPI) 定义了一个用于主机名和地址查找的服务提供者接口。这将允许第三方库实现替代的
java.net.InetAddress
解析器,这些解析器在主机查找过程中不会锁定线程。