虚拟线程是一种轻量级线程,它可以极大地降低编写、维护和监控高吞吐量并发应用程序的难度。
历史
虚拟线程最初由 JEP 425 作为预览功能提出,并在 JDK 19 中发布。为了留出时间收集反馈并积累更多使用经验,它们再次由 JEP 436 作为预览功能提出,并在 JDK 20 中发布。本 JEP 提议在 JDK 21 中正式完成虚拟线程的功能,并根据开发者的反馈,对 JDK 20 中的版本做出以下更改:
现在虚拟线程始终支持线程局部变量(thread-local variables)。不再像预览版中那样,可以创建不支持线程局部变量的虚拟线程。对线程局部变量的全面支持确保了更多的现有库可以在无需修改的情况下与虚拟线程一起使用,并有助于将面向任务的代码迁移到使用虚拟线程。
默认情况下,通过 Thread.Builder API 直接创建的虚拟线程(而不是通过 Executors.newVirtualThreadPerTaskExecutor() 创建的虚拟线程),在其整个生命周期内也都可以被监控,并可通过“观察虚拟线程”章节中描述的新线程转储机制进行查看。
目标
- 支持采用简单的“每个请求一个线程”风格编写的服务器应用程序,能够实现接近最优的硬件利用率并轻松扩展
- 使使用 java.lang.Thread API 编写的现有代码能够以极少的改动迁移到虚拟线程。
- 使开发者能够使用现有的 JDK 工具轻松地对虚拟线程进行故障排查、调试和性能分析。
非目标
- 我们的目标不是移除传统线程的实现,也不是在不通知的情况下将现有应用程序静默迁移到使用虚拟线程。
- 我们的目标不是改变 Java 的基本并发模型。
- 我们的目标不是在 Java 语言或 Java 类库中引入新的数据并行结构。Stream API 仍然是并行处理大规模数据集的首选方式。
动机
近三十年来,Java 开发者一直依赖线程作为构建并发服务器应用程序的基本单元。每个方法中的每一条语句都是在线程内部执行的,并且由于 Java 是多线程的,因此可以同时发生多个执行线程。线程是 Java 并发的基本单位:一段顺序执行的代码,它可以与其他类似的单元并发且基本独立地运行。每个线程都提供了一个栈,用于存储局部变量、协调方法调用,以及在出现问题时提供上下文信息:异常由同一个线程中的方法抛出和捕获,因此开发者可以使用线程的堆栈跟踪来查明发生了什么问题。线程也是各种工具的核心概念:调试器通过线程中的方法逐行执行,性能分析工具则可视化多个线程的行为,以帮助理解其性能表现。
“每个请求一个线程”的风格
服务器应用程序通常处理彼此独立的并发用户请求,因此让一个线程在整个请求期间专门负责处理该请求是一种合理的设计方式。这种“每个请求一个线程”的风格易于理解、编写,并且便于调试和性能分析,因为它使用了平台的并发基本单位来表示应用程序的并发单元。
服务器应用程序的可扩展性受利特尔定律(Little’s Law)支配,该定律描述了延迟(latency)、并发性(concurrency)和吞吐量(throughput)之间的关系:在给定请求处理时间(即延迟)的情况下,应用程序同时处理的请求数(即并发性)必须随着请求到达率(即吞吐量)的增加而成比例增长。例如,假设一个应用程序平均延迟为50毫秒,通过同时处理10个请求达到了每秒200个请求的吞吐量。为了让该应用程序扩展到每秒2000个请求的吞吐量,它将需要同时处理100个请求。如果每个请求在其持续期间都由一个线程处理,那么为了跟上吞吐量的增长,线程数量也必须相应增加。
不幸的是,可用的线程数量是有限的,因为JDK中的线程是作为对操作系统(OS)线程的封装实现的。而操作系统线程的资源开销较大,因此我们无法创建太多这样的线程,这使得当前的线程实现不适合“每个请求一个线程”的编程风格。如果每个请求在其整个生命周期中都占用一个线程,从而占用一个操作系统线程,那么通常在线程数量成为瓶颈之前,其他资源如CPU或网络连接可能还未被耗尽。JDK目前的线程实现限制了应用程序的吞吐量,使其远低于硬件所能支持的水平。即使使用线程池也无法根本解决这个问题,因为线程池虽然可以避免频繁创建新线程的高昂代价,但并不能增加总的线程数量。
通过异步风格提升可扩展性
一些希望充分利用硬件性能的开发者已经放弃了“每个请求一个线程”的编程风格,转而采用线程共享的方式。不同于从头到尾在一个线程中处理一个请求,这种新方式在请求等待某个 I/O 操作完成时,会将当前线程归还给线程池,以便该线程可以去处理其他请求。这种细粒度的线程共享方式——即代码只在执行计算时占用线程,而在等待 I/O 时不占用线程——可以在不使用大量线程的前提下支持高并发操作。虽然这种方式消除了由于操作系统线程数量有限所造成的吞吐量瓶颈,但代价也很高:它要求采用一种被称为异步编程风格的方式。
在这种风格中,需要使用一组特殊的 I/O 方法,这些方法不会阻塞等待 I/O 操作完成,而是在稍后通过回调通知操作已完成。由于没有专门的线程来顺序执行整个请求逻辑,开发者必须将请求处理逻辑拆分成多个小阶段,通常以 lambda 表达式的形式编写,然后通过某个 API 将它们组合成一个顺序的流水线(例如使用 CompletableFuture
,或者所谓的“响应式”框架)。这样一来,开发者就无法再使用 Java 语言中基本的顺序控制结构,比如循环或 try/catch 块。
在异步风格中,一个请求的不同阶段可能由不同的线程执行,而每个线程则以交错的方式处理来自不同请求的各个阶段。这给理解程序行为带来了深远的影响:堆栈跟踪不再能提供有用的上下文信息,调试器无法逐步执行请求处理逻辑,性能分析工具也无法将某个操作的开销与其调用者关联起来。
当使用 Java 的流式 API 来处理简短的数据流水线时,lambda 表达式的组合方式尚可管理;但如果应用程序中的所有请求处理逻辑都必须以这种方式编写,则会变得非常复杂和难以维护。这种编程风格与 Java 平台的设计理念存在冲突,因为此时应用程序的并发单元——异步流水线——已不再是平台所支持的并发单元。
通过虚拟线程保留“每个请求一个线程”的风格
为了使应用程序在保持与平台一致的同时实现良好的可扩展性,我们应该努力保留“每个请求一个线程”的编程风格。我们可以通过更高效地实现线程来做到这一点,从而使得线程可以更加“ plentiful”(丰富)地被使用。
操作系统无法更高效地实现操作系统线程(OS threads),因为不同的语言和运行时对线程栈的使用方式各不相同。然而,Java 运行时却可以采用一种新的方式来实现 Java 线程,使其不再与操作系统线程一一对应。这就像操作系统通过将大的虚拟地址空间映射到有限的物理内存上,从而营造出“内存充足”的假象一样,Java 运行时也可以通过将大量的虚拟线程映射到少量的操作系统线程上,来营造出“线程充足”的假象。
虚拟线程是一个 java.lang.Thread
的实例,但它并不绑定到某个特定的操作系统线程。相比之下,平台线程则是以传统方式实现的 java.lang.Thread
实例,它本质上是对操作系统线程的一个薄封装。
采用“每个请求一个线程”风格编写的应用程序代码可以在一个虚拟线程中完整地运行整个请求生命周期,但虚拟线程只有在执行 CPU 计算时才会占用操作系统线程。其结果与异步风格相当的可扩展性,但这一切是透明完成的:当运行在虚拟线程中的代码调用 java.*
类库中的阻塞式 I/O 操作时,运行时会执行一个非阻塞的操作系统调用,并自动挂起该虚拟线程,直到稍后可以恢复为止。
对 Java 开发者来说,虚拟线程就是一种创建成本极低、几乎可以无限使用的线程。硬件利用率接近最优水平,从而支持高度并发,进而实现高吞吐量。同时,应用程序仍然与 Java 平台的多线程设计及其工具链保持一致。
虚拟线程的影响
虚拟线程是轻量级的、成本低廉且数量众多,因此永远不需要使用线程池:每个应用程序任务都应该创建一个新的虚拟线程。因此,大多数虚拟线程都是生命周期很短的,并且调用栈较浅,可能只执行一个 HTTP 客户端调用或一个 JDBC 查询。相比之下,平台线程是重量级的、资源开销较大,因此通常必须使用线程池。它们往往生命周期较长、调用栈较深,并被多个任务共享。
总结来说,虚拟线程保留了与 Java 平台设计理念一致的、“每个请求一个线程”的可靠编程风格,同时实现了对硬件资源的最优利用。使用虚拟线程并不需要学习新的概念,尽管你可能需要摒弃一些为应对当前线程高昂成本而养成的习惯。虚拟线程不仅有助于应用程序开发者,也将帮助框架设计者提供易于使用的 API,这些 API 既与平台的设计兼容,又不会牺牲可扩展性。
描述
目前,JDK 中每一个 java.lang.Thread
的实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在整个代码执行期间独占该操作系统线程。因此,平台线程的数量受限于操作系统线程的数量。
而虚拟线程也是 java.lang.Thread
的一个实例,它同样在底层操作系统线程上运行 Java 代码,但不会在整个执行期间独占操作系统线程。这意味着多个虚拟线程可以在同一个操作系统线程上交替执行,实现对该线程的共享。当一个平台线程独占宝贵的 OS 线程资源时,虚拟线程则不会造成这种占用。因此,虚拟线程的数量可以远远超过操作系统线程的数量。
虚拟线程是由 JDK 实现的一种轻量级线程机制,而不是由操作系统提供的。它们是一种用户态线程(user-mode threads)的形式,在其他多线程语言中已有成功应用(例如 Go 中的 goroutines 和 Erlang 中的进程)。在 Java 早期版本中也曾经出现过类似的概念,被称为“绿色线程”(green threads),当时操作系统线程尚未成熟和普及。然而,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() 被隐式调用,并等待所有任务完成
在这个例子中,任务很简单——休眠一秒——现代硬件可以轻松支持并发运行一万个执行这种代码的虚拟线程。在底层,JDK 将这些任务运行在少量操作系统线程上,可能甚至只使用了一个。
如果这个程序改用为每个任务创建一个平台线程的 ExecutorService
,例如 Executors.newCachedThreadPool()
,情况就会大不相同。该 ExecutorService
会尝试创建一万个平台线程,也就是一万个操作系统线程,这可能导致程序崩溃,具体取决于机器和操作系统。
如果程序改用从线程池中获取平台线程的方式,例如 Executors.newFixedThreadPool(200)
,情况也不会好太多。此时 ExecutorService
会创建 200 个平台线程供全部 10,000 个任务共享,因此许多任务将不得不串行执行,导致整个程序运行时间大幅增加。对于这个程序来说,使用 200 个平台线程只能达到每秒处理 200 个任务的吞吐量,而使用虚拟线程则能达到接近每秒处理 10,000 个任务的吞吐量(经过适当预热后)。
此外,如果将示例程序中的 10_000
改为 1_000_000
,那么程序将提交一百万任务、创建一百万个虚拟线程并发执行,并在预热后实现每秒约一百万任务的吞吐量。
但如果这些任务不是简单的休眠,而是进行一秒的计算(例如排序一个巨大的数组),那么无论是虚拟线程还是平台线程,只要线程数量超过 CPU 核心数,就无法带来性能提升。虚拟线程并不是“更快”的线程——它们并不会比平台线程更快地执行代码。它们存在的意义在于提供更高的可扩展性(更高的吞吐量),而不是提高单个任务的执行速度(更低的延迟)。由于虚拟线程的数量可以远多于平台线程,因此可以根据利特尔定律(Little’s Law)实现更高的并发性,从而提升整体吞吐量。
换句话说,当满足以下两个条件时,虚拟线程可以显著提升应用程序的吞吐量:
- 并发任务数量很高(超过几千个);
- 工作负载不是 CPU 密集型的,因为在这种情况下,线程数量远超 CPU 核心数也无法提升吞吐量。
虚拟线程之所以能显著改善典型服务器应用的吞吐能力,正是因为这类应用通常包含大量并发任务,而这些任务大部分时间都在等待 I/O 操作完成。
虚拟线程可以运行平台线程所能运行的任何代码。特别是,虚拟线程支持线程局部变量(thread-local variables) 和 线程中断(thread interruption),就像平台线程一样。这意味着现有的 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);
}
}
像这样结构清晰、使用简单阻塞式代码的服务器应用程序可以很好地扩展,因为它可以利用大量的虚拟线程。
Executors.newVirtualThreadPerTaskExecutor()
并不是创建虚拟线程的唯一方式。下文将讨论的新的 java.lang.Thread.Builder
API 也可以用来创建并启动虚拟线程。此外,结构化并发(structured concurrency) 提供了更强大的 API 来创建和管理虚拟线程,尤其是在类似上面这个服务器示例的代码中,它可以让平台及其工具清楚地了解线程之间的关系。
不要对虚拟线程使用线程池
开发者通常会将应用程序代码从传统的基于线程池的 ExecutorService
迁移到每个任务使用一个虚拟线程的 ExecutorService
。线程池和任何资源池一样,其设计目的是为了共享那些创建成本高昂的资源。但虚拟线程本身是轻量级且廉价的,因此永远不需要对它们进行池化。
有时,开发者使用线程池来限制对有限资源的并发访问。例如,如果某个服务最多只能处理 20 个并发请求,那么通过将所有对该服务的请求提交到一个大小为 20 的线程池中,就可以实现并发控制。这种做法在过去非常普遍,因为平台线程的成本很高,导致线程池无处不在。但请不要试图用池化虚拟线程的方式来限制并发量。相反,应该使用专门为此设计的结构,例如 信号量(semaphore)。
在使用线程池的同时,开发者有时会利用 ThreadLocal
变量,在多个共享同一个线程的任务之间共享昂贵的资源。例如,如果创建数据库连接代价很高,你可以在一个线程中打开一次连接,并将其存储在一个 ThreadLocal
变量中,供该线程中的其他任务重复使用。
如果你将代码从使用线程池的方式迁移到每个任务使用一个虚拟线程的方式,请注意这种编程习惯可能会带来问题。因为如果每个虚拟线程都创建一个昂贵的资源,会导致性能显著下降。
你应该修改这类代码,采用其他的缓存策略,使得昂贵的资源能够在大量的虚拟线程之间被高效地共享。
观察虚拟线程
写出清晰的代码只是故事的一部分。为了进行故障排查、维护和性能优化,对正在运行的程序状态的清晰呈现也至关重要。长期以来,JDK 提供了用于调试、性能分析和监控线程的机制。这些工具也应该以类似方式支持虚拟线程——也许需要针对其数量庞大的特点做一些调整,毕竟它们本质上仍然是 java.lang.Thread
的实例。
Java 调试器可以逐行执行虚拟线程、显示调用栈,并检查栈帧中的变量。JDK Flight Recorder(JFR)是 JDK 中低开销的性能分析与监控机制,它可以将来自应用程序代码的事件(如对象分配和 I/O 操作)正确地关联到对应的虚拟线程上。而这些功能在使用异步风格编写的应用中是无法实现的。在那种编程风格中,任务与线程之间没有直接关联,因此调试器无法显示或操作任务的状态,性能分析工具也无法准确统计任务等待 I/O 所花费的时间。
线程转储(thread dump) 是排查“每个请求一个线程”风格应用问题的常用工具。然而,JDK 传统的线程转储(通过 jstack
或 jcmd
获取)只是一个扁平的线程列表。这种格式适合几十或几百个平台线程的情况,但不适合成千上万甚至数百万个虚拟线程的情况。因此,我们不会扩展传统线程转储来包含虚拟线程;而是会在 jcmd
中引入一种新型线程转储,以结构化的方式同时展示虚拟线程和平台线程。
当程序使用结构化并发(structured concurrency)时,线程之间的丰富关系也可以被展现出来。
由于可视化和分析大量线程需要工具支持,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 进程使用的操作系统线程数量要少于虚拟线程的数量。
虚拟线程的调度
要执行有用的工作,线程需要被调度,也就是被分配到处理器核心上执行。对于平台线程(作为操作系统线程实现的线程),JDK 依赖操作系统的调度器来完成调度。而对于虚拟线程,JDK 拥有自己的调度器。它不是直接将虚拟线程分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的 M:N 调度模型)。然后平台线程由操作系统按照常规方式进行调度。
JDK 的虚拟线程调度器是一个采用 FIFO 模式工作的工作窃取 ForkJoinPool。该调度器的并行度是指可用于调度虚拟线程的平台线程数量。默认情况下,它等于可用处理器的数量,但可以通过系统属性 jdk.virtualThreadScheduler.parallelism
来调整。这个 ForkJoinPool 不同于用于并行流等任务的公共池,后者使用的是 LIFO 模式。
调度器将虚拟线程分配到的平台线程被称为该虚拟线程的载体(carrier)。在虚拟线程的生命周期中,它可以被调度到不同的载体上运行;换句话说,调度器不会维持虚拟线程与某个特定平台线程之间的亲和性。
从 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
调整调度器可用的最大平台线程数。
有两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被“钉住”在其载体上:
- 当它在同步块或同步方法中执行时;
- 当它执行本地方法或外部函数时。
钉住状态不会使应用程序出错,但可能会影响其可扩展性。如果虚拟线程在被钉住的状态下执行了一个阻塞操作(如 I/O 或 BlockingQueue.take()
),那么它的载体和底层操作系统线程将在整个操作期间被阻塞。长时间频繁的钉住操作会因为占用载体而影响应用程序的可扩展性。
调度器不会通过扩展其并行度来补偿钉住状态。因此,应避免频繁且长时间的钉住行为,可以通过修改频繁运行的同步块或方法,或将可能长时间 I/O 操作的保护替换为使用 java.util.concurrent.locks.ReentrantLock
来实现。对于不常使用的同步块或方法(例如仅在启动时使用),或者仅保护内存操作的方法,则无需替换。一如既往,应尽量保持锁策略的简洁清晰。
新的诊断功能可以帮助你将代码迁移到虚拟线程,并判断是否应将某处 synchronized
替换为 java.util.concurrent
包中的锁:
- 当线程在被钉住状态下阻塞时,JDK Flight Recorder(JFR)会生成一个事件(参见 JDK Flight Recorder)。
- 系统属性
jdk.tracePinnedThreads
可以在虚拟线程被钉住并阻塞时打印堆栈跟踪。使用-Djdk.tracePinnedThreads=full
参数运行程序会在虚拟线程被钉住并阻塞时打印完整堆栈跟踪,并突出显示本地帧和持有监视器的帧;使用-Djdk.tracePinnedThreads=short
参数则只输出有问题的帧。
在未来版本中,我们可能会移除上述第一个限制,即对 synchronized
内部钉住的限制。第二个限制则是为了与本地代码正确交互所必需的。
内存使用与垃圾回收的交互
虚拟线程的栈作为栈片段(stack chunk)对象存储在 Java 的垃圾回收堆中。随着程序运行,这些栈会动态增长和收缩,既为了节省内存,也为了支持深度达到 JVM 配置的平台线程栈大小的调用栈。这种内存效率是支持大量虚拟线程存在的关键,从而保证了服务器应用程序中“每个请求一个线程”风格的可持续性。
在上面的第二个示例中,请回想那个假设的框架:它通过创建一个新的虚拟线程并调用 handle
方法来处理每个请求。即使 handle
是在一个很深的调用栈末端被调用的(比如经过身份验证、事务处理等),它本身又会生成多个仅执行短暂任务的虚拟线程。因此,对于每一个具有深调用栈的虚拟线程来说,会有多个具有浅调用栈、占用内存较少的虚拟线程存在。
一般来说,虚拟线程所需的堆空间和垃圾回收活动很难与异步代码进行直接比较。一百万个虚拟线程至少需要一百万个对象,但共享平台线程池的一百万个任务也是如此。此外,处理请求的应用程序代码通常需要在 I/O 操作之间保留数据。基于“每个请求一个线程”的代码可以将这些数据保存在局部变量中,而这些变量会被存储在堆中的虚拟线程栈上;而异步代码则必须将这些数据保存在堆对象中,并在流水线的不同阶段之间传递。一方面,虚拟线程所需的栈帧布局可能比紧凑的对象更浪费空间;另一方面,在很多情况下,虚拟线程可以修改和重用它们的栈(取决于底层 GC 的交互方式),而异步流水线总是需要分配新的对象,因此虚拟线程可能需要更少的内存分配。总体而言,“每个请求一个线程”风格代码与异步代码在堆内存消耗和垃圾回收活动方面的表现大致相当。随着时间推移,我们期望能显著优化虚拟线程栈的内部表示,使其更加紧凑。
与平台线程的栈不同,虚拟线程的栈不是 GC Roots。因此,像 G1 这样的垃圾回收器在执行并发堆扫描时,不会在“Stop-the-World”暂停期间遍历虚拟线程栈中的引用。
当前虚拟线程的一个限制是:G1 垃圾回收器不支持超大尺寸(humongous)的栈片段对象。如果一个虚拟线程的栈达到了一个内存区域大小的一半(可能小至 512KB),就可能会抛出 StackOverflowError
错误。
参考
https://docs.oracle.com/en/java/javase/21/core/concurrency.html