JEP 436:虚拟线程(第二预览版)
概括
将_虚拟线程_引入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 中永久化。
目标
-
使以简单的每个请求线程风格编写的服务器应用程序能够以接近最佳的硬件利用率进行扩展。
-
使使用 API 的现有代码
java.lang.Thread
能够以最小的更改采用虚拟线程。 -
使用现有 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 线程与操作系统线程的一对一对应关系的方式来实现 Java 线程。正如操作系统通过将大的虚拟地址空间映射到有限的物理 RAM 来提供充足内存的假象一样,Java 运行时也可以通过将大量_虚拟_线程映射到少量操作系统线程来提供充足线程的假象。
_虚拟线程_是java.lang.Thread
不依赖于特定操作系统线程的实例。相比之下,_平台线程_是以java.lang.Thread
传统方式实现的实例,作为操作系统线程的薄包装器。
每个请求线程风格的应用程序代码可以在请求的整个持续时间内在虚拟线程中运行,但虚拟线程仅在 CPU 上执行计算时才消耗操作系统线程。结果是与异步风格相同的可扩展性,只不过它是以透明方式实现的:当虚拟线程中运行的代码调用 API 中的阻塞 I/O 操作时java.*
,运行时执行非阻塞 OS 调用并自动挂起虚拟线程直到稍后可以恢复。对于 Java 开发人员来说,虚拟线程只是创建成本低廉且数量几乎无限的线程。硬件利用率接近最佳,允许高水平的并发性,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持协调。
虚拟线程的含义
虚拟线程便宜且充足,因此永远不应该被池化:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程都是短暂的,并且具有浅层调用堆栈,只执行单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程重量级且昂贵,因此通常必须进行池化。它们往往寿命很长,具有很深的调用堆栈,并且在许多任务之间共享。
总之,虚拟线程保留了可靠的每请求线程风格,该风格与 Java 平台的设计相协调,同时最佳地利用硬件。使用虚拟线程不需要学习新概念,尽管它可能需要放弃为应对当今线程的高成本而养成的习惯。虚拟线程不仅可以帮助应用程序开发人员,还可以帮助框架设计人员提供易于使用的 API,这些 API 与平台的设计兼容,而不影响可扩展性。
描述
java.lang.Thread
如今, JDK 中的每个实例都是一个_平台线程_。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内捕获操作系统线程。平台线程数受限于操作系统线程数。
_虚拟线程_是在底层操作系统线程上运行 Java 代码的实例java.lang.Thread
,但不会在代码的整个生命周期内捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行它们的 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。
线程转储 是另一种流行的工具,用于对以每个请求线程风格编写的应用程序进行故障排除。不幸的是,JDK 的传统线程转储(使用jstack
或获得jcmd
)提供了一个简单的线程列表。这适用于数十或数百个平台线程,但不适合数千或数百万虚拟线程。因此,我们不会扩展传统的线程转储以包含虚拟线程,而是引入一种新的线程转储来jcmd
将虚拟线程与平台线程一起呈现,所有这些都以有意义的方式分组。当程序使用结构化并发时,可以显示线程之间更丰富的关系。
因为可视化和分析大量线程可以从工具中受益,所以jcmd
除了纯文本之外,还可以以 JSON 格式发出新的线程转储:
$ jcmd <pid> Thread.dump_to_file -format=json <file>
新的线程转储格式列出了网络 I/O 操作中被阻止的虚拟线程,以及由上面显示的 new-thread-per-task 创建的虚拟线程ExecutorService
。它不包括对象地址、锁、JNI 统计信息、堆统计信息以及传统线程转储中出现的其他信息。此外,由于它可能需要列出大量线程,因此生成新的线程转储不会暂停应用程序。
以下是此类线程转储的示例,取自与上面第二个示例类似的应用程序,在 JSON 查看器中呈现(单击可放大):
由于虚拟线程是在 JDK 中实现的,并且不依赖于任何特定的操作系统线程,因此它们对于操作系统来说是不可见的,操作系统不知道它们的存在。操作系统级监控将观察到 JDK 进程使用的操作系统线程少于虚拟线程。
调度虚拟线程
为了完成有用的工作,需要调度线程,即分配线程在处理器核心上执行。对于作为操作系统线程实现的平台线程,JDK 依赖于操作系统中的调度程序。相比之下,对于虚拟线程,JDK 有自己的调度程序。 JDK的调度程序不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的M:N调度)。然后,操作系统像往常一样调度平台线程。
JDK的虚拟线程调度程序是一个以先进先出ForkJoinPool
(FIFO)模式运行的工作窃取程序。调度程序的_并行度_是可用于调度虚拟线程的平台线程的数量。默认情况下,它等于可用处理器的数量,但可以通过系统属性进行调整jdk.virtualThreadScheduler.parallelism
。请注意,这与公共池ForkJoinPool
不同,公共池用于例如并行流的实现,并且以 LIFO 模式运行。
调度程序为其分配虚拟线程的平台线程称为虚拟线程的_载体_。虚拟线程在其生命周期内可以被调度到不同的载体上;换句话说,调度程序不维护虚拟线程和任何特定平台线程之间的_关联性_。从Java代码的角度来看,一个正在运行的虚拟线程在逻辑上独立于它当前的载体:
-
虚拟线程无法获取运营商的身份。返回的值
Thread.currentThread()
始终是虚拟线程本身。 -
载体和虚拟线程的堆栈跟踪是分开的。虚拟线程中抛出的异常将不包括载体的堆栈帧。线程转储不会显示虚拟线程堆栈中载体的堆栈帧,反之亦然。
-
载体的线程局部变量对于虚拟线程不可用,反之亦然。
另外,从Java代码的角度来看,虚拟线程及其载体暂时共享OS线程的事实是不可见的。相比之下,从本机代码的角度来看,虚拟线程及其载体都运行在同一个本机线程上。因此,在同一虚拟线程上多次调用的本机代码可能会在每次调用时观察到不同的操作系统线程标识符。
调度程序当前 不实现虚拟线程的_时间共享_。分时是对消耗了分配的 CPU 时间的线程进行强制抢占。虽然当平台线程数量相对较少且 CPU 利用率为 100% 时,时间共享可以有效减少某些任务的延迟,但尚不清楚时间共享对于 100 万个虚拟线程是否同样有效。
执行虚拟线程
要利用虚拟线程,无需重写程序。虚拟线程不需要或期望应用程序代码显式地将控制权交还给调度程序;换句话说,虚拟线程是不合作_的_。用户代码不得对如何或何时将虚拟线程分配给平台线程做出任何假设,就像不得对如何或何时将平台线程分配给处理器内核做出假设一样。
为了在虚拟线程中运行代码,JDK的虚拟线程调度程序通过将虚拟线程_挂载_到平台线程上来分配虚拟线程在平台线程上执行。这使得平台线程成为虚拟线程的载体。稍后,在运行一些代码后,虚拟线程可以从其载体上_卸载_。此时平台线程是空闲的,因此调度程序可以在其上安装不同的虚拟线程,从而使其再次成为载体。
通常,虚拟线程在 I/O 阻塞或 JDK 中的其他阻塞操作(例如BlockingQueue.take()
.当阻塞操作准备完成时(例如,套接字上已接收到字节),它将虚拟线程提交回调度程序,调度程序将虚拟线程安装在载体上以恢复执行。
虚拟线程的挂载和卸载频繁且透明地发生,并且不会阻塞任何操作系统线程。例如,前面显示的服务器应用程序包含以下代码行,其中包含对阻塞操作的调用:
response.send(future1.get() + future2.get());
这些操作将导致虚拟线程多次挂载和卸载,通常每次调用一次,get()
并且可能在执行 I/O 的过程中多次挂载和卸载send(...)
。
JDK中的绝大多数阻塞操作都会卸载虚拟线程,释放其载体和底层操作系统线程来承担新的工作。然而,JDK中的一些阻塞操作不会卸载虚拟线程,从而阻塞其载体和底层操作系统线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如,Object.wait()
)的限制。这些阻塞操作的实现将通过暂时扩展调度程序的并行性来补偿操作系统线程的捕获。因此,调度程序中的平台线程数量ForkJoinPool
可能会暂时超过可用处理器的数量。调度程序可用的最大平台线程数可以通过系统属性进行调整jdk.virtualThreadScheduler.maxPoolSize
。
在两种情况下,虚拟线程 在阻塞操作期间无法卸载,因为它被_固定_到其载体上:
- 当它执行块或方法内的代码时
synchronized
,或者 - 当它执行一个
native
方法或一个外部函数时。
固定不会使应用程序不正确,但可能会妨碍其可扩展性。如果虚拟线程执行阻塞操作(例如 I/O)或BlockingQueue.take()
在其被固定时,则其载体和底层操作系统线程在操作期间将被阻塞。长时间频繁固定可能会通过捕获载波来损害应用程序的可扩展性。
调度程序不会通过扩展其并行性来补偿固定。相反,应通过修改频繁运行的块或方法来避免频繁且长期的固定synchronized
,并保护可能使用的长时间 I/O 操作java.util.concurrent.locks.ReentrantLock
。无需替换不synchronized
经常使用的块和方法(例如,仅在启动时执行)或保护内存中操作的块和方法。一如既往,努力保持锁定策略简单明了。
synchronized
新的诊断有助于将代码迁移到虚拟线程并评估是否应该用锁替换特定用途java.util.concurrent
:
-
当线程在固定状态下阻塞时,会发出 JDK Flight Recorder (JFR) 事件(请参阅JDK Flight Recorder)。
-
jdk.tracePinnedThreads
当线程在固定状态下阻塞时,系统属性会触发堆栈跟踪。-Djdk.tracePinnedThreads=full
当线程在固定状态下阻塞时,运行 with会打印完整的堆栈跟踪,并且本机框架和保持监视器突出显示的框架。运行 with-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.util.concurrent
- 联网
java.io
- Java 本机接口 (JNI)
- 调试(JVM TI、JDWP 和 JDI)
- JDK 飞行记录器 (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)
是创建然后启动虚拟线程的便捷方法。 -
A
Thread.Builder
可以创建一个线程,也可以创建一个ThreadFactory
,然后 a 可以创建多个具有相同属性的线程。 -
Thread.isVirtual()
测试线程是否是虚拟线程。 -
Thread.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程。
该java.lang.Thread
JEP 在其他方面没有改变 API。Thread
与以前一样,该类定义的构造函数创建平台线程。没有新的公共构造函数。
(在 JDK 20 中,虚拟线程的Thread
throw的三个方法— 、和— 也将更改为平台线程的throw 。此更改独立于此 JEP。)UnsupportedOperationException
stop()
suspend()
resume()
UnsupportedOperationException
虚拟线程和平台线程之间的主要 API 差异是:
-
公共
Thread
构造函数不能创建虚拟线程。 -
虚拟线程始终是守护线程。该
Thread.setDaemon(boolean)
方法无法将虚拟线程更改为非守护线程。 -
虚拟线程具有固定的优先级
Thread.NORM_PRIORITY
。该Thread.setPriority(int)
方法对虚拟线程没有影响。未来版本中可能会重新考虑此限制。 -
虚拟线程不是线程组的活动成员。在虚拟线程上调用时,
Thread.getThreadGroup()
返回一 个名为 的占位符线程组"VirtualThreads"
。 APIThread.Builder
没有定义设置虚拟线程的线程组的方法。 -
虚拟线程在集合运行时没有权限
SecurityManager
。
线程局部变量
虚拟线程支持线程局部变量 ( ThreadLocal
) 和可继承的线程局部变量 ( InheritableThreadLocal
),就像平台线程一样,因此它们可以运行使用线程局部变量的现有代码。但是,由于虚拟线程可能非常多,因此请在仔细考虑后使用线程局部变量。特别是,不要使用线程局部变量在线程池中共享同一线程的多个任务之间池化昂贵的资源。虚拟线程永远不应该被池化,因为每个虚拟线程在其生命周期内只运行一个任务。我们从模块中删除了许多线程局部变量的使用,java.base
为虚拟线程做准备,以减少运行数百万线程时的内存占用。
此外:
-
API
Thread.Builder
定义了一种在创建线程时选择退出线程局部变量的方法。它还定义了一个方法来选择不继承可继承的 thread-locals 的初始值。当从不支持线程局部变量的线程调用时,ThreadLocal.get()
返回初始值并ThreadLocal.set(T)
引发异常。 -
遗留上下文类加载器现在被指定为像可继承的本地线程一样工作。如果
Thread.setContextClassLoader(ClassLoader)
在不支持线程局部变量的线程上调用,则会引发异常。
对于某些用例,作用域值(JEP 429)可能被证明是线程局部变量的更好替代方案。
java.util.concurrent
支持锁定的原始 APIjava.util.concurrent.LockSupport
现在支持虚拟线程:停放虚拟线程会释放底层平台线程以执行其他工作,取消停放虚拟线程会安排其继 续。此更改LockSupport
使所有使用它的 API(Lock
s、Semaphore
s、阻塞队列等)在虚拟线程中调用时能够正常停放。
此外:
Executors.newThreadPerTaskExecutor(ThreadFactory)
并Executors.newVirtualThreadPerTaskExecutor()
创建一个ExecutorService
为每个任务创建一个新线程的线程。这些方法支持与使用线程池和ExecutorService
.
联网
java.net
和包中网络 API 的实现java.nio.channels
现在可以使用虚拟线程:虚拟线程上的操作会阻塞,例如建立网络连接或从套接字读取,释放底层平台线程以执行其他工作。
为了允许中断和取消,由java.net.Socket
、ServerSocket
和定义的阻塞 I/O 方法DatagramSocket
现在被指定为在虚拟线程中调用时可中断:中断在套接字上阻塞的虚拟线程将取消驻留线程并关闭套接字。从通道获取时阻止这些类型的套接字上的 I/O 操作InterruptibleChannel
始终是可中断的,因此此更改使这些 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
大多数使用 a (即对象的 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 飞行记录器 (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()
就必须返回某种看起来像 aThread
但委托给用户模式线程对象的包装对象。让两个对象代表当前执行线程会令人困惑,因此我们最终得出结论,保留旧
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)
方法对虚拟线程没有影响,虚拟线程始终是守护线程。 -
API
Thread
支持创建不支持线程局部变量的线程。ThreadLocal.set(T)
并在不支持线程局部变量的线程上下文中调用时Thread.setContextClassLoader(ClassLoader)
抛出异常。UnsupportedOperationException
-
Thread.getAllStackTraces()
现在返回所有平台线程的映射,而不是所有线程的映射。 -
现在,当在虚拟线程上下文中调用时,由 、 和 定义的阻塞 I/O 方法是可
java.net.Socket
中断ServerSocket
的。DatagramSocket
当套接字操作上阻塞的线程被中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。 -
虚拟线程不是
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(重新实现旧版 Socket API)和JDK 15 中的JEP 373(重新实现旧版 DatagramSocket API)用设计用于虚拟线程的新实现替换了
java.net.Socket
、ServerSocket
和的实现。DatagramSocket
-
JDK 18 中的JEP 418(互联网地址解析 SPI)定义了用于主机名和地址查找的服务提供者接口。这将允许第三方库实现
java.net.InetAddress
在主机查找期间不固定线程的替代解析器。