JEP 444: Virtual Threads JEP 444: 虚拟线程

JEP 444: Virtual Threads JEP 444: 虚拟线程Author 作者 Ron Pressler amp Alan BatemanOwner 所有者 Alan Bateman 艾伦 贝特曼 Type 类型 Feature 特征 Scope 范围 SEStatus 地位 Closed De

欢迎大家来到IT世界,在知识的湖畔探索吧!

Author 作者 Ron Pressler & Alan Bateman

Owner 所有者 Alan Bateman 艾伦·贝特曼

Type 类型 Feature 特征

Scope 范围 SE

Status 地位 Closed / Delivered 关闭/交付

Release 释放 21

  1. Summary 总结

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

Introduce virtual threads to the Java Platform. Virtual threads are lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.

  1. History 历史

虚拟线程由 JEP 425 作为预览功能提出,并在 JDK 19 中提供。为了留出时间反馈并获得更多经验,JEP 436 再次将它们作为预览功能提出,并在 JDK 20 中交付。此 JEP 建议根据开发人员反馈,通过对 JDK 20 进行以下更改,最终完成 JDK 21 中的虚拟线程:

Virtual threads were proposed as a preview feature by JEP 425 and delivered in JDK 19. To allow time for feedback and to get more experience, they were proposed as a preview feature again by JEP 436 and delivered in JDK 20. This JEP proposes to finalize virtual threads in JDK 21 with the following changes from JDK 20, informed by developer feedback:

  • Virtual threads now always support thread-local variables. It is no longer possible, as it was in the preview releases, to create virtual threads that cannot have thread-local variables. Guaranteed support for thread-local variables ensures that many more existing libraries can be used unchanged with virtual threads, and helps with the migration of task-oriented code to use virtual threads. 虚拟线程现在始终支持线程局部变量。不再可能像预览版那样创建不能具有线程局部变量的虚拟线程。对线程局部变量的保证支持可确保更多现有库可以原封不动地与虚拟线程一起使用,并有助于将面向任务的代码迁移到使用虚拟线程。
  • Virtual threads created directly with the Thread.Builder API (as opposed to those created through Executors.newVirtualThreadPerTaskExecutor()) are now also, by default, monitored throughout their lifetime and observable via the new thread dump described in the Observing virtual threads section. 默认情况下,直接使用 Thread.Builder API 创建的虚拟线程(与通过 创建的 Executors.newVirtualThreadPerTaskExecutor() 虚拟线程相反)现在还在其整个生命周期中进行监视,并且可以通过观察虚拟线程部分中描述的新线程转储进行观察。
  1. Goals 目标
  • Enable server applications written in the simple thread-per-request style to scale with near-optimal hardware utilization. 使以简单的按请求线程样式编写的服务器应用程序能够以接近最佳的硬件利用率进行扩展。
  • Enable existing code that uses the java.lang.Thread API to adopt virtual threads with minimal change. 启用使用 java.lang.Thread API 的现有代码,以最少的更改采用虚拟线程。
  • Enable easy troubleshooting, debugging, and profiling of virtual threads with existing JDK tools. 使用现有的 JDK 工具轻松对虚拟线程进行故障排除、调试和分析。
  1. Non-Goals 非目标
  • It is not a goal to remove the traditional implementation of threads, or to silently migrate existing applications to use virtual threads. 删除线程的传统实现或静默迁移现有应用程序以使用虚拟线程不是目标。
  • It is not a goal to change the basic concurrency model of Java. 改变 Java 的基本并发模型不是目标。
  • It is not a goal to offer a new data parallelism construct in either the Java language or the Java libraries. The Stream API remains the preferred way to process large data sets in parallel. 在 Java 语言或 Java 库中提供新的数据并行构造不是目标。流 API 仍然是并行处理大型数据集的首选方法。
  1. Motivation 赋予动机
  2. 近三十年来,Java 开发人员一直依赖线程作为并发服务器应用程序的构建块。每个方法中的每个语句都在线程内执行,并且由于Java是多线程的,因此同时发生多个执行线程。线程是Java的并发单元:一段与其他此类单元同时运行的顺序代码,并且在很大程度上独立于其他此类单元。每个线程都提供一个堆栈来存储局部变量和坐标方法调用,以及出现问题时的上下文:异常由同一线程中的方法引发和捕获,因此开发人员可以使用线程的堆栈跟踪来找出发生了什么。线程也是工具的核心概念:调试器逐步执行线程方法中的语句,探查器可视化多个线程的行为以帮助了解其性能。
  1. The thread-per-request style

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

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

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

  1. 使用异步样式提高可伸缩性

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

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

  1. 使用虚拟线程保留每个请求的线程样式

为了使应用程序能够扩展,同时与平台保持协调,我们应该努力保留每个请求的线程样式。我们可以通过更有效地实现线程来做到这一点,这样它们就可以更丰富。操作系统无法更有效地实现操作系统线程,因为不同的语言和运行时以不同的方式使用线程堆栈。但是,Java 运行时有可能以切断 Java 线程与操作系统线程一对一对应的方式实现 Java 线程。正如操作系统通过将大型虚拟地址空间映射到有限数量的物理 RAM 来给人一种内存充足的错觉一样,Java 运行时可以通过将大量虚拟线程映射到少量操作系统线程来产生大量线程的错觉。

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

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

  1. 虚拟线程的含义

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

总之,虚拟线程保留了可靠的每请求线程样式,该样式与 Java 平台的设计相协调,同时以最佳方式利用可用硬件。使用虚拟线程不需要学习新概念,尽管可能需要摒弃习惯,以应对当今高昂的线程成本。虚拟线程不仅可以帮助应用程序开发人员,还将帮助框架设计人员提供易于使用的API,这些API与平台设计兼容,而不会影响可扩展性。

  1. Description 描述

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

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

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

  1. 使用虚拟线程与平台线程

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

欢迎大家来到IT世界,在知识的湖畔探索吧!

此示例中的任务是简单的代码(休眠一秒钟),现代硬件可以轻松支持 10,000 个虚拟线程同时运行此类代码。在后台,JDK 在少量操作系统线程上运行代码,可能只有一个。

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

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

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

换句话说,虚拟线程可以显著提高应用程序吞吐量:

  1. 并发任务数很高(超过几千个) The number of concurrent tasks is high (more than a few thousand)
  2. 工作负载不受 CPU 限制,因为在这种情况下,线程数比处理器内核多得多,无法提高吞吐量。

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

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

下面是聚合其他两个服务结果的服务器应用程序示例。假设的服务器框架(未显示)为每个请求创建一个新的虚拟线程,并在该虚拟线程中运行应用程序 handle 的代码。反过来,应用程序代码创建两个新的虚拟线程,通过与第一个示例相同的 ExecutorService 方式并发获取资源:

欢迎大家来到IT世界,在知识的湖畔探索吧!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 来创建和管理虚拟线程,特别是在与此服务器示例类似的代码中,其中线程之间的关系由平台及其工具知道。

  1. 不池化虚拟线程

开发人员通常会 ExecutorService 将应用程序代码从传统的基于线程池的 ExecutorService .与任何资源池一样,线程池旨在共享昂贵的资源,但虚拟线程并不昂贵,因此永远不需要池化它们。开发人员有时会使用线程池来限制对有限资源的并发访问。例如,如果服务无法处理超过 20 个并发请求,则通过提交到大小为 20 的线程池的任务向服务发出所有请求将确保这一点。这个习惯用法已经变得无处不在,因为平台线程的高成本使线程池无处不在,但不要试图为了限制并发而池化虚拟线程。请改用专门为此目的设计的构造,例如信号量。

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

  1. 观察虚拟线程

编写清晰的代码并不是全部。清晰地呈现正在运行的程序的状态对于故障排除、维护和优化也是必不可少的,JDK 长期以来一直提供调试、分析和监视线程的机制。这些工具应该对虚拟线程执行相同的操作 – 也许可以适应它们的大量 – 因为它们毕竟是 . java.lang.Thread。

Java 调试器可以单步执行虚拟线程、显示调用堆栈以及检查堆栈帧中的变量。JDK 飞行记录器 (JFR) 是 JDK 的低开销分析和监视机制,可以将来自应用程序代码的事件(如对象分配和 I/O 操作)与正确的虚拟线程相关联。对于以异步样式编写的应用程序,这些工具无法执行这些操作。在这种样式中,任务与线程无关,因此调试器无法显示或操作任务的状态,并且探查器无法判断任务等待 I/O 所花费的时间。

线程转储是另一种流行的工具,用于对以按请求线程样式编写的应用程序进行故障排除。不幸的是,JDK 的传统线程转储(使用 jstack or jcmd 获得)提供了一个平面线程列表。这适用于数十或数百个平台线程,但不适用于数千或数百万个虚拟线程。因此,我们不会扩展传统的线程转储以包含虚拟线程;相反,我们将引入一种新的线程转储,以将虚拟线程与平台线程一起呈现,所有这些都 jcmd 以有意义的方式分组。当程序使用结构化并发时,可以显示线程之间更丰富的关系。

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

$ jcmd 
   
     Thread.dump_to_file -format=json 
     
   

新的线程转储格式不包括对象地址、锁、JNI 统计信息、堆统计信息以及传统线程转储中显示的其他信息。此外,由于它可能需要列出大量线程,因此生成新的线程转储不会暂停应用程序。如果 system 属性 jdk.trackAllThreads 设置为 false ,即使用 -Djdk.trackAllThreads=false 命令行选项,则运行时不会始终跟踪直接使用 Thread.Builder API 创建的虚拟线程,并且可能不会出现在新的线程转储中。在这种情况下,新的线程转储将列出在网络 I/O 操作中被阻止的虚拟线程,以及由上面显示的每个任务 ExecutorService 的新线程创建的虚拟线程。由于虚拟线程是在 JDK 中实现的,并且不绑定到任何特定的操作系统线程,因此它们对操作系统是不可见的,操作系统不知道它们的存在。操作系统级别的监视将观察到 JDK 进程使用的操作系统线程数少于虚拟线程数

  1. 调度虚拟线程

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

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

调度程序为其分配虚拟线程的平台线程称为虚拟线程的载体。虚拟线程可以在其生命周期内安排在不同的载体上;换句话说,调度程序不维护虚拟线程和任何特定平台线程之间的关联。从 Java 代码的角度来看,正在运行的虚拟线程在逻辑上独立于其当前载体:

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

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

调度程序当前未实现虚拟线程的分时。分时是对消耗分配的 CPU 时间的线程的强制抢占。虽然当平台线程数量相对较少且 CPU 利用率为 100% 时,分时可以有效地减少某些任务的延迟,但不清楚分时是否与一百万个虚拟线程一样有效。

  1. 执行虚拟线程

要利用虚拟线程,无需重写程序。虚拟线程不需要或期望应用程序代码将控制权显式交回调度程序;换句话说,虚拟线程是不合作的。用户代码不得假设如何或何时将虚拟线程分配给平台线程,就像它不能假设平台线程如何或何时分配给处理器内核一样。

要在虚拟线程中运行代码,JDK 的虚拟线程调度程序通过在平台线程上挂载虚拟线程来分配虚拟线程以在平台线程上执行。这使得平台线程成为虚拟线程的载体。稍后,在运行一些代码后,虚拟线程可以从其载体中卸载。此时,平台线程是空闲的,因此调度程序可以在其上挂载不同的虚拟线程,从而再次使其成为载体。

通常,当虚拟线程阻塞 I/O 或 JDK 中的某些其他阻塞操作(如 BlockingQueue.take() .当阻塞操作准备好完成时(例如,套接字上已收到字节),它将虚拟线程提交回调度程序,调度程序会将虚拟线程挂载到载体上以恢复执行。

虚拟线程的挂载和卸载频繁且透明地进行,并且不会阻塞任何操作系统线程。例如,前面显示的服务器应用程序包含以下代码行,其中包含对阻止操作的调用:

欢迎大家来到IT世界,在知识的湖畔探索吧!response.send(future1.get() + future2.get());

这些操作将导致虚拟线程多次装载和卸载,通常每次调用一次 get() ,在执行 中的 send(…) I/O 过程中可能多次。

JDK 中的绝大多数阻塞操作将卸载虚拟线程,从而释放其载体和底层操作系统线程以承担新工作。但是,JDK 中的某些阻塞操作不会卸载虚拟线程,从而会同时阻塞其载体和基础操作系统线程。这是因为操作系统级别(例如,许多文件系统操作)或 JDK 级别(例如, Object.wait() )。这些阻塞操作的实现通过临时扩展调度程序的并行性来补偿操作系统线程的捕获。因此,调度程序中的平台线程数 ForkJoinPool 可能会暂时超过可用处理器的数量。可以使用 系统属性 jdk.virtualThreadScheduler.maxPoolSize 调整调度程序可用的最大平台线程数。

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

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

固定不会使应用程序不正确,但可能会妨碍其可伸缩性。如果虚拟线程执行阻塞操作(如 I/O)或在固定 BlockingQueue.take() 时执行阻塞操作,则其载体和基础操作系统线程将在操作期间被阻塞。长时间频繁固定可能会捕获载体,从而损害应用程序的可伸缩性。

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

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

  • 当线程在固定时阻塞时,将发出 JDK 飞行记录器 (JFR) 事件(请参阅 JDK 飞行记录器)。
  • 当线程在固定时阻塞时,system 属性 jdk.tracePinnedThreads 会触发堆栈跟踪。当线程在固定时阻塞时,运行 with -Djdk.tracePinnedThreads=full 会打印完整的堆栈跟踪,突出显示本机帧和持有监视器的帧。运行 with -Djdk.tracePinnedThreads=short 会将输出限制为只有有问题的帧。

在未来的版本中,我们也许能够删除上述第一个限制,即 固定在 synchronized .第二个限制是与本机代码正确交互所必需的。

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

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

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

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

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

虚拟线程的当前限制是 G1 GC 不支持庞大的堆栈块对象。如果虚拟线程的堆栈达到区域大小的一半(可能小至 512KB),则可能会引发 。 StackOverflowError。

  1. Thread-local variables 线程局部变量

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

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

对于某些用例,作用域值 (JEP 429) 可能被证明是线程局部变量的更好替代方案。

  1. java.util.concurrent

支持锁定 java.util.concurrent.LockSupport 的基元 API 现在支持虚拟线程:停放虚拟线程会释放底层平台线程以执行其他工作,而取消停放虚拟线程会安排它继续。此更改使 LockSupport 使用它的所有 API( s、 Lock s、 Semaphore 阻塞队列等)在虚拟线程中调用时能够正常停放。

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

  1. Networking 联网

和 java.net java.nio.channels 包中的网络 API 的实现现在使用虚拟线程:虚拟线程上的操作阻止(例如,建立网络连接或从套接字读取)释放底层平台线程以执行其他工作。

为了允许中断和取消,由 、 ServerSocket 定义的 java.net.Socket 阻塞 I/O 方法现在 DatagramSocket 被指定为在虚拟线程中调用时可中断:中断套接字上阻塞的虚拟线程将取消寄存线程并关闭套接字。从 获取 InterruptibleChannel 这些类型的套接字时,阻止这些类型的套接字上的 I/O 操作始终是可中断的,因此此更改使使用这些 API 的构造函数创建时的行为与从通道获取时的行为保持一致。

  1. java.io

该 java.io 包为字节和字符流提供 API。这些 API 的实现高度同步,需要进行更改以避免在虚拟线程中使用时固定。作为背景,面向字节的输入/输出流未指定为线程安全,并且未指定在读取或写入方法中阻塞线程时调用时的 close() 预期行为。在大多数情况下,使用来自多个并发线程的特定输入或输出流是没有意义的。面向字符的读取器/编写器也没有指定为线程安全的,但它们确实为子类公开了一个锁定对象。除了固定之外,这些类中的同步还有问题且不一致;例如,流解码器和编码器由 InputStreamReader 流对象而不是锁定对象使用并 OutputStreamWriter 同步。

为了防止固定,实现现在的工作方式如下:

  • BufferedInputStream 、、、 BufferedOutputStream BufferedReader BufferedWriter 、 PrintStream PrintWriter 现在直接使用时使用显式锁而不是监视器。这些类在子类化时像以前一样同步。
  • 由 使用的 InputStreamReader OutputStreamWriter 流解码器和编码器现在使用与封闭 InputStreamReader 或 OutputStreamWriter .

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


免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/84886.html

(0)

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们YX

mu99908888

在线咨询: 微信交谈

邮件:itzsgw@126.com

工作时间:时刻准备着!

关注微信