JEP 444:针对 Java 21 的虚拟线程


将虚拟线程引入Java 平台。虚拟线程是轻量级线程,可显着减少编写、维护和观察高吞吐量并发应用程序的工作量。

目标

  • 使以简单的每个请求一个线程的方式编写的服务器应用程序能够随着接近最佳的硬件利用率进行扩展。
  • 使使用java.lang.ThreadAPI 的现有代码能够以最小的更改采用虚拟线程。
  • 使用现有的 JDK 工具轻松地对虚拟线程进行故障排除、调试和分析。

近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于 Java 是多线程的,因此多个执行线程同时发生。

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

服务器应用程序的可伸缩性受Little 定律支配,它与延迟、并发性和吞吐量相关:对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发性)必须与到达率(即吞吐量)成比例增长。

例如,假设一个平均延迟为 50 毫秒的应用程序通过并发处理 10 个请求来实现每秒 200 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它需要并发处理 100 个请求。如果每个请求在请求的持续时间内都在一个线程中处理,那么为了让应用程序跟上,线程的数量必须随着吞吐量的增长而增长。

不幸的是,可用线程的数量是有限的,因为 JDK 将线程实现为操作系统 (OS) 线程的包装器。OS 线程成本很高,所以我们不能拥有太多线程,这使得实现不适合每个请求一个线程的风格。

使用异步风格提高可扩展性
请求处理代码不是在一个线程上从头到尾处理请求,而是在等待另一个 I/O 操作完成时将其线程返回到池中,以便该线程可以为其他请求提供服务。

虽然它消除了操作系统线程稀缺对吞吐量的限制,但它的代价很高:它需要所谓的异步编程风格,采用一组独立的 I/O 方法,这些方法不等待 I/O 操作完成,而是稍后将其完成信号通知回调。

如果没有专用线程,开发人员必须将他们的请求处理逻辑分解成小阶段,通常编写为 lambda 表达式,然后将它们组合成带有 API 的顺序管道(例如,参见 CompletableFuture 或所谓的“反应式框架). 因此,他们放弃了该语言的基本顺序组合运算符,例如循环和try/catch块。

在异步风格中,请求的每个阶段可能在不同的线程上执行,并且每个线程以交错的方式运行属于不同请求的阶段。这对理解程序行为具有深远的意义:堆栈跟踪不提供可用的上下文,调试器无法单步执行请求处理逻辑,分析器无法将线程运作的成本与其调用者相关联,也就无法追踪。

当使用 Java 的流 API在短管道中处理数据时,组合 lambda 表达式是易于管理的,但当应用程序中的所有请求处理代码都必须以这种方式编写时,就会出现问题。这种编程风格与 Java 平台不一致,因为应用程序的并发单元——异步管道——不再是平台的并发单元。

使用虚拟线程保留每个请求的线程样式
为了使应用程序能够扩展,同时与平台保持和谐,我们应该努力保持每个请求线程的风格。
因为不同的语言和运行时以不同的方式使用线程堆栈,操作系统无法更有效地实现操作系统线程(基于nio_uring的Hella快速Java HTTP服务器库
Java 运行时可以通过将大量虚拟线程映射到少量操作系统线程来提供丰富线程的错觉.。

thread-per-request 样式的应用程序代码可以在请求的整个持续时间内在虚拟线程中运行,但虚拟线程仅在它在 CPU 上执行计算时使用操作系统线程。结果是与异步风格相同的可扩展性。
虚拟线程只是创建成本低且几乎无限丰富的线程。硬件利用率接近最佳,允许高水平并发,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持和谐。

虚拟线程的含义
虚拟线程既便宜又充足,因此永远不应该被池化:应该为每个应用程序任务创建一个新的虚拟线程。因此,大多数虚拟线程将是短暂的并且具有浅层调用堆栈,只执行单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程是重量级且昂贵的,因此通常必须合并。它们往往是长期存在的,具有很深的调用堆栈,并在许多任务之间共享。

开发人员可以选择是使用虚拟线程还是平台线程。这是一个创建大量虚拟线程的示例程序。该程序首先获得一个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 在少量操作系统线程上运行代码,可能只有一个线程。


虚拟线程可以在以下情况下显着提高应用程序吞吐量

  • 任务并发数高(几千以上),并且
  • 工作负载不受 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);
    }
}

像这样的服务器应用程序,具有简单的阻塞代码,可以很好地扩展,因为它可以使用大量虚拟线程。

不要池化虚拟线程
线程池旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远不需要将它们池化。

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