关于后端:JDK21最终版协程实现之虚拟线程

4次阅读

共计 19218 个字符,预计需要花费 49 分钟才能阅读完成。

1 全新并发编程模式

JDK9 后的版本你感觉没必要折腾,我也认可,然而 JDK21 有必要关注。因为 JDK21 引入全新的并发编程模式。

始终欺世盗名的 GoLang 吹得最厉害的就是协程了。JDK21 中就在这方面做了很大的改良,让 Java 并发编程变得更简略一点,更丝滑一点。

之前写过 JDK21 Feature。Virtual ThreadsScoped ValuesStructured Concurrency 就是针对多线程并发编程的几个性能。。

2 倒退历史

虚构线程是轻量级线程,极大地缩小了编写、保护和察看高吞吐量并发利用的工作量。

虚构线程是由 JEP 425 提出的预览性能,并在 JDK 19 中公布,JDK 21 中最终确定虚构线程,以下是依据开发者反馈从 JDK 20 中的变动:

  • 当初,虚构线程始终反对线程本地变量。与在预览版本中容许的不同,当初不再可能创立不能具备线程本地变量的虚构线程。对线程本地变量的有保障反对确保了许多现有库能够不经批改地与虚构线程一起应用,并有助于将以工作为导向的代码迁徙到应用虚构线程
  • 间接应用 Thread.Builder API 创立的虚构线程(而不是通过 Executors.newVirtualThreadPerTaskExecutor() 创立的虚构线程)当初默认状况下也会在其生命周期内进行监控,并且能够通过形容在 ” 察看虚构线程 ” 局部中的新线程转储来察看。

基于协程的线程,与其余语言中的协程有相似之处,也有不同。虚构线程是依附于主线程的,如果主线程销毁了,虚构线程也不复存在。

3 指标

  • 使采纳简略的 thread-per-request 模式编写的服务器应用程序,能以靠近最佳的硬件利用率扩大
  • 使利用 java.lang.Thread API 的现有代码能在最小更改下采纳虚构线程
  • 通过现有的 JDK 工具轻松进行虚构线程的故障排除、调试和剖析

4 非指标

  • 不是删除传统的线程实现,也不是轻轻将现有应用程序迁徙到应用虚构线程
  • 不是扭转 Java 的根本并发模型
  • 不是在 Java 语言或 Java 库中提供新的数据并行结构。Stream API 仍是解决大型数据集的首选形式。

5 动机

Java 开发人员在近 30 年来始终依赖线程作为并发服务端应用程序的构建块。每个办法中的每个语句都在一个线程内执行,并且因为 Java 是多线程,多个线程同时执行。

线程是 Java 的并发单元:它是一段程序代码,与其余这样的单元并发运行,很大水平上是独立的。每个线程提供一个堆栈来存储局部变量和协调办法调用及在呈现问题时的上下文:异样由同一线程中的办法抛出和捕捉,因而开发可应用线程的堆栈跟踪来查找产生了啥。

线程也是工具的外围概念:调试器逐渐执行线程办法中的语句,剖析工具可视化多个线程的行为,以帮忙了解它们的性能。

6 thread-per-request 模式

服务器应用程序通常解决彼此独立的并发用户申请,因而将一个线程专用于解决整个申请在逻辑上是正当的。这种模式易了解、易编程,且易调试和剖析,因为它应用平台的并发单元来示意应用程序的并发单元。

服务器应用程序的可扩展性受到 Little 定律束缚,该定律关联提早、并发性和吞吐量:对给定的申请解决持续时间(即提早),应用程序同时解决的申请数量(并发性)必须与达到速率(吞吐量)成比例增长。如一个具备均匀提早为 50ms 的应用程序,通过同时解决 10 个申请实现每秒解决 200 个申请的吞吐量。为使该应用程序扩大到每秒解决 2000 个申请吞吐量,它要同时解决 100 个申请。如每个申请在其持续时间内都应用一个线程(因而应用一个 os 线程),那在其余资源(如 CPU 或网络连接)耗尽前,线程数量通常成为限度因素。JDK 对线程的以后实现将应用程序的吞吐量限度在远低于硬件反对程度的程度。即便线程进行池化,依然产生,因为池化可防止启动新线程的高老本,但并不会减少总线程数。

7 应用异步模式进步可扩展性

一些开发人员为了充分利用硬件资源,曾经放弃了采纳 ”thread-per-request” 的编程格调,转而采纳 ” 共享线程 ”。这种形式,申请解决的代码在期待 I / O 操作实现时会将其线程返回给一个线程池,以便该线程能够为其余申请提供服务。这种对线程的精密共享,即只有在执行计算时才放弃线程,而在期待 I / O 时开释线程,容许高并发操作而不耗费大量线程资源。尽管它打消了因为 os 线程无限而导致的吞吐量限度,但代价高:它须要一种异步编程格调,应用一组专门的 I / O 办法,这些办法不会期待 I / O 操作实现,而是稍后通过回调告诉其实现。

在没有专用线程状况下,开发须将申请解决逻辑合成为小阶段,通常编写为 lambda 表达式,而后应用 API(如 CompletableFuture 或响应式框架)将它们组合成程序管道。因而,他们放弃语言的根本程序组合运算符,如循环和 try/catch 块。

异步格调中,申请的每个阶段可能在不同线程执行,每个线程交织形式运行属于不同申请的阶段。这对于了解程序行为产生了粗浅的影响:堆栈跟踪提供不了可用的上下文,调试器无奈逐渐执行申请解决逻辑,分析器无奈将操作的老本与其调用者关联起来。应用 Java 的流 API 在短管道中解决数据时,组合 lambda 表达式是可治理的,但当应用程序中的所有申请解决代码都必须以这种形式编写时,会带来问题。这种编程格调与 Java 平台不符,因为应用程序的并发单位——异步管道——不再是平台的并发单位。

8 通过虚构线程放弃 thread-per-request 编程格调

为了在放弃与平台谐和的状况下使应用程序能扩大,应致力通过更高效形式实现线程,以便它们可更丰盛存在。os 无奈更高效实现操作系统线程,因为不同编程语言和运行时以不同形式应用线程堆栈。然而,JRE 可通过将大量虚构线程映射到大量操作系统线程来实现线程的假装丰富性,就像 os 通过将大型虚拟地址空间映射到无限的物理内存一样,JRE 可通过将大量虚构线程映射到大量操作系统线程来实现线程的假装丰富性。

虚构线程是 java.lang.Thread 一个实例,不与特定 os 线程绑定。相同,平台线程是 java.lang.Thread 的一个实例,以传统形式实现,作为包装在操作系统线程四周的薄包装。

采纳 thread-per-request 编程格调的应用程序,可在整个申请的持续时间外在虚构线程中运行,但虚构线程仅在它在 CPU 上执行计算时才会耗费 os 线程。后果与异步格调雷同,只是它是通明实现:当在虚构线程中运行的代码调用 java.* API 中的阻塞 I / O 操作时,运行时会执行非阻塞的 os 调用,并主动暂停虚构线程,直到可稍后复原。对 Java 开发,虚构线程只是便宜且简直有限丰盛的线程。硬件利用率靠近最佳,容许高并发,因而实现高吞吐量,同时应用程序与 Java 平台及其工具的多线程设计放弃和谐一致。

9 虚构线程的含意

虚构线程成本低且丰盛,因而永远都不应被池化:每个应用程序工作应该创立一个新的虚构线程。因而,大多数虚构线程将是短暂的,且具备浅层次的调用栈,执行的操作可能只有一个 HTTP 客户端调用或一个 JDBC 查问。相比之下,平台线程是重量级且代价低廉,因而通常必须池化。它们偏向于具备较长的生命周期,具备深层次调用栈,并在许多工作间共享。

总之,虚构线程保留了与 Java 平台设计和谐一致的牢靠的 thread-per-request 编程格调,同时最大限度地利用硬件资源。应用虚构线程无需学习新概念,只管可能须要放弃为应答以后线程老本昂扬而养成的习惯。虚构线程不仅将帮忙应用程序开发人员,还将帮忙框架设计人员提供与平台设计兼容且不会就义可伸缩性的易于应用的 API。

10 形容

现在,JDK 中的每个 java.lang.Thread 实例都是平台线程。平台线程在底层 os 线程上运行 Java 代码,并在代码的整个生命周期内捕捉 os 线程。平台线程的数量受限于 os 线程的数量。

虚构线程是 java.lang.Thread 的一个实例,它在底层 os 线程上运行 Java 代码,但并不在代码的整个生命周期内捕捉操作系统线程。这意味着许多虚构线程可在同一个 os 线程上运行其 Java 代码,无效地共享它。而平台线程会独占一个贵重的 os 线程,虚构线程则不会。虚构线程的数量可 >> os 线程的数量。

虚构线程是 JDK 提供的轻量级线程实现,不是由 os 提供。它们是用户态线程的一种模式,在其余多线程语言(如 Go 的 goroutine 和 Erlang 的过程)中取得成功。

晚期版本 Java,当 os 线程尚未成熟和宽泛应用时,Java 的绿色线程都共享一个 os 线程(M:1 调度),最终被作为 os 线程的包装器(1:1 调度)超过。虚构线程采纳 M:N 调度,其中大量(M)虚构线程被调度在较少(N)的 os 线程上运行。

11 应用虚构线程与平台线程

开发人员可抉择应用虚构线程或平台线程。

11.1 创立大量虚构线程 demo

先获取一个 ExecutorService,用于为每个提交的工作创立一个新的虚构线程。而后,它提交 10,000 个工作并期待它们全副实现:

try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {IntStream.range(0, 10_000).forEach(i -> {executor.submit(() -> {
              // 工作即休眠 1s
            Thread.sleep(Duration.ofSeconds(1));
            return i;
        });
    });
}  // executor.close() is called implicitly, and waits

古代硬件可轻松反对同时运行 10,000 个虚构线程来执行这样代码。幕后,JDK 在较少的 os 线程上运行代码,可能只有一个:

  • 若此程序应用一个为每个工作创立一个新的平台线程的 ExecutorService,如 Executors.newCachedThreadPool(),状况将齐全不同。ExecutorService 将尝试创立 10,000 个平台线程,因而会创立 10,000 个操作系统线程,依据计算机和 os 的不同,程序可能会解体
  • 若程序改用从池中获取平台线程的 ExecutorService,如 Executors.newFixedThreadPool(200),状况也不好多少。ExecutorService 将创立 200 个平台线程供所有 10,000 个工作共享,因而许多工作将程序而非并发运行,程序将要很久能力实现

该程序,具备 200 个平台线程的池只能实现每秒 200 个工作的吞吐量,而虚构线程在足够热身后,可实现每秒约 10,000 个工作的吞吐量。

若将 demo 中的 10_000 更改为 1_000_000,则程序将提交 1,000,000 个工作,创立 1,000,000 个同时运行的虚构线程,并在足够热身后实现每秒约 1,000,000 个工作的吞吐量。

若此程序工作执行一个须要 1s 计算(如对大型数组排序),而不仅是休眠,那减少线程数量超过 CPU 核数量将无奈进步吞吐量,无论是虚构线程、平台线程。虚构线程不是更快的线程 —— 它们不会比平台线程运行代码更快。它们存在目标是提供规模(更高吞吐量),而非速度(更低的提早)。虚构线程的数量能够远远多于平台线程的数量,因而它们能够实现更高的并发,从而实现更高的吞吐量,依据 Little 定律。

换句话说,虚构线程可在以下状况显著进步利用吞吐量:

  1. 并发工作的数量很高(超过几千)
  2. 工作负载不是 CPU 限度的,因为此时,比 CPU 核数量更多的线程无奈进步吞吐量

虚构线程有助进步典型服务器应用程序的吞吐量,因为这种应用程序由大量并发工作组成,这些工作在大部分工夫内都在期待。

虚构线程可运行任何平台线程可运行的代码。特地是,虚构线程反对线程本地变量和线程中断,就像平台线程一样。这意味着已存在的用于解决申请的 Java 代码可轻松在虚构线程中运行。许多服务端框架可能会主动抉择这样做,为每个传入的申请启动一个新的虚构线程,并在其中运行应用程序的业务逻辑。

11.2 聚合服务 demo

聚合了另外两个服务的后果。一个假如的服务器框架(未显示)为每个申请创立一个新的虚构线程,并在该虚构线程中运行应用程序的解决代码。

又创立两个新虚构线程并发通过与第一个示例雷同的 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 可创立和启动虚构线程。

结构化并发提供更弱小 API,用于创立和治理虚构线程,特地是在相似这服务器示例的代码,其中线程之间的关系对于平台和其工具是已知的。

12 解除默认禁用限度

虚构线程是一项预览 API,默认禁用。下面程序应用 Executors.newVirtualThreadPerTaskExecutor() 办法,所以要在 JDK 19 上运行它们,须启用预览 API:

  • 应用 javac --release 19 --enable-preview Main.java 编译程序,而后应用 java --enable-preview Main 运行它;
  • 当应用源代码启动器时,应用 java --source 19 --enable-preview Main.java 运行程序
  • 当应用 jshell 时,启动它时加上 jshell --enable-preview

13 不要池化虚构线程

开发通常会将利用程序代码从基于线程池的传统 ExecutorService 迁徙到基于虚构线程的 virtual-thread-per-task 的 ExecutorService。线程池就像所有资源池一样,旨在共享低廉资源,但虚构线程并不低廉,永远不要对它们池化。

开发人员有时应用线程池限度对无限资源的并发拜访。如一个服务不能解决超过 20 个并发申请,通过提交到大小为 20 的线程池的工作来执行对该服务的所有拜访将确保这点。因为平台线程高老本已使线程池无处不在,这种习惯也无处不在,但开发不应引诱本人在虚构线程中进行池化以限度并发。应该应用专门设计用于此目标的结构,如信号量来爱护对无限资源的拜访。这比线程池更无效不便,也平安,因为不存在线程本地的数据意外透露给另一个工作的危险。

13 察看虚构线程

编写清晰的代码还不够,运行中程序状态的清晰出现对故障排除、保护和优化也重要,而 JDK 始终提供调试、剖析和监督线程的机制。这些工具应答虚构线程执行雷同操作,只管可能须要适应它们的大量存在,因为它们毕竟是 java.lang.Thread 的实例。

13.1 Java 调试器

可逐渐执行虚构线程,显示调用栈,并查看栈帧变量。JDK Flight Recorder(JFR)是 JDK 的低开销剖析和监督机制,可将来自利用程序代码(如对象调配和 I/O 操作)的事件与正确的虚构线程关联起来。这些工具无奈为采纳异步编程格调编写的应用程序执行这些操作。在该格调中,工作与线程无关,因而调试器无奈显示或操作工作的状态,分析器无奈判断工作期待 I/O 所破费的工夫。

13.2 线程 dump

故障排除线程 - 每申请编程格调应用程序的常用工具。但 JDK 的传统线程转储(应用 jstack 或 jcmd 获取)出现为线程的扁平列表。实用于几十或数百平台线程,但不适用于数千或数百万虚构线程。因而,官网不会扩大传统线程转储以包含虚构线程,而是会引入一种新的线程转储类型,在 jcmd 中以有意义的形式将虚构线程与平台线程一起显示。当程序应用结构化并发时,可显示线程之间更丰盛的关系。

因为可视化和剖析大量线程可受害于工具反对,jcmd 还能够 JSON 格局输入新的线程转储,而不仅是纯文本:

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

新的线程转储格局列出了在网络 I/O 操作中被阻塞的虚构线程以及由下面示例中的 new-thread-per-task ExecutorService 创立的虚构线程。它不包含对象地址、锁、JNI 统计信息、堆统计信息和传统线程转储中显示的其余信息。此外,因为可能须要列出大量线程,生成新的线程转储不会暂停应用程序。

相似第二个 demo 程序的线程转储示例,以 JSON 出现:

{
  "virtual_threads": [
    {
      "id": 1,
      "name": "VirtualThread-1",
      "state": "RUNNABLE",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Thread",
          "method": "lambda$main$0",
          "file": "Main.java",
          "line": 10
        }
      ]
    },
    {
      "id": 2,
      "name": "VirtualThread-2",
      "state": "BLOCKED",
      "stack_trace": [
        {
          "class": "java.base/java.net.SocketInputStream",
          "method": "socketRead0",
          "file": "SocketInputStream.java",
          "line": 61
        }
      ]
    }
  ],
  "platform_threads": [
    {
      "id": 11,
      "name": "Thread-11",
      "state": "RUNNABLE",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Thread",
          "method": "run",
          "file": "Thread.java",
          "line": 834
        }
      ]
    },
    {
      "id": 12,
      "name": "Thread-12",
      "state": "WAITING",
      "stack_trace": [
        {
          "class": "java.base/java.lang.Object",
          "method": "wait",
          "file": "Object.java",
          "line": 328
        }
      ]
    }
  ]
}

因为虚构线程是在 JDK 实现的,不与任何特定 OS 线程绑定,因而它们对 os 不可见的,os 也不晓得它们、存在。操作系统级别的监控将察看到 JDK 过程应用的 OS 线程少于虚构线程的数量。

14 虚构线程调度

要执行有用的工作,线程须要被调度,即调配给一个处理器外围来执行。对于作为 OS 线程实现的平台线程,JDK 依赖 os 中的调度程序。对虚构线程,JDK 有本人调度程序。JDK 调度程序不是间接将虚构线程调配给处理器,而是将虚构线程调配给平台线程(虚构线程 M:N 调度)。而后,os 会像平常一样对这些平台线程调度。

JDK 虚构线程调度程序是以 FIFO 运行的 work-stealing ForkJoinPool。调度程序的并行度是用于调度虚构线程的可用平台线程数量。默认为可用处理器数量,但可应用零碎属性 jdk.virtualThreadScheduler.parallelism 调整。这 ForkJoinPool 与通常用于并行流实现等的公共池不同,后者 LIFO 运行。

调度程序调配虚构线程给平台线程就是虚构线程的载体。虚构线程可在其生命周期内被调配给不同载体,即调度程序不会在虚构线程和任何特定的平台线程之间放弃关联。从 Java 代码角度看,正在运行的虚构线程逻辑上与其以后载体无关:

  • 虚构线程无奈获取载体标识。Thread.currentThread() 返回值始终是虚构线程自身
  • 载体和虚构线程的栈轨迹是离开的。在虚构线程中抛出的异样不会蕴含载体栈帧。线程转储不会在虚构线程的栈中显示载体的栈帧,反之亦然
  • 载体的线程本地变量对虚构线程不可见,反之亦然

Java 代码角度,虚构线程及其载体临时共享一个 OS 线程的事实是不可见的。本地代码角度,与虚构线程屡次调用雷同本地代码可能会在每次调用时察看到不同的 OS 线程标识。

工夫共享

目前,调度程序不实现虚构线程的工夫共享。工夫共享是对已耗费的 CPU 工夫进行强制抢占的机制。尽管工夫共享在某些工作的提早升高方面可能无效,但在平台线程绝对较少且 CPU 利用率达 100% 时,不分明工夫共享是否同样无效,尤其领有百万虚构线程时。

15 执行虚构线程

要利用虚构线程,无需重写程序。虚构线程不须要或不冀望利用程序代码明确将控制权交还给调度程序,即虚构线程不是合作式的。用户代码不应假如虚构线程是如何或何时调配给平台线程的,就像它不应假如平台线程是如何或何时调配给处理器核。

要在虚构线程中运行代码,JDK 虚构线程调度程序通过将虚构线程挂载到平台线程,为其调配平台线程来执行。这使平台线程成为虚构线程的载体。稍后,在运行一些代码后,虚构线程能够从其载体卸载。在这点上,平台线程是闲暇的,因而调度程序能够再次将不同的虚构线程挂载到下面,从而使其成为载体。

通常,当虚构线程在 JDK 中的某些阻塞操作(如 BlockingQueue.take())阻塞时,它会卸载。当阻塞操作筹备实现(如在套接字上接管到字节)时,它会将虚构线程提交回调度程序,后者将挂载虚构线程到载体上以复原执行。

虚构线程的挂载和卸载频繁而通明地产生,不会阻塞任何 OS 线程。如后面示例中的服务器应用程序蕴含以下一行代码,蕴含对阻塞操作的调用:

response.send(future1.get() + future2.get());

这些操作将导致虚构线程屡次挂载和卸载,通常对每次调用 get() 进行一次,可能在执行 send(...) 中的 I/O 操作期间屡次进行。

JDK 大多数阻塞操作都会卸载虚构线程,开释其载体和底层 OS 线程以承当新工作。然而,JDK 一些阻塞操作不会卸载虚构线程,因而会阻塞其载体和底层 OS 线程。这是因为在 OS 级别(如许多文件系统操作)或 JDK 级别(如 Object.wait())存在一些限度。这些阻塞操作实现将通过长期扩大调度程序的并行性来补救 OS 线程的占用,因而调度程序的 ForkJoinPool 中的平台线程数量可能会在短时间内超过可用处理器的数量。可通过零碎属性 jdk.virtualThreadScheduler.maxPoolSize 调整调度程序可用于的最大平台线程数量。

如下状况下,虚构线程在阻塞操作期间无奈卸载,因为它被固定在其载体:

  • 当它执行同步块或办法外部的代码时
  • 当它执行本机办法或内部函数时

固定不会使应用程序不正确,但可能会妨碍其可扩展性。若虚构线程在固定状态下执行阻塞操作,如 I/O 或 BlockingQueue.take(),则其载体和底层 OS 线程将在操作的持续时间内被阻塞。频繁而长时间的固定可能会侵害应用程序的可扩展性,因为它会占用载体。

调度程序不会通过扩大其并行性来弥补固定。相同,防止频繁和长时间的固定,通过批改频繁运行并爱护潜在的长时间 I/O 操作的同步块或办法,以应用 java.util.concurrent.locks.ReentrantLock,而不是 synchronized。无需替换仅在启动时执行的同步块和办法(如仅在启动时执行的同步块和办法,或者爱护内存中操作的同步块和办法)。判若两人,致力放弃锁策略简单明了。

新的诊断工具有助于将代码迁徙到虚构线程并评估是否应该用 java.util.concurrent 锁替换特定的 synchronized 应用:

  • 当线程在固定状态下阻塞时,会收回 JDK Flight Recorder (JFR) 事件(参阅 JDK Flight Recorder)。
  • 零碎属性 jdk.tracePinnedThreads 触发线程在固定状态下阻塞时的堆栈跟踪。应用 -Djdk.tracePinnedThreads=full 运行时会打印残缺的堆栈跟踪,突出显示了持有监视器的本机帧和帧。应用 -Djdk.tracePinnedThreads=short 会将输入限度为仅蕴含有问题的帧。

未来版本可能可能解决上述的第一个限度(在同步块外部固定)。第二个限度是为了与本机代码进行正确交互而须要的。

16 内存应用和与垃圾回收的交互

虚构线程的堆栈存储在 Java 的垃圾回收堆中,作为堆栈块对象。随利用运行,堆栈会动静增长和膨胀,既能高效应用内存,又可能包容任意深度的堆栈(最多达到 JVM 配置的平台线程堆栈大小)。这种效率是反对大量虚构线程的要害,因而线程每申请的格调在服务器应用程序中依然具备继续的可行性。

第二个示例中,一个假如的框架通过创立一个新的虚构线程并调用 handle 办法来解决每个申请;即便它在深层次的调栈开端(通过身份验证、事务等)调用 handlehandle 自身也会生成多个仅执行短暂工作的虚构线程。因而,对有深度调用栈的每个虚构线程,都将有多个具备浅调用栈的虚构线程,占用内存很少。

虚构线程与异步代码的堆空间应用和垃圾回收流动难以比拟:

  • 一百万个虚构线程需至多一百万个对象
  • 但共享平台线程池的一百万个工作也须要一百万个对象
  • 解决申请的利用程序代码通常会在 I/O 操作之间保留数据

Thread-per-request 的代码可将这些数据保留在本地变量,这些变量存储在堆中的虚构线程栈,而异步代码须将雷同的数据保留在从管道的一个阶段传递到下一个阶段的堆对象。一方面,虚构线程所需的栈更节约空间,而异步管道总是须要调配新对象,因而虚构线程可能须要较少的调配。总体而言,线程每申请代码与异步代码的堆耗费和垃圾回收流动应该大抵类似。随时间推移,心愿将虚构线程栈的外部示意大大压缩。

与平台线程栈不同,虚构线程栈不是 GC root,因而不会在垃圾收集器(如 G1)进行并发堆扫描时遍历其中的援用。这还意味着,如虚构线程被阻塞在如 BlockingQueue.take(),并且没有其余线程可获取到虚构线程或队列的援用,那该线程可进行垃圾回收 — 这没问题,因为虚构线程永远不会被中断或解除阻塞。当然,如虚构线程正在运行或正在阻塞且可能会被解除阻塞,那么它将不会被垃圾回收。

16.1 以后限度

G1 不反对宏大的(humongous)堆栈块对象。如虚构线程的堆栈达到 region 大小一半,这可能只有 512KB,那可能会抛 StackOverflowError

17 详情变动

在 Java 平台及其实现中的更改:

java.lang.Thread

API 更新:

  • Thread.Builder、Thread.ofVirtual() 和 Thread.ofPlatform(),创立虚构线程和平台线程的新 API。如
// 创立一个名为 "duke" 的新的未启动的虚构线程
Thread thread = Thread.ofVirtual().name("duke").unstarted(runnable);
  • Thread.startVirtualThread(Runnable),创立并启动虚构线程的便捷形式
  • Thread.Builder 可创立线程或 ThreadFactory 可创立具备雷同属性的多个线程
  • Thread.isVirtual():测试线程是否为虚构线程
  • Thread.join 和 Thread.sleep 的新重载承受 java.time.Duration 的期待和休眠参数
  • 新的 final 办法 Thread.threadId() 返回线程的标识符。现有的非 final 办法 Thread.getId() 已弃用。
  • Thread.getAllStackTraces() 当初返回所有平台线程的映射,而不是所有线程。

java.lang.Thread API 在其余方面不变。Thread 类定义的构造函数仍创立平台线程,与以前一样。没有新构造函数。

虚构线程和平台线程 API 区别

  • public Thread 构造函数无奈创立虚构线程
  • 虚构线程始终是守护线程。Thread.setDaemon(boolean) 办法无奈将虚构线程更改为非守护线程
  • 虚构线程具备 Thread.NORM_PRIORITY 的固定优先级。Thread.setPriority(int) 办法对虚构线程没有影响。这个限度可能会在未来版本重审
  • 虚构线程不是线程组的沉闷成员。在虚构线程上调用时,Thread.getThreadGroup() 返回一个带有名称 ”VirtualThreads” 的占位符线程组。Thread.Builder API 不定义设置虚构线程线程组的办法
  • 设置 SecurityManager 时,虚构线程在运行时没有权限
  • 虚构线程不反对 stop()、suspend() 或 resume() 办法。在虚构线程上调用这些办法会抛异样

线程本地变量

虚构线程反对:

  • 线程本地变量(ThreadLocal)
  • 可继承线程本地变量(InheritableThreadLocal)

就像平台线程,因而它们可运行应用线程本地变量的现有代码。然而,因为虚构线程可能十分多,应用线程本地变量时需谨慎思考。

不要应用线程本地变量在线程池中共享低廉资源,多个工作共享同一个线程。

虚构线程不应被池化,因为每个虚构线程的生命周期只用于运行单个工作。为在运行时具备数百万个线程时缩小内存占用,已从 java.base 模块删除了许多线程本地变量的用法。

更多的

Thread.Builder API 定义了一个办法,用于在创立线程时抉择不应用线程本地变量。它还定义了一个办法,用于抉择不继承 inheritable thread-locals 的初始值。在不反对线程本地变量的线程上调用 ThreadLocal.get() 将返回初始值,ThreadLocal.set(T) 会抛异样。

传统的上下文类加载器当初被指定为像 inheritable thread local 一样工作。如在不反对 thread locals 的线程上调用 Thread.setContextClassLoader(ClassLoader),则抛异样。

范畴本地变量可能对某些用例来说是线程本地变量的更好抉择。

JUC

反对锁的根本 API,java.util.concurrent.LockSupport,现反对虚构线程:

  • 挂起虚构线程会开释底层的平台线程以执行其余工作
  • 而唤醒虚构线程会安顿它继续执行

这对 LockSupport 的更改使得所有应用它的 API(锁、信号量、阻塞队列等)在虚构线程中调用时可能优雅地挂起。

此外

Executors.newThreadPerTaskExecutor(ThreadFactory) 和 Executors.newVirtualThreadPerTaskExecutor() 创立一个 ExecutorService,它为每个工作创立一个新线程。这些办法容许迁徙和与应用线程池和 ExecutorService 的现有代码进行互操作。

ExecutorService 现扩大 AutoCloseable,可应用 try-with-resource 结构来应用此 API,如下面 demo。

Future 现定义了获取已实现工作的后果或异样及获取工作状态的办法。它们组合可将 Future 对象用作流的元素,过滤蕴含已实现工作的流,而后 map 以获取后果的流。这些办法也将对结构化并发的 API 增加十分有用。

18 网络

java.net 和 java.nio.channels 包中的网络 API 的实现当初与虚构线程一起工作:在虚构线程上执行的操作,如建设网络连接或从套接字读取时,将开释底层平台线程以执行其余工作。

为容许中断和勾销操作,java.net.Socket、ServerSocket 和 DatagramSocket 定义的阻塞 I / O 办法当初在虚构线程中调用时被规定为可中断:中断在套接字上阻塞的虚构线程将唤醒线程并敞开套接字。从 InterruptibleChannel 获取的这些类型套接字上的阻塞 I / O 操作始终是可中断,因而这个更改使得这些 API 在应用它们的构造函数创立时的行为与从通道获取时的行为保持一致。

java.io

提供了字节和字符流的 API。这些 API 的实现在被虚构线程应用时须要进行更改以防止固定(pinning)。

作为背景,面向字节的输出 / 输入流没有规定是线程平安的,也没有规定在线程在读取或写入办法中被阻塞时调用 close() 的预期行为。大多状况下,不应在多个并发线程中应用特定的输出或输入流。面向字符的读取 / 写入器也没规定是线程平安的,但它们为子类公开了一个锁对象。除了固定,这些类中的同步存在问题且不统一;例如,InputStreamReader 和 OutputStreamWriter 应用的流解码器和编码器在流对象上同步,而不是在锁对象上同步。

为了避免固定,当初实现的工作形式如下:

  • BufferedInputStream、BufferedOutputStream、BufferedReader、BufferedWriter、PrintStream 和 PrintWriter 当初在间接应用时应用显式锁,而不是监视器。当它们被子类化时,这些类会像以前一样同步
  • InputStreamReader 和 OutputStreamWriter 应用的流解码器和编码器当初应用与蕴含它们的 InputStreamReader 或 OutputStreamWriter 雷同的锁
  • BufferedOutputStream、BufferedWriter 和 OutputStreamWriter 应用的流编码器的初始缓冲区大小当初更小,以缩小在堆中存在许多流或编写器时的内存应用——如果有百万个虚构线程,每个线程都有一个套接字连贯上的缓冲流,这种状况可能会产生。

JNI

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

调试

调试架构包含三个接口:JVM 工具接口(JVM TI)、Java 调试线协定(JDWP)和 Java 调试接口(JDI)。这三个接口当初都反对虚构线程。

JVM TI 的更新包含:

  • 大多数应用 jthread(即对 Thread 对象的 JNI 援用)调用的函数当初能够应用对虚构线程的援用来调用。一小部分函数,即 PopFrame、ForceEarlyReturn、StopThread、AgentStartFunction 和 GetThreadCpuTime,不反对虚构线程。SetLocal* 函数仅限于在中断或单步事件时挂起的虚构线程的最顶层帧中设置本地变量
  • GetAllThreads 和 GetAllStackTraces 函数当初规定返回所有平台线程,而不是所有线程
  • 所有事件,除了在晚期 VM 启动或堆迭代期间公布的事件外,都能够在虚构线程的上下文中调用事件回调
  • 挂起 / 复原实现容许调试器挂起和复原虚构线程,以及在挂载虚构线程时挂起平台线程
  • 一个新的能力 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 示意虚构线程被固定(pinned)时的状况,即在不开释其平台线程的状况下被挂起。此事件默认状况下启用,阈值为 20 毫秒。

jdk.VirtualThreadSubmitFailed 示意启动或唤醒虚构线程失败,可能是因为资源问题。此事件默认状况下启用。

Java 治理扩大(JMX)
java.lang.management.ThreadMXBean 仅反对监督和治理平台线程。findDeadlockedThreads() 办法查找处于死锁状态的平台线程的循环;它不会查找处于死锁状态的虚构线程的循环。

com.sun.management.HotSpotDiagnosticsMXBean 中的一个新办法生成了下面形容的旧式线程转储。能够通过平台 MBeanServer 从本地或近程 JMX 工具间接调用此办法。

java.lang.ThreadGroup
java.lang.ThreadGroup 是一个用于分组线程的遗留 API,在古代应用程序中很少应用,不适宜分组虚构线程。咱们当初将其标记为已过期并降级,预计未来将在结构化并发的一部分中引入新的线程组织结构。

作为背景,ThreadGroup API 来自 Java 1.0。最后,它的目标是提供作业控制操作,如进行组中的所有线程。古代代码更有可能应用自 Java 5 引入的 java.util.concurrent 包的线程池 API。ThreadGroup 反对晚期 Java 版本中小程序的隔离,但 Java 1.2 中 Java 安全性架构的演进显著,线程组不再表演重要角色。ThreadGroup 还旨在用于诊断目标,但这个角色在 Java 5 引入的监督和治理性能,包含 java.lang.management API,中已被取代。

除了当初根本无关紧要外,ThreadGroup API 和其实现存在一些重要问题:

销毁线程组的能力存在缺点。

API 要求实现具备对组中的所有流动线程的援用。这会减少线程创立、线程启动和线程终止的同步和争用开销。

API 定义了 enumerate() 办法,这些办法实质上是竞态条件的。

API 定义了 suspend()、resume() 和 stop() 办法,这些办法实质上容易产生死锁且不平安。

ThreadGroup 当初被规定为已过期和降级如下:

明确删除了显式销毁线程组的能力:已终止过期的 destroy() 办法不再执行任何操作。

删除了守护线程组的概念:已终止过期的 setDaemon(boolean) 和 isDaemon() 办法设置和检索的守护状态被疏忽。

当初,实现不再放弃对子组的强援用。ThreadGroup 当初在组中没有流动线程且没有其余货色放弃线程组存活时能够被垃圾回收。

已终止的 suspend()、resume() 和 stop() 办法总是抛出异样。

代替计划

持续依赖异步 API。异步 API 难与同步 API 集成,创立了两种示意雷同 I / O 操作的不同示意,不提供用于上下文的操作序列的对立概念,无奈用于故障排除、监督、调试和性能剖析。

向 Java 增加语法无堆栈协程(即 async/await)。与用户模式线程相比,这些更易实现,并且将提供一种示意操作序列上下文的对立结构。然而,这个结构是新的,与线程离开,与线程在许多方面类似但在一些奥妙的形式中不同。它将在线程设计的 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、BufferedReader、BufferedWriter、PrintStream 和 PrintWriter 类中的外部(和未记录的)锁定协定的订正可能会影响那些假如 I / O 办法在调用时会在其上同步的代码。这些更改不会影响通过扩大这些类并假设由超类同步的代码,也不会影响扩大 java.io.Reader 或 java.io.Writer 并应用这些 API 公开的锁对象的代码。

java.lang.ThreadGroup 不再容许销毁线程组,不再反对守护线程组的概念,并且其 suspend()、resume() 和 stop() 办法始终引发异样。

有一些源不兼容的 API 更改和一个二进制不兼容的更改,可能会影响那些扩大 java.lang.Thread 的代码:

如果现有源文件中的代码扩大了 Thread 并且子类中的办法与任何新的 Thread 办法抵触,则该文件将无奈在不进行更改的状况下编译。

Thread.Builder 被增加为嵌套接口。如果现有源文件中的代码扩大了 Thread,导入了名为 Builder 的类,并且子类中援用“Builder”作为简略名称的代码,则该文件将无奈在不进行更改的状况下编译。

Thread.threadId() 被增加为一个返回线程标识符的 final 办法。如果现有源文件中的代码扩大了 Thread,并且子类申明了一个名为 threadId 的无参数办法,则它将无奈编译。如果存在已编译的扩大 Thread 的代码,并且子类定义了一个返回类型为 long 且没有参数的 threadId 办法,则在加载子类时将抛出 IncompatibleClassChangeError。

在混合现有代码与利用虚构线程或新 API 的较新代码时,可能会察看到平台线程和虚构线程之间的一些行为差别:

Thread.setPriority(int) 办法不会对虚构线程产生影响,虚构线程始终具备 Thread.NORM_PRIORITY 优先级。

Thread.setDaemon(boolean) 办法对虚构线程没有影响,虚构线程始终是守护线程。

Thread.stop()、suspend() 和 resume() 办法在虚构线程上调用时会引发 UnsupportedOperationException 异样。

Thread API 反对创立不反对线程本地变量的线程。在不反对线程本地变量的线程上调用 ThreadLocal.set(T) 和 Thread.setContextClassLoader(ClassLoader) 时会引发 UnsupportedOperationException 异样。

Thread.getAllStackTraces() 当初返回所有平台线程的映射,而不是所有线程的映射。

java.net.Socket、ServerSocket 和 DatagramSocket 定义的阻塞 I / O 办法当初在虚构线程的上下文中被中断时可中断。当线程在套接字操作上被中断时,现有代码可能会中断,这将唤醒线程并敞开套接字。

虚构线程不是 ThreadGroup 的流动成员。在虚构线程上调用 Thread.getThreadGroup() 将返回一个名为 ”VirtualThreads” 的虚构线程组,该组为空。

虚构线程在设置了 SecurityManager 的状况下没有权限。

在 JVM TI 中,GetAllThreads 和 GetAllStackTraces 函数不返回虚构线程。已启用 ThreadStart 和 ThreadEnd 事件的现有代理程序可能会遇到性能问题,因为它们无奈将这些事件限度为平台线程。

java.lang.management.ThreadMXBean API 反对监督和治理平台线程,但不反对虚构线程。

-XX:+PreserveFramePointer 标记对虚构线程性能产生重大的负面影响。

依赖关系

JEP 416(应用 Method Handles 从新实现外围反射)在 JDK 18 中移除 VM 本机反射实现。这容许虚构线程在通过反射调用办法时失常挂起。

JEP 353(应用新实现替换传统 Socket API)在 JDK 13 中,以及 JEP 373(应用新实现替换传统 DatagramSocket API)在 JDK 15 中,替换了 java.net.Socket、ServerSocket 和 DatagramSocket 的实现,以适应虚构线程的应用。

JEP 418(Internet 地址解析 SPI)在 JDK 18 中定义了一种主机名和地址查找的服务提供程序接口。这将容许第三方库实现不会在主机查找期间钉住线程的代替 java.net.InetAddress 解析器。

本文由博客一文多发平台 OpenWrite 公布!

正文完
 0