千呼万唤始出来-JDK21确定正式发布虚拟线程

4,476 阅读41分钟

JDK21确定要正式发布虚拟线程,其目的是为了提高并发能力,本文翻译JEP 444,希望可以帮助大家理解虚拟线程。 1680877112220.jpg

摘要

向Java平台介绍虚拟线程。虚拟线程是轻量级的线程,可以大大减少编写、维护和观察高吞吐量并发应用程序的工作量。

历史

虚拟线程是由JEP 425提出的一项预览功能,并在JDK 19中交付。为了给反馈留出时间并获得更多经验,JEP 436再次将其作为预览功能提出,并在JDK 20中交付。这个JEP建议在JDK 21中最终确定虚拟线程,与JDK 20相比只有一个变化。

根据开发者的反馈,这个变化是:虚拟线程现在支持thread-local variables。再也不可能像预览版那样,创建不能有线程本地变量的虚拟线程了。保证对线程本地变量的支持,确保了更多的现有库可以在不改变的情况下使用虚拟线程,并帮助面向任务的代码迁移到使用虚拟线程。

目标

  • 以简单的thread-per-request方式编写的服务器应用程序能够以接近最佳的硬件利用率进行扩展。
  • 使用java.lang.Thread API的现有代码能够以最小的改动采用虚拟线程。
  • 使用现有的 JDK 工具,可以轻松地对虚拟线程进行故障排除、调试和分析。

非目标

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

动机

近三十年来,Java开发者一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每条语句都在一个线程中执行,由于Java是多线程的,所以多个执行线程同时发生。线程是Java的并发单元:一段顺序代码,与其他此类单元同时运行,而且基本上是独立的。每个线程都提供一个堆栈来存储局部变量和协调方法调用,以及出错时的上下文:异常是由同一线程中的方法抛出和捕获的,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的一个核心概念:Debuggers逐步调用线程方法中的语句,profilers将多个线程的行为可视化,以帮助了解其性能。

thread-per-request风格

服务器应用程序通常处理相互独立的并发用户请求,因此,应用程序通过在整个请求期间为该请求分配一个线程来处理请求是合理的。这种thread-per-request style很容易理解,容易编程,也容易调试和剖析,因为它使用平台的并发单位来代表应用程序的并发单位。

服务器应用程序的可扩展性受Little's Law的制约,该定律将延迟、并发性和吞吐量联系起来:对于一个给定的请求处理时间(即延迟),应用程序同时处理的请求数量(即并发性)必须与到达率(即吞吐量)成比例增长。例如,假设一个平均延迟为50ms的应用程序,通过并发处理10个请求,达到每秒200个请求的吞吐量。为了使该应用程序扩展到每秒2000个请求的吞吐量,它将需要同时处理100个请求。如果每个请求都在一个线程中处理,那么,为了使应用程序跟上,线程的数量必须随着吞吐量的增长而增长。

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

用异步风格提高可扩展性

一些希望充分利用硬件的开发者放弃了thread-per-request style,而选择了thread-sharing style。请求处理代码不是从头到尾在一个线程上处理一个请求,而是在等待另一个I/O操作完成时,将其线程返回到一个池中,以便该线程可以为其他请求提供服务。这种细粒度的线程共享--代码只在执行计算时保留线程,而不是在等待I/O时保留线程--允许大量的并发操作,而不会消耗大量的线程。虽然它消除了操作系统线程稀缺性对吞吐量的限制,但它的代价很高:它需要所谓的异步编程风格,采用一套单独的I/O方法,不等待I/O操作的完成,而是在稍后向回调发出完成信号。如果没有专门的线程,开发者必须将他们的请求处理逻辑分解成小的阶段,通常写成lambda表达式,然后用API将它们组成一个顺序管道(例如见CompletableFuture,或所谓的 "反应式"框架)。因此,他们放弃了语言的基本顺序组合操作符,如循环和try/catch块。

在异步风格中,一个请求的每个阶段可能在不同的线程上执行,每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为有深刻的影响:堆栈跟踪没有提供可用的上下文,debuggers不能逐步调用请求处理逻辑,profilers不能将一个操作的成本与它的调用者联系起来。当使用Java的stream API来处理短管道中的数据时,组成lambda表达式是可以管理的,但当应用程序中所有的请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与Java平台不一致,因为应用程序的并发单位--异步流水线--不再是平台的并发单位了。

用虚拟线程保留thread-per-request style

为了使应用程序能够扩展,同时保持与平台的协调,我们应该努力保留thread-per-request style。我们可以通过更有效地实现线程来做到这一点,这样它们就会更加丰富。操作系统无法更有效地实现操作系统线程,因为不同的语言和runtimes以不同的方式使用线程栈。然而,对于Java runtime来说,有可能以一种方式实现Java线程,从而切断它们与操作系统线程的一对一的对应关系。就像操作系统通过将大量的虚拟地址空间映射到有限的物理RAM上,给人以内存充足的错觉一样,Java runtime也可以通过将大量的虚拟线程映射到少量的操作系统线程上,给人以线程充足的错觉。

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

thread-per-request style的应用程序代码可以在请求的整个过程中在虚拟线程中运行,但虚拟线程只在CPU上执行计算时消耗一个操作系统线程。其结果是与异步风格相同的可扩展性,只是它是以透明方式实现的:当在虚拟线程中运行的代码调用java.* API中的阻塞I/O操作时,runtime会执行一个非阻塞的操作系统调用,并自动暂停虚拟线程,直到以后可以恢复。对Java开发者来说,虚拟线程只是创建成本低且几乎无限多的线程。硬件利用率接近最优,允许高水平的并发,因此,吞吐量很高,而应用程序仍然与Java平台及其工具的多线程设计相协调。

虚拟线程的影响

虚拟线程很便宜,而且数量很多,因此不应该被池化:应该为每个应用任务创建一个新的虚拟线程。因此,大多数虚拟线程都是短命的,而且调用栈很浅,只执行一次HTTP客户端调用或一次JDBC查询。相比之下,平台线程则是重量级的、昂贵的,因此往往必须是池化的。它们往往寿命很长,有很深的调用栈,并在许多任务之间共享。

总之,虚拟线程保留了与Java平台设计相协调的可靠的thread-per-request style,同时优化利用了可用硬件。使用虚拟线程不需要学习新的概念,尽管它可能需要取消为应对今天的高线程成本而养成的习惯。虚拟线程不仅可以帮助应用开发者--还可以帮助框架设计者提供易于使用的API,这些API与平台的设计兼容,同时又不影响可扩展性。

描述

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

虚拟线程是java.lang.Thread的一个实例,它在底层操作系统线程上运行Java代码,但在代码的整个生命周期中不捕获操作系统线程。这意味着许多虚拟线程可以在同一个操作系统线程上运行他们的Java代码,有效地共享它。虽然一个平台线程垄断了宝贵的操作系统线程,但虚拟线程并没有。虚拟线程的数量可以比操作系统线程的数量大得多。

虚拟线程是一种轻量级的线程实现,由JDK而不是操作系统提供。它们是用户模式线程的一种形式,在其他多线程语言中也很成功(例如Go中的goroutines和Erlang中的processes)。用户模式线程甚至在Java的早期版本中作为所谓的"green threads"出现,当时操作系统线程还没有成熟和普及。然而,Java的绿色线程都共享一个操作系统线程(M:1调度),并最终被作为操作系统线程的封装器实现的平台线程(1:1调度)所超越。虚拟线程采用M:N调度,即大量(M)虚拟线程被调度到较少数量(N)的操作系统线程上运行。

使用 虚拟线程 vs 平台线程

开发人员可以选择是使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。该程序首先获得一个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()被隐式调用,并进行等待

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

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

如果程序使用ExecutorService,从一个线程池中获取平台线程,例如Executors.newFixedThreadPool(200),情况就不会好多少。ExecutorService将创建200个平台线程,由所有10,000个任务共享,因此许多任务将按顺序运行,而不是并发运行,程序将需要很长时间才能完成。对于这个程序来说,有200个平台线程的池子只能达到每秒200个任务的吞吐量,而虚拟线程的吞吐量约为每秒10000个任务(经过充分预热)。此外,如果将示例程序中的10_000改为1_000_000,那么该程序将提交1,000,000个任务,创建1,000,000个并发运行的虚拟线程,并(在充分预热后)实现约1,000,000个任务/秒的吞吐量。

如果这个程序中的任务进行一秒钟的计算(例如,对一个巨大的数组进行排序),而不仅仅是sleeping,那么增加线程的数量超过处理器内核的数量将没有帮助,无论它们是虚拟线程还是平台线程。虚拟线程不是更快的线程--它们运行代码的速度并不比平台线程快。它们的存在是为了提供规模(更高的吞吐量),而不是速度(更低的延迟)。它们可以比平台线程多得多,所以根据Little's Law,它们可以实现更高的并发性,从而获得更高的吞吐量。

换句话说,在以下情况下,虚拟线程可以显著提高应用程序的吞吐量

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

虚拟线程有助于提高典型的服务器应用程序的吞吐量,正是因为这种应用程序由大量的并发任务组成,这些任务的大部分时间都在等待。

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

下面是一个服务器应用程序的例子,它聚合了其他两个服务的结果。一个假设的服务器框架(未展示)为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序的处理代码。而应用程序代码则通过与第一个例子相同的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,可以创建和启动虚拟线程。此外,structured concurrency提供了一个更强大的API来创建和管理虚拟线程,特别是在类似于这个服务器例子的代码中,线程之间的关系被告知平台及其工具。

不要池化虚拟线程

开发人员通常会将应用程序代码从传统的基于线程池的ExecutorService迁移到virtual-thread-per-task的ExecutorService。像任何资源池一样,线程池的目的是共享昂贵的资源,但虚拟线程并不昂贵,所以从来没有必要将它们池化。

开发人员有时会使用线程池来限制对有限资源的并发访问。例如,如果一个服务不能处理超过20个并发请求,那么通过提交给大小为20的线程池的任务使所有对该服务的请求都能确保这一点。由于平台线程的高成本使得线程池无处不在,所以这个习惯已经变得无处不在,但不要为了限制并发性而池化虚拟线程。相反,要使用专门为此目的而设计的结构,如semaphores。

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

观察虚拟线程

编写清晰的代码并不是故事的全部。对运行中程序状态的清晰呈现对于故障排除、维护和优化也是至关重要的,JDK早就提供了调试、剖析和监控线程的机制。这些工具也应该为虚拟线程做同样的事情--也许要对它们的数量做一些调整--因为它们毕竟是java.lang.Thread的实例。

Java debuggers可以逐步调用虚拟线程,显示调用堆栈,并检查堆栈框架中的变量。JDK Flight Recorder(JFR)是JDK的低开销分析和监控机制,它可以将应用程序代码的事件(如对象分配和I/O操作)与正确的虚拟线程联系起来。对于以异步风格编写的应用程序,这些工具不能做这些事情。在这种风格中,任务与线程没有关系,所以调试器不能显示或操作任务的状态,profilers也不能知道一个任务在等待I/O时花了多少时间。 thread dump是另一个流行的工具,用于排除thread-per-request style编写的应用程序的故障。不幸的是,JDK的传统thread dump,即用jstackjcmd获得的thread dump,呈现出一个平面的线程列表。这适合于几十或几百个平台线程,但不适合于几千或几百万个虚拟线程。因此,我们不会将传统的thread dump扩展到包含虚拟线程;相反,我们将在jcmd中引入一种新的thread dump,将虚拟线程与平台线程放在一起,并以一种有意义的方式进行分组。当程序使用structured concurrency时,可以显示线程之间更丰富的关系。

由于可视化和分析大量的线程可以从工具中受益,因此jcmd可以在纯文本之外,以JSON格式发射新的线程转储:

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

新的thread dump格式列出了在网络I/O操作中被阻塞的虚拟线程,以及由上面显示的new-thread-per-task ExecutorService创建的虚拟线程。它不包括对象地址、锁、JNI统计、堆统计,以及其他出现在传统thread dump中的信息。此外,由于它可能需要列出大量的线程,生成一个新的thread dump并不会暂停应用程序。

下面是这样一个线程转储的例子,它取自一个与上面第二个例子类似的应用程序,在JSON浏览器中呈现:

threaddump-700.png

由于虚拟线程是在JDK中实现的,并不与任何特定的操作系统线程相联系,因此它们对操作系统来说是不可见的,而操作系统也不知道它们的存在。操作系统级别的监控将观察到,一个JDK进程使用的操作系统线程比虚拟线程少。

调度虚拟线程

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

JDK的虚拟线程调度器是一个偷工减料的ForkJoinPool,以FIFO模式运行。调度器的并行性是指可用于调度虚拟线程的平台线程的数量。默认情况下,它等于available processors的数量,但它可以通过系统属性jdk.virtualThreadScheduler.parallelism进行调整。这个ForkJoinPool与common pool不同,后者被用于实现并行流,并且以后进先出的模式运行。

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

  • 载体的身份对虚拟线程来说是不可用的。Thread.currentThread()返回的值始终是虚拟线程本身。
  • 载体和虚拟线程的堆栈痕迹是分开的。在虚拟线程中抛出的异常将不包括载体的堆栈帧。Thread dumps将不会在虚拟线程的堆栈中显示载体的堆栈帧,反之亦然。
  • 载体的线程本地变量对虚拟线程是不可用的,反之亦然。

此外,从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 block or method)中执行代码时,或
  • 当它执行一个本地方法或一个外来函数(foreign function)时。

钉住并不会使应用程序变得不正确,但它可能会阻碍其可扩展性。如果一个虚拟线程在被钉住时执行了一个阻塞操作,如I/O或BlockingQueue.take(),那么它的载体和底层操作系统线程在操作的过程中就会被阻塞。频繁的长时间钉住会因为捕获载体而损害应用程序的可扩展性。

调度器不会通过扩展其并行性来补偿钉住的情况。相反,通过修改频繁运行的同步块或方法,使用java.util.concurrent.locks.ReentrantLock进行代替,来避免频繁和长时间的钉住,并守护潜在的长I/O操作。没有必要替换那些不经常使用(例如,只在启动时执行)或守护内存操作的同步块和方法。一如既往,努力保持锁策略的简单和清晰。

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

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

在未来的版本中,我们可能会移除上面的第一个限制,即在同步中钉住。第二个限制是为了与本地代码正确互动。

内存使用和与垃圾收集的互动

虚拟线程的堆栈作为堆栈块对象存储在Java的垃圾收集堆中。堆栈随着应用程序的运行而增长和缩小,这既是为了提高内存效率,也是为了适应堆栈的深度,直至JVM配置的平台线程堆栈大小。这种效率使大量的虚拟线程成为可能,从而使服务器应用程序中的thread-per-request style继续可行。

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

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

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

目前虚拟线程的一个限制是,G1 GC不支持巨大的堆栈块对象(humongous stack chunk objects)。如果一个虚拟线程的堆栈达到区域大小的一半,可能小到512KB,那么可能会抛出一个StackOverflowError。

详细的变化

剩下的几个小节详细描述了我们提出的整个Java平台及其实现的变化:

java.lang.Thread

我们对java.lang.Thread API 进行了如下更新:

Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);

创建一个名为 "duke"的新的未启动的虚拟线程。

java.lang.Thread API在其他方面没有被这个JEP改变。和以前一样,Thread类定义的构造函数可以创建平台线程。没有新的公共构造函数。

(Thread中为虚拟线程抛出UnsupportedOperationException的三个方法--stop()suspend()resume()--在JDK 20也被修改为平台线程抛出UnsupportedOperationException)。

虚拟线程和平台线程之间的主要API差异是:

  • 公共线程构造函数不能创建虚拟线程。

  • 虚拟线程总是守护线程。Thread.setDaemon(boolean)方法不能将一个虚拟线程改为非守护线程。

  • 虚拟线程有一个固定的优先级,即Thread.NORM_PRIORITYThread.setPriority(int)方法对虚拟线程没有影响。这一限制可能会在未来的版本中被重新审视。

  • 虚拟线程不是线程组的活动成员。当在一个虚拟线程上调用时,Thread.getThreadGroup()返回一个名称为 "VirtualThreads"的占位符线程组。Thread.Builder API并没有定义一个方法来设置虚拟线程的线程组。

  • 在设置了SecurityManager的情况下运行时,虚拟线程没有权限。

Thread-local variables

虚拟线程支持线程局部变量(ThreadLocal)和可继承的线程局部变量(InheritableThreadLocal),就像平台线程一样,所以它们可以运行使用线程局部变量的现有代码。然而,由于虚拟线程可能非常多,所以只有在仔细考虑后才能使用线程局部变量。特别是,不要使用线程局部变量来在线程池中共享同一线程的多个任务之间汇集昂贵的资源。虚拟线程不应该池化,因为每个线程在其生命周期内都只能运行一个任务。我们已经从JDK的java.base模块中删除了许多线程局部变量的使用,为虚拟线程做准备,以便在运行数百万线程时减少内存占用。

系统属性jdk.traceVirtualThreadLocals可以用来在虚拟线程设置任何线程局部变量的值时触发堆栈跟踪。在迁移代码以使用虚拟线程时,这种诊断性输出可能有助于移除线程局部。将系统属性设置为 "true "以触发堆栈跟踪;默认值为 "false"。

对于某些用例来说,Scoped values(JEP 429)可能被证明是线程局部的一个更好的替代品。

java.util.concurrent

原始API支持锁,java.util.concurrent.LockSupport,现在支持虚拟线程:Parking一个虚拟线程会释放底层的平台线程去做其他工作,而unparking一个虚拟线程则会安排它继续工作。对LockSupport的这一改变使得所有使用它的API(锁、Semaphores、阻塞队列等)在虚拟线程中被调用时能够优雅地park。

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

Networking

java.netjava.nio.channels包中的网络API的实现现在可以与虚拟线程一起工作:在虚拟线程上进行的操作,如建立网络连接或从套接字中读取,会释放底层平台线程以进行其他工作。

为了允许中断和取消,由java.net.Socket, ServerSocket, 和 DatagramSocket定义的阻塞式I/O方法现在被指定为在虚拟线程中调用时可中断(interruptible):中断一个在套接字上阻塞的虚拟线程将取消该线程并关闭该套接字。当从 InterruptibleChannel 获取时,这些类型的套接字上的阻塞 I/O 操作一直是可中断的,因此这一变化使这些 API 在用构造函数创建时的行为与从通道获取时的行为一致。

java.io

java.io包提供了字节和字符流的API。这些API的实现具有很强的同步性,当它们在虚拟线程中使用时,需要进行修改以避免钉住。

作为背景,面向字节的输入/输出流没有被指定为线程安全的,也没有指定当线程在读或写方法中被阻塞时调用close()的预期行为。在大多数情况下,从多个并发线程中使用一个特定的输入或输出流是没有意义的。面向字符的reader/writers也没有被指定为线程安全的,但它们确实为子类暴露了一个锁对象。除了钉住之外,这些类的同步是有问题的,也是不一致的;例如,InputStreamReaderOutputStreamWriter使用的流解码器和编码器是在流对象而不是锁对象上同步的。

为了防止钉住,现在的实现工作如下:

  • BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriterPrintStreamPrintWriter现在在直接使用时使用显式锁而不是监视器。当这些类被子类化时,它们会像以前一样进行同步。

  • InputStreamReaderOutputStreamWriter使用的流解码器和编码器现在使用与封闭的InputStreamReaderOutputStreamWriter相同的锁。

更进一步,消除所有这些经常不必要的锁,已经超出了本JEP的范围。

此外,BufferedOutputStreamBufferedWriterOutputStreamWriter的流编码器所使用的缓冲区的初始大小现在更小了,以便在堆中有许多流或writers时减少内存的使用--如果有一百万个虚拟线程,每个线程在一个套接字连接上有一个缓冲流,就可能出现这种情况。

Java Native Interface (JNI)

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

JNI规范在其他方面没有变化。

Debugging

调试架构由三个接口组成:JVM Tool Interface(JVM TI)、Java Debug Wire Protocol(JDWP)和Java Debug Interface(JDI)。这三个接口现在都支持虚拟线程。

JVM TI的更新是:

  • 大多数用jthread(即对Thread对象的JNI引用)调用的函数可以用对虚拟线程的引用来调用。少数函数,即AgentStartFunctionPopFrameForceEarlyReturn*StopThreadGetThreadCpuTime,在虚拟线程上不被支持或被选择性支持。SetLocal*函数仅限于设置在断点或单步事件中被暂停的虚拟线程的最顶层帧中的局部变量。

  • GetAllThreadsGetAllStackTraces函数现在被指定为返回所有平台线程而不是所有线程。

  • 除了那些在早期虚拟机启动或堆迭期间发布的事件外,所有事件都可以在虚拟线程的上下文中调用事件回调。

  • 暂停/恢复(suspend/resume)实现允许虚拟线程被调试器暂停和恢复,并且允许平台线程在虚拟线程被挂载时暂停。

  • 一个新的能力,can_support_virtual_threads,让代理对虚拟线程的线程开始和结束事件有更精细的控制。

  • 新的函数支持虚拟线程的批量暂停和恢复;这些需要can_support_virtual_threads能力。

现有的JVM TI代理大多会像以前一样工作,但如果他们调用不支持虚拟线程的函数,可能会遇到错误。当一个不知道虚拟线程的代理与一个使用虚拟线程的应用程序一起使用时,会出现这些错误。对GetAllThreads的改变是返回一个只包含平台线程的数组,这对一些代理来说可能是个问题。启用ThreadStartThreadEnd事件的现有代理可能会遇到性能问题,因为它们缺乏将这些事件限制在平台线程的能力。

JDWP的更新是:

  • 一个新的命令允许debuggers测试一个线程是否是一个虚拟线程。

  • EventRequest命令的一个新modifier允许debuggers将线程的开始和结束事件限制在平台线程上。

JDI的更新是:

如上所述,虚拟线程不被认为是线程组中的活动线程。因此,由JVM TI函数GetThreadGroupChildren、JDWP命令ThreadGroupReference/Children和JDI方法com.sun.jdi.ThreadGroupReference.threads()返回的线程列表只包括平台线程。

JDK Flight Recorder (JFR)

JFR通过几个新的事件支持虚拟线程:

  • jdk.VirtualThreadStartjdk.VirtualThreadEnd表示虚拟线程开始和结束。这些事件在默认情况下是禁用的。

  • jdk.VirtualThreadPinned表示一个虚拟线程在被钉住时被parked,即没有释放其平台线程(见上文)。该事件默认为启用,阈值为20ms。

  • jdk.VirtualThreadSubmitFailed表示启动或unparking虚拟线程失败,可能是因为资源问题。这个事件默认是启用的

Java Management Extensions (JMX)

java.lang.management.ThreadMXBean只支持对平台线程的监控和管理。findDeadlockedThreads()方法可以找到处于死锁状态的平台线程的周期;它不会找到处于死锁状态的虚拟线程的周期。

com.sun.management.HotSpotDiagnosticsMXBean中的一个新方法可以生成上述的新式thread 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;他们大多使用ExecutorService等更高级别的API进行交互。随着时间的推移,我们将通过废弃和删除过时的方法来抛弃Thread类以及ThreadGroup等相关类中不需要的包袱。

Testing

现有的测试将确保我们在这里提出的改变不会在众多的配置和执行模式中造成意外的回归。

  • 我们将扩展jtreg测试线束,允许现有的测试在虚拟线程的背景下运行。这将避免许多测试需要两个版本。

  • 新的测试将测试所有新的和修订的API,以及所有支持虚拟线程的领域。

  • 新的压力测试将针对那些对可靠性和性能至关重要的领域。

  • 新的微观测试将针对对性能至关重要的领域。

  • 我们将使用一些现有的服务器,包括HelidonJetty,进行大规模的测试。

风险和假设

这个建议的主要风险是由于现有的API及其实现的变化而导致的兼容性问题:

  • java.io.BufferedInputStreamBufferedOutputStreamBufferedReaderBufferedWriterPrintStreamPrintWriter类中使用的内部(和无记录的)锁定协议的修改,可能会影响那些假定I/O方法在它们被调用的流上进行同步的代码。这些变化并不影响扩展这些类并假定由超类锁定的代码,也不影响扩展java.io.Readerjava.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.getAllStackTraces()现在返回所有平台线程的映射,而不是所有线程的映射。

  • java.net.SocketServerSocketDatagramSocket定义的阻塞式I/O方法现在在虚拟线程的上下文中被调用时可以被中断。当阻塞在套接字操作上的线程被中断时,现有的代码可能会中断,这将唤醒该线程并关闭套接字。

  • 虚拟线程不是ThreadGroup的活动成员。在一个虚拟线程上调用Thread.getThreadGroup()会返回一个空的 "VirtualThreads "组。

  • 在设置了安全管理器的情况下运行时,虚拟线程没有权限。有关在 Java 17 及更高版本上使用安全管理器运行的信息,请参见JEP 411 (Deprecate the Security Manager for Removal)

  • 在JVM TI中,GetAllThreadsGetAllStackTraces函数不返回虚拟线程。启用ThreadStartThreadEnd事件的现有代理可能会遇到性能问题,因为它们缺乏将事件限制在平台线程的能力。

  • java.lang.management.ThreadMXBeanAPI支持对平台线程的监控和管理,但不支持虚拟线程。

  • -XX:+PreserveFramePointer标志对虚拟线程的性能有极大的负面影响。

Dependencies