jdk21-虚拟线程

发布时间 2023-11-29 22:54:01作者: 使用D

jdk文档:https://openjdk.org/jeps/444

关键内容整理

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

  默认情况下,直接使用 API 创建的虚拟线程(而不是通过 创建的虚拟线程)现在也会在其整个生命周期内受到监视,并且可以通过观察虚拟线程部分中描述的新线程转储进行观察。Thread.BuilderExecutors.newVirtualThreadPerTaskExecutor().

  对于给定的请求处理持续时间(即延迟),应用程序同时处理的请求数(即并发)必须与到达速率(即吞吐量)成比例增长。例如,假设平均延迟为 50 毫秒的应用程序通过并发处理 200 个请求,实现了每秒 10 个请求的吞吐量。为了使该应用程序扩展到每秒 2000 个请求的吞吐量,它需要同时处理 100 个请求。如果每个请求在请求的持续时间内在线程中处理,则要使应用程序跟上,线程数必须随着吞吐量的增长而增长。

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

  Java 运行时可以以一种映射Java 线程与操作系统线程的一对一对应关系的方式实现 Java 线程。

  正如操作系统通过将大型虚拟地址空间映射到有限数量的物理 RAM 来产生大量内存的错觉一样,Java 运行时通过将大量虚拟线程映射到少量的操作系统线程,可以给人一种大量线程的错觉。

  “每个请求线程”样式的应用程序代码可以在请求的整个持续时间内在虚拟线程中运行,但虚拟线程仅在 CPU 上执行计算时才使用操作系统线程。

  • 当在虚拟线程中运行的代码调用 API 中的阻塞 I/O 操作时,运行时将执行非阻塞 OS 调用,并自动挂起虚拟线程,直到以后可以恢复。
  • 虚拟线程只是创建成本低廉且几乎无限丰富的线程。硬件利用率接近最佳,允许高级别的并发性,从而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计保持和谐。
  • 大多数虚拟线程的生存期很短,并且具有浅层调用堆栈,执行的操作仅为单个 HTTP 客户端调用或单个 JDBC 查询。相比之下,平台线程重量大且价格昂贵,因此通常必须合并。它们往往生存期长,具有深厚的调用堆栈,并在许多任务之间共享。

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

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

  虚拟线程是由 JDK 而不是 OS 提供的线程的轻量级实现。

  开发人员可以选择是使用虚拟线程还是平台线程。下面是一个创建大量虚拟线程的示例程序。程序首先获取一个 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

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

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

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

  虚拟线程支持线程局部变量和线程中断,就像平台线程一样。

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

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

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

在两种情况下,虚拟线程在阻塞操作期间无法卸载,因为它被固定到其载体:

  1. 当它在块或方法中执行代码时,或者synchronized
  2. 当它执行方法或外函数时。native

  虚拟线程堆栈作为堆栈块对象存储在 Java 的垃圾回收堆中。堆栈会随着应用程序的运行而增长和收缩,既可以节省内存,也可以容纳深度堆栈,最高可达 JVM 配置的平台线程堆栈大小。这种效率使大量虚拟线程成为可能,从而使服务器应用程序中每个请求线程样式的持续可行性成为可能。

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

  与平台线程堆栈不同,虚拟线程堆栈不是 GC 根。因此,它们包含的引用不会被执行并发堆扫描的垃圾回收器(如 G1)在停止世界暂停时遍历