JEP 444:虚拟线程

Release 21

概要

在Java平台引入虚拟线程。虚拟线程是轻量级线程,可以显著降低编写、维护以及监视高吞吐量并发应用的工作量。

历史

虚拟线程是JEP425提出的预览功能,并在JDK19中提供。为了留出时间进行反馈并获得更多经验,JEP436再次将它们作为预览功能提出,并在JDK20中提供。根据开发人员的反馈,本JEP建议在JDK 21中完成虚拟线程定案,并对JDK 20进行以下更改:

  • 虚拟线程现在总是支持线程局部变量。不再像预览版中那样,可以创建没有线程本地变量的虚拟线程。对线程本地变量有保证的支持确保了更多现有库可以与虚拟线程一起使用,并且有助于将面向任务的代码迁移到使用虚拟线程。
  • 直接使用Thread.Builder API创建的虚拟线程(与通过Executors.newVirtualThreadPerTaskExecutor()创建的不同)现在默认情况下也会在其整个生命周期内进行监控,并通过“观察虚拟线程”一节描述的新线程dump进行观察。

目标

  • 对于简单的以请求线程(thread-per-request)方式编写的服务端应用程序能够以接近最佳硬件利用率进行扩展。
  • 对于现有的已经采用java.lang.Thread API的代码,可以通过最小变更来使用虚拟线程。
  • 使用现有JDK工具可以轻松的对虚拟线程进行故障排除、调试和分析。

非目标

  • 目标不是删除传统的线程实现,也不会静默的迁移现有的应用程序以使用虚拟线程。
  • 目标不是改变Java的基本并发模型。
  • 目标不是在Java语言或者Java库中提供新的数据并行结构。Stream API 仍然是并行处理大型数据集的首选方式。

动机

近三十年来,Java开发人员一直依赖线程作为并发服务端应用程序的构建块。每个方法中的每个语句都在一个线程内执行,由于Java是多线程的,因此同时执行多个线程。线程是Java的并发单元:一段顺序代码与其他这样的单元并行运行——并且在很大程度上独立于这些单元。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出错时的上下文:异常是由同一线程中的方法抛出和捕获的,因此开发人员可以使用线程的堆栈跟踪来查明发生了什么。线程也是工具的核心概念:调试器逐步处理线程方法中的语句,而分析工具则可视化多个线程的行为,以帮助了解它们的性能。

thread-per-request风格

服务端应用程序通常处理相互独立的并发用户请求,因此应用程序在请求的整个持续时间内将线程专用于该请求的处理是有意义的。这种thread-per-request风格易于理解、易于编程、易于调试和配置,因为它使用平台的并发单元来表示应用程序的并发单元。

服务端应用程序的可伸缩性受利特尔法则影响,它与延迟、并发性和吞吐量相关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数量(即并发性)必须与到达速度(即吞吐量)成比例地增长。例如,假设一个平均延迟为50ms的应用程序通过并发处理10个请求来实现每秒200个请求的吞吐量。为了使该应用程序能够扩展到每秒2000个请求的吞吐量,它需要并发处理100个请求。如果在请求的持续时间内每个请求都在一个线程中处理,那么为了让应用程序跟上,线程数量必须随着吞吐量的增长而增长。

不幸的是,可用线程的数量是有限的,因为JDK将线程实现为操作系统线程的包装器。操作系统线程成本高昂,因此我们不能有太多线程,这使得该实现不适合thread-per-request风格。如果每个请求在其持续时间内消耗一个线程,从而消耗一个操作系统线程,那么早在其他资源(如CPU或网络连接)耗尽之前,线程数量往往就会成为限制因素。JDK当前的线程实现将应用程序的吞吐量限制在远低于硬件所能支持的水平。即使在线程被池化的情况下也会发生这种情况,因为池化有助于避免启动新线程的高昂成本,但不会增加线程总数。

通过异步提高可伸缩性的风格

一些希望充分利用硬件的开发人员已经放弃了thread-per-request风格,转而采用thread-sharing(线程共享)的风格。处理请求不是自始至终在一个线程上,请求处理代码在等待I/O操作完成时将其线程返回到池中,以便线程可以为其他请求提供服务。这种细粒度的线程共享——其中代码只在线程执行计算时持有线程,而不是在等待I/O时持有线程——允许在不消耗大量线程的情况下进行大量并发操作。虽然它消除了操作系统线程稀缺对吞吐量的限制,但代价高昂:它需要所谓的异步编程风格,使用一组独立的I/O方法,这些方法不等待I/O操作完成,而是在稍后向回调发出完成信号。在没有专用线程的情况下,开发人员必须将他们的请求处理逻辑分解为小阶段,通常编写为lambda表达式,然后使用API将它们组成一个顺序管道(例如,参见CompletableFuture,或所谓的“reactive”框架)。因此,它们放弃了语言的基本顺序组合运算符,如循环和try/catch块。

在异步风格中,请求的每个阶段可能在不同的线程上执行,每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为有着深刻的影响:堆栈跟踪不提供可用的上下文,调试器无法遍历请求处理逻辑,分析工具无法将操作的成本与其调用方相关联。当使用Java的Stream API在短管道中处理数据时,编写lambda表达式是可以管理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与Java平台不一致,因为应用程序的并发单元——异步管道——不再是平台的并发单元。

使用虚拟线程保留thread-per-request风格

为了使应用程序能够在与平台保持协调的同时进行扩展,我们应该努力保持thread-per-request风格。我们可以通过更有效地实现线程来做到这一点,这样线程就可以更加丰富。操作系统无法更有效的实现操作系统线程,因为不同的语言和运行时环境以不同的方式使用线程堆栈。然而,Java运行时可以通过切断Java线程与操作系统线程的一一对应关系来实现Java线程。正如操作系统通过将大的虚拟地址空间映射到有限数量的物理RAM来产生充足内存的错觉一样,Java运行时也可以通过将大量虚拟线程映射到少量操作系统线程来产生充足线程的错觉。

虚拟线程是java.lang.Thread的一个实例,它不绑定到特定的操作系统线程。相比之下,平台线程是一个以传统方式实现的java.lang.Thread的实例,作为操作系统线程的精简包装器。

thread-per-request风格的应用程序代码可以在请求的整个持续时间内在虚拟线程中运行,但虚拟线程仅在CPU上执行计算时使用操作系统线程。其结果是与异步样式具有相同的可伸缩性,只是它是透明实现的:当虚拟线程中运行的代码调用java.* API中的阻塞I/O操作时,运行时执行非阻塞操作系统调用,并自动挂起虚拟线程,直到稍后可以恢复。对于Java开发人员来说,虚拟线程只是创建成本低廉且几乎无限丰富的线程。硬件利用率接近最佳,允许高水平的并发性,从而实现高吞吐量,同时应用程序与Java平台及其工具的多线程设计保持一致。

虚拟线程的含义

虚拟线程既便宜又丰富,因此永远不应该被池化:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程将是短暂的,并且具有浅调用堆栈,只执行一个HPPT客户端调用或者一个JDBC查询。相比之下,平台线程是重量级且昂贵的,因此经常必须合并。它们往往是长寿命的,具有深度调用堆栈,并且可以在许多任务之间共享。

总之,虚拟线程保留了可靠的thread-per-request风格,这种风格与Java平台的设计相协调,同时以最佳方式利用硬件。使用虚拟线程,不需要学习新概念,尽管它可能需要忘记为应对当今高昂的线程成本而养成的习惯。虚拟线程不仅有助于应用程序开发人员,还将帮助框架设计者提供易于使用的API,这些API与平台的设计兼容,而不会影响可扩展性。

描述

今天,JDK中java.lang.Thread的每个实例都是一个平台线程。平台线程在底层操作系统线程上运行Java代码,并在代码的整个生命周期中捕获操作系统线程。平台线程的数量限制为操作系统线程的数量。

虚拟线程是java.lang.Thread的一个实例,它在底层操作系统线程上运行Java代码,但不会在代码的整个生命周期内捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行Java代码,从而有效的共享线程。虽然平台独占了宝贵的操作系统线程,但虚拟线程却没有。虚拟线程数量可以远远大于操作系统线程数量。

虚拟线程是JDK提供的线程的轻量级实现,而不是OS。它们是用户模式线程的一种形式,已在其他多线程语言中取得了成功(例如Go中的goroutine和Erlang中的process)。用户模式线程甚至在早期版本的Java中被称为“green threads(绿色线程)”,当时操作系统线程还不成熟和被广泛使用。然而,Java的绿色线程都共享一个操作系统线程(M:1调度),最终被实现为OS线程包装器(1:1调度)的平台线程所超越。虚拟线程采用M:N调度,其中大量(M)虚拟线程被调度在少量(N)OS线程上运行。

使用虚拟线程 VS 平台线程

开发人员可以选择使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。该程序首先获得一个ExecutorService,它将为每个提交的任务创建一个新的虚拟线程。然后,它提交10000个任务,并等待所有任务完成:

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

这个例子中的任务是简单的代码——睡眠一秒钟——现代硬件可以轻松的支持10000个虚拟线程同时运行这样的代码。在幕后,JDK在少数操作系统线程上运行代码,也许只有一个。

如果这个程序使用为每个任务创建一个新平台线程的ExecutorService,情况会大不相同,例如Executors.newCachedThreadPool()。ExecutorService将尝试创建10000个平台线程,从而创建10000个操作系统线程,程序可能会崩溃,具体取决于机器和操作系统。

如果程序转而使用从池中获取平台线程的ExecutorService,比如Executors.newFixedThreadPool(200),情况也不会好到哪里去。ExecutorService将创建200个平台线程,供所有10000个任务共享,因此许多任务将按顺序运行,而不是并行运行,程序将需要很长时间才能完成。对于该程序,具有200个平台线程的池只能实现每秒200个任务的吞吐量,而虚拟线程(在足够预热之后)可以实现每秒约10000个任务的吞吐量。此外,如果示例程序中的10_000更改为1_000_000,则该程序将提交1000000个任务,创建1000000个并发运行的虚拟线程,并(在充分预热后)实现每秒约1000000个任务的吞吐量。

如果这个程序中的任务执行一秒钟的计算(例如,对一个巨大的数组进行排序),而不仅仅是睡眠,那么将线程数量增加到处理器内核数量之外将没有帮助,无论它们是虚拟线程还是平台线程。虚拟线程不是更快的线程——它们运行代码的速度不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。它们可能比平台线程多得多,因此根据利特尔法则,它们能够实现更高吞吐量所需的更高并发性。

换句话说,当出现以下情况时,虚拟线程可以显著提高应用程序吞吐量:

  • 并发任务的数量很高(超过几千个)
  • 工作负载不受CPU限制,因为在这种情况下,拥有比处理器核数多得多的线程并不能提高吞吐量

虚拟线程有助于提高典型服务端应用程序的吞吐量,正是因为此类应用程序由大量并发组成,这些任务花费了大量时间等待。

虚拟线程可以运行平台线程能够运行的任何代码。特别是,虚拟线程支持线程本地变量和线程中断,就像平台线程一样。这意味着处理请求的现有Java代码将很容易在虚拟线程汇总运行。这意味着处理请求的Java代码将很容易在虚拟机线程中执行。许多服务端框架会选择自动执行此操作,为每个传入的请求启动一个新的虚拟线程,并在其中运行应用程序的业务逻辑。

下面是一个服务端应用程序的示例,它聚合了另外两个服务的结果。假设的服务端框架(未显示)为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序处理代码。反过来,应用程序代码创建两个新的虚拟线程,以通过与第一个示例相同的ExecitorService同时获取资源:

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来创建和管理虚拟线程,特别是在类似于此服务端示例的代码中,从而使平台及其工具知道线程之间的关系。

不池化虚拟线程

开发人员通常会将应用程序代码从传统的基于线程池的ExecutorService迁移到每个任务一个虚拟线程的ExecutorService。与任何资源池一样,线程池旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远不需要对它们进行池化。

开发人员有时会使用线程池来限制对有限资源的并发访问。例如,如果一个服务不能处理超过20个并发请求,那么通过提交到大小为20的线程池的任务向该服务发出所有请求将确保这一点。这种习惯用法之所以无处不在,是因为平台线程的高成本使线程池无处不在,但不要为了限制并发性而将虚拟线程池化。相反,使用专门为此目的设计的构造,例如信号量。

结合线程池,开发人员有时会使用线程本地变量在共享同一线程的多个任务之间共享昂贵的资源。例如,如果数据库连接的创建成本很高,那么您可以打开它一次,并将其存储在线程本地变量中,供同一线程中的其他任务稍后使用。如果您将代码从使用线程池迁移到每个任务使用一个虚拟线程,请注意使用此习惯用法,因为为每个虚拟线程创建昂贵的资源可能会显著降低性能。更改这样的代码为使用替代的缓存策略,以便在大量虚拟线程之间高效共享昂贵的资源。

观察虚拟线程

编写清晰的代码并不是全部。清楚的显示运行中的程序状态对于故障排除、维护和优化也是至关重要的,JDK长期以来一直提供调试、分析和监控线程的机制。这样的工具应该对虚拟线程执行相同的操作——还可以适应它们的大量——因为它们毕竟是java.lang.Thread的实例。

Java调试器可以遍历虚拟线程,显示调用堆栈,并检查堆栈帧中的变量。JDK Flight Recorder(JFR)是JDK低开销分析和监视机制,它可以将应用程序代码中的事件(如对象分配和I/O操作)与正确的虚拟线程相关联。对于以异步样式编写的应用程序,这些工具无法完成这些任务。在这种风格下,任务与线程无关,因此调试器无法显示或操作任务的状态,分析工具也无法判断任务等待I/O的时间。

线程dump是另一个用于对thread-per-request风格编写的应用程序进行故障诊断的流行的工具。不幸的是,JDK传统的线程dump(通过jstack或jcmd获取)提供了一个线程的平面列表。这适用于数十或数百个平台线程,但不适用于数千或数百万个虚拟线程。因此,我们不会将传统的线程dump扩展到包含虚拟线程;我们将在jcmd中引入一种新的线程dump,将虚拟线程和平台线程一起呈现,所有这些都以有意义的方式分组。当程序使用结构化并发时,可以显示线程之间更丰富的关系。

因为可视化和分析大量线程可以从工具中收益,所以jcmd除了纯文本之外,还可以以JSON的格式发出新的线程dump:

$ jcmd <pid> Thread.dump_to_file -format=json <file>

新的线程dump格式不包括对象地址、锁、JNI统计信息、堆统计信息和其他出现在传统线程dump中的信息。此外,由于它可能需要列出大量线程,因此生成新的线程dump不会暂停应用程序。

如果系统属性jdk.trackAllThreads设置为false,即使用-Djdk.trackAllThreads=false命令行选项,则使用Thread.Builder API直接创建的虚拟线程将不会始终被运行时跟踪,并且可能不会出现在新的线程dump中。在这种情况下,新的线程dump将列出在网络I/O操作中被阻塞的虚拟线程,以及如上所示由new-thread-per-task ExecutorService创建的虚拟线程。

下面是这样一个线程dump的例子,取自类似于上面第二个例子的应用程序,在JSON查看器中呈现(点击放大):

image

由于虚拟线程是在JDK中实现的,并且不与任何特定的操作系统线程绑定,因此操作系统不知道它们的存在。操作系统级别的监视将观察到JDK进程使用的操作系统线程少于虚拟线程。

调度虚拟线程

为了完成有用的工作,需要对线程进行调度,也就是说,分配线程在处理器核上执行。对于作为操作系统线程实现的平台线程,JDK依赖于操作系统中的调度器。相比之下,对于虚拟线程,JDK有自己的调度程序。JDK调度器不是将虚拟线程直接分配给处理器,而是将虚拟线程分配给平台线程(这就是前面提到的虚拟线程的M:N调度)。然后,操作系统像往常一样对平台线程进行调度。

JDK虚拟线程调度程序是一个基于工作窃取机制的ForkJoinPool,以FIFO模式运行。调度器的并行度是可用于调度虚拟线程的平台线程数。默认情况下,它等于可用处理器的数量,但可以使用系统属性jdk.virtualThreadScheduler.parallelism进行调优。此ForkJoinPool与公共池不同,后者用于实现并行流,并在LIFO模式下运行。

调度器为其分配虚拟线程的平台线程称为虚拟线程的载体。虚拟线程可以在其生命周期过程中被调度在不同的载体上;换句话说,调度器不维护虚拟线程和任何特定平台线程之间的亲和性。从Java代码的角度看,一个正在运行的虚拟线程在逻辑上独立于其当前的载体:

  • 载体的本体对于虚拟线程不可用。Thread.currentThread()返回的值始终是虚拟线程本身。
  • 载体和虚拟线程的堆栈跟踪是分开的。在虚拟线程中引发的异常将不包括载体的堆栈帧。线程dump不会在虚拟线程的堆栈中显示载体的堆栈帧,反之亦然。
  • 载体的线程本地变量对虚拟线程不可用,反之亦然。

此外,从Java代码的角度看,虚拟线程机器载体临时共享操作系统线程这一事实是不可见的。相比之下,从本地代码的角度看,虚拟线程及其载体都在同一个本地线程上运行。因此,在同一虚拟线程上被多次调用的本地代码在每次调用时可以观察到不同的OS线程标识符。

调度程序当前未实现虚拟线程的时间共享。时间共享是对已经消耗了分配数量的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(),则其载体和底层操作系统线程在操作期间会被阻塞。长时间的频繁固定可能会捕获载体,从而损害应用程序的可伸缩性。

调度程序并不会通过扩展其并行性来补偿固定。相反,通过修改频繁运行的同步块或方法来避免频繁和长期固定,并且改为使用java.util.concurrent.locks.ReentrantLock来保护潜在长I/O操作。不需要替换不经常使用(例如,仅在启动时执行)或保护内存操作的同步块和方法。一如既往,努力保持锁定策略简单明了。

新的诊断有助于将代码迁移到虚拟线程,并评估是否应将synchronized的特定使用替换为java.util.concurrent锁:

  • 当线程在固定期间发生阻塞,会发出一个JDK Flight Recorder(JFR)事件(参见 JDK Flight Recorder
  • 当线程在固定期间发生阻塞,系统属性jdk.tracePinnedThreads会触发堆栈跟踪。使用-Djdk.tracePinnedThreads=full运行时,当线程在固定期间发生阻塞,将打印完整的堆栈跟踪,并突出显示本地帧和持有监视器的帧。使用-Djdk.tracePinnedThreads=short运行会将输出仅限于有问题的帧

在未来的版本中,我们可能能够消除上面的第一个限制,即同步内部固定。第二个限制是与本地代码正确交互所必需的。

内存使用和与垃圾回收交互

虚拟线程的栈作为栈块对象存储在Java的垃圾收集堆中。栈随着应用程序的运行而增长和收缩,即可以节省内存,也可以容纳深度高达JVM配置的平台线程栈大小的栈。这种效率使得能够在服务端应用程序中使用大量虚拟线程,从而使thread-per-request风格持续可行。

在上面的第二个例子中,回想一下,假设框架通过创建一个新的虚拟线程并调用handle方法来处理每个请求。即使它在深度调用堆栈的末尾调用handle(身份验证、事务等之后),handle本身也会产生多个只执行短暂任务的虚拟线程。因此,对于每个具有深度调用堆栈的虚拟线程,将存在多个具有浅调用堆栈的、消耗很少内存的虚拟线程。

一般来说,虚拟线程所需的堆空间数量和垃圾收集器活动很难与异步代码进行比较。一百万个虚拟线程至少需要一百万个对象,但共享平台线程池的一百万个任务也是如此。此外,处理请求的应用程序代码通常会维护跨I/O操作的数据。thread-per-request代码可以将数据保存在本地变量中,这些变量存储在堆中的虚拟线程堆栈上,而异步代码必须将相同的数据保存在堆对象中,从管道的一个阶段传递到下一个阶段。一方面,虚拟线程所需的堆栈帧布局比紧凑对象更浪费;另一方面,虚拟线程在许多情况下(取决于低层次的GC交互)可能会发生变化并重用其堆栈,而异步管道总是需要分配新对象,因此虚拟线程可能需要更少的分配。总而言之,与异步代码相比,thread-per-request的堆消耗和垃圾收集器活动应该大致相似。随着时间的推移,我们希望虚拟线程堆栈的内部表示更加紧凑。

与平台线程堆栈不同,虚拟线程堆栈不是GC根。因此执行并发堆扫描的垃圾收集器(如G1)不会在“stop-the-world”暂停中遍历它们所包含的引用。这也意味着,如果一个虚拟线程被阻塞,例如BlockingQueue.take(),并且没有其他线程可以获得对该虚拟线程或队列的引用,那么该线程可以被垃圾回收——这很好,因为虚拟线程永远不会被中断或取消阻塞。当然,如果虚拟线程正在运行,或者它被阻塞并且可以会被取消阻塞,那么它将不会被垃圾回收。

当前虚拟线程的一个限制是G1垃圾收集器不支持巨大的栈块对象。如果虚拟线程的到达Region大小的一半(可能小到512K),则可能会引发StackOverflowError。

详细变更

剩余小节详细描述了我们在Java平台及其实现中提出的变更:

  • java.lang.Thread
  • Thread-local variables
  • java.util.concurrent
  • Networking
  • java.io
  • Java Native Interface (JNI)
  • Debugging (JVM TI, JDWP, and JDI)
  • JDK Flight Recorder (JFR)
  • Java Management Extensions (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()——在JDK20中变更为也对平台线程抛出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。

在某些场景下,作用域变量JEP429)可能被证明是线程本地变量的更好的替代方案。

java.util.concurrent

支持锁的基本API java.util.concurrent.LockSupport现在支持虚拟线程:park虚拟线程会释放底层平台线程来做其他工作,而unpark虚拟线程会调度它继续执行。对LockSupport的这一变更使得使用它的所有API(锁、信号量、阻塞队列等)在虚拟线程中调用时都能正常park。

此外,Executors.newThreadPerTaskExecutor(ThreadFactory) 和Executors.newVirtualThreadPerTaskExecutor()创建一个ExecutorService,为每个任务创建一个新线程。这些方法实现了与使用线程池和ExecutorService的现有代码的迁移和互操作性。

网络

java.net和java.nio.channels包中的网络API实现现在与虚拟线程一起工作:对虚拟线程的一个操作,它阻塞(如建立网络连接或从套接字读取),释放底层平台线程来做其他工作。

为了允许中断和取消,java.net.Socket、ServerSocket和DatagramSocket定义的阻塞I/O方法现在被指定为在虚拟线程中调用时可中断:中断套接字阻塞的虚拟线程将会unpark线程并关闭套接字。当从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的流编码器使用的缓冲区初始化size现在更小,以便在堆中有许多流或写入程序时减少内存使用量——如果有一百万个虚拟线程,每个线程在套接字连接上都有一个缓冲流,则可能会出现这种情况。

Java Native Interface(JNI)

JNI定义了一个新函数IsVirtualThread,用于测试对象是否为虚拟线程。

JNI规范在其他方面保持不变。

Debugging

Debugging架构由三个接口组成:JVM工具接口(JVM TI)、Java调试连线协议(JDWP)和Java调试接口(JDI)。所有三个接口现在都支持虚拟线程。

Java TI更新包括:

  • 使用jthread(即对Thread对象的JNI引用)调用的大多数函数都可以使用对虚拟线程的引用来调用。虚拟线程上不支持或可选支持少量函数,即AgentStartFunction、PopFrame、ForceEarlyReturn*、StopThread和GetThreadPuTime。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表示虚拟线程在固定时park。即未释放其平台线程(见上文)。此事件默认启用,阈值为20ms。
  • jdk.VirtualThreadSubmitFailed表示启动或unpark虚拟线程失败,可能是由于资源问题。默认情况下会启用此事件。

Java Management Extensions (JMX)

java.lang.management.ThreadMXBean仅支持对平台线程的监视和管理。findDeadlockedThreads()方法查找处于死锁状态的平台线程的循环;它找不到处于死锁状态的虚拟线程的循环。

com.sun.management.HotSpotDiagnosticsMXBean中的一个新方法生成上述新型线程dump。这个方法也可以通过平台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;他们大多使用更高级的API进行交互,例如ExecutorService。随着时间推移,我们将通过废弃和删除过时方法,抛弃Thread类和相关类(如ThreadGroup)中不需要的包袱。

测试

  • 现有测试将确保我们在这里提出的更改不会在运行它们的众多配置和执行模式中导致意外的倒退。
  • 我们将扩展jtreg测试工具。以允许在虚拟线程的上下文中运行现有测试。这将避免需要两个版本的许多测试。
  • 新的测试将执行所有新的和修改过的API,并更改所有区域以支持虚拟线程。
  • 新的压力测试将针对可靠性和性能至关重要的领域。
  • 新的微型基准将针对性能关键领域。
  • 我们将使用许多现有的服务器,包括Helidon和Jetty,进行更大规模的测试

风险与假设

该方案的主要风险是由于现有API及其实现的变化而导致的兼容性风险:

  • java.io.BufferedInputStream、BufferedOutputStream、Buffered Reader、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()是一个新的final方法。如果存在扩展Thread的现有编译代码,并且子类声明了具有相同名称和返回类型的方法,那么如果加载了子类,则在运行时将抛出不兼容ClassChangeError

当将现有代码与利用虚拟线程或新API的较新代码混合时,可以观察到平台线程和虚拟线程之间的一些行为差异:

  • Thread.setPriority(int)方法对虚拟线程没有影响,它的优先级始终为Thread.NORM_PRIORITY。
  • Thread.setDaemon(boolean)方法对虚拟线程没有影响,它始终是守护线程。
  • Thread.getAllStackTraces()现在返回所有平台线程的映射,而不是所有线程的映射。
  • java.net.Socket、ServerSocket和DatagramSocket定义的阻塞I/O方法现在在虚拟线程上下文中调用时是可中断的。当套接字操作阻塞的线程中断时,现有代码可能会中断,这将唤醒线程并关闭套接字。
  • 虚拟线程不是ThreadGroup的活动成员。在虚拟线程上调用Thread.getThreadGroup()方法会返回一个空的伪“VirtualThreads”组。
  • 使用安全管理器设置运行时,虚拟线程没有权限。有关在Java 17及更高版本上使用安全管理器运行的信息,请参阅JEP 411(废弃移除安全管理器)
  • 在JVM TI中,GetAllThreads和GetAllStackTraces函数不返回虚拟线程。启用ThreadStart和ThreadEnd事件的现有代理可能会遇到性能问题,因为它们缺乏将事件限制到平台线程的能力。
  • java.lang.management.ThreadMXBean API支持监视和管理平台线程,但不支持虚拟线程。
  • -XX:+PreserveFramePointer标志对虚拟线程性能有严重的负面影响。

依赖

  • JDK 18中的JEP416(带有方法句柄的重新实现核心反射)删除了VM本地反射实现。这允许虚拟线程在反射式调用方法时优雅的park。
  • JDK 13中的JEP353(重新实现遗留的Socket API)和JDK 15中的JEP773(重新实现遗漏的DatagramSocket API)用新实现替代了java.net.Socket、ServerSocket和DatagramSocket原有实现,用于虚拟线程。
  • JDK 18中的JEP418(互联网地址解析SPI)定义了用于主机名和地址查找的service-provider接口。这将允许第三方库实现可供替代的java.net.InetAddress解析器,在主机查找期间不固定线程。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值