Java 20——在 Loom 项目上加倍努力
作者:互联网
作用域值
要理解为什么要开发作用域值功能,需要很好地理解线程局部变量,以及它的所有优点和缺点。
在连接的工作流中的参与者之间共享数据传统上是通过将其作为方法参数传递来完成的,但是为了避免代码中包含过多的方法参数,一种方法是对正在运行的线程内的数据使用线程局部变量。每个工作负载线程模式最常用的示例当然是 Web 服务器,它们正在侦听互联网流量并为每个单独的请求生成一个新线程。
您可以方便地在请求处理程序的入口点存储一些数据,并在正在执行的请求的所有工作负载中使用该数据,而无需将该数据作为方法参数显式传递到您的代码库中。存储的数据仅对当前线程可用,并与所述线程一起生存和死亡,这意味着当线程通过成功完成请求或由于任何原因突然停止而完成执行时,垃圾收集器将清除数据。
您希望按请求/线程存储、从代码中的不同点访问并在线程被销毁时销毁的数据的一个很好的例子是发起 Web 请求的用户。您将需要整个代码中的用户数据,以检查与用户本身相关的不同权限和查询数据,因此数据可以简单地用于当前工作流/请求处理程序/线程中正在执行的代码,而不必将它作为方法参数传递到整个代码中可能非常有用且设计简洁。
Java 2 中引入的线程局部变量是实现上述数据共享方式的主要机制,巧合的是,Java 20 为我们提供了一种称为作用域值的新机制,主要是为与虚拟线程协同工作而量身定制的,虚拟线程现在可以产生数百万个绿色线程,而不是几千个平台(OS)“重”线程。
让我们简要地看看 Thread Local 数据共享的作用。您所要做的就是创建一个新的 Thread Local 通用变量,通常它是一个最终静态变量,以便于访问和使用:
class Handler { final static ThreadLocal<String> userInfo = new ThreadLocal <>(); ... }
一旦创建你的变量就可以设置和访问任意次数,不同的线程将设置不同的值,JVM 保证你的值将是本地的并且为每个单独的线程隔离,这意味着你保证在稍后的执行中读取该值读取*您的*线程设置的值,将无法读取任何其他线程设置的数据。
通过一个非常简单的 api 访问和设置数据:
userInfo.set("用户名"); “用户名”); .... 处理程序.userInfo.get();
您可能已经注意到 Thread Local 设计的第一个潜在缺陷,即不可变性或缺乏不可变性。在绝大多数用例中,您将设置一次数据,并且只在整个请求上下文中读取该数据,但这不容易强制执行,这意味着data.set("data")
可以随时随地调用任意次数。这是 Scoped Values 带来的第一个设计差异——它们使数据不可变,只设置一次,之后是只读的。
第二个潜在的陷阱是当请求不是短暂的时数据的生命周期。对于长时间运行的请求,当您在 ThreadLocal 存储中存储大量/昂贵的数据时,您可能希望在不再需要时立即释放数据。API 允许您在线程仍在运行时通过调用方法手动释放数据remove()
。问题在于知道什么时候调用删除是安全的,您是否完全确定data.get()
删除后没有人会调用?如果今天它在您的代码库中是安全的,那么将来修改代码的每个人都会知道数据已被删除并且在某个时间点之后获取它是不可能的吗?因此,虽然非常有用,但删除功能可能容易出错并且很难以正确的方式执行。
最后一个陷阱有点微妙,但对于可扩展性很重要,因为与平台线程相比,虚拟线程可以在数量级上扩展更多,而且它与线程继承有关。一些深入使用过 ThreadLocal 的人会知道,线程局部变量实际上不是一个线程的局部变量。当一个子线程被创建时,它需要分配额外的存储空间来保存父线程写入内存的所有线程局部变量。对于非常大量的线程(如虚拟线程的情况),过多的存储分配会给 JVM 带来巨大损失,并显着影响您的性能。
为了解决所有提到的陷阱,Oracle 引入了一个新的 - 轻量级 - 数据共享系统,该系统使数据不可变,因此可以由子线程有效地共享。
Scoped values 特性直接受到 Lisp 方言的启发,它提供对动态范围变量的支持,因此它的语法可能与许多人在“传统”Java 代码中所期望的有点不同。
定义 Scoped Value 与 ThreadLocal 几乎相同:
final static ScopedValue<String> userInfo = new ScopedValue <>();
但是用法本身并不像调用.get()
和那样简单.set(...)
用法在JEP本身中得到了最好的描述,就像这样。
在请求处理程序的开头,您将调用ScopedValue.where(...)
,提供一个作用域值和它要绑定到的对象。run(...)
对绑定作用域值的调用,提供特定于当前线程的化身,然后执行作为参数传递的 lambda 表达式。在调用的生命周期中run(...)
,lambda 表达式或从该表达式直接或间接调用的任何方法都可以通过值的get()
方法读取范围内的值。方法完成后run(...)
,绑定将被销毁。
final static ScopedValue<...> V = new ScopedValue <>(); // 在某些方法中,大部分时间在请求处理程序的开头 ScopedValue.where(V, <value>) .run(() -> { ... V.get() ... 调用方法 .. }); .... // 在从 lambda 表达式直接或间接调用的方法中 ... V.get() ...
你可能会说:嘿,但我可以通过将数据传递给方法调用的所有方法来做同样的事情.run()
。这正是重点,你可以但现在你不必这样做,就像线程局部变量一样,数据将简单地可供当前线程执行的所有代码使用,而不必将其作为参数显式传递在每个单独的方法调用中,此数据也是不可变的,共享它会非常快速和高效。
虽然 Scoped Values 是一个全新的事物,但我们将讨论的以下两个功能是记录模式的第二个预览版和结构化并发的第二个孵化器。这两个与几个月前首次出现在 Java 19 中时略有不同,因此下面的内容与我去年的Java 19 概述基本相同。这意味着如果您完全熟悉 19 中介绍的内容,您将不会在接下来的段落中看到任何新内容。
虚拟线程
添加对轻量级/虚拟线程的本机支持背后的动机并不是要弃用或替换当前传统的 Java 线程 API。而是引入这种强大的范例,它将大大(用 Oracle 的话来说是“戏剧性地”)减少在 Java 中创建超大规模并发工作流的工作量。其他语言(如 Go 或 Erlang)拥有多年或数十年的东西。
当前线程实现的普遍问题是它可以将应用程序带宽限制在远低于现代硬件可以处理的水平。这意味着在当今的 Java 应用程序中,尤其是基于 Web 的软件,限制吞吐量的不是 CPU、内存或网络,而是可供您使用的操作系统线程的数量,因为 Java 线程直接环绕操作系统线程。
虽然池确实有很大帮助,因为您不必每次都为创建新线程付出高昂的代价,但它不会增加可用线程的总数。
为什么不“简单地”对高吞吐量 Java 应用程序使用响应式编程?嗯,根据 Java 的核心团队,反应式范式与 Java 平台的其余部分不协调,并且它不是用 Java 编写程序的自然方式。因此,根据 Oracle 的说法,实现虚拟线程与 Java 中当前存在的所有内容完美对齐,并且在未来,它应该是在 Java 中构建大规模的 thread-per-request 样式程序时的首选方法。
用最简单的术语来说,虚拟线程不直接绑定到特定的操作系统线程,而平台线程是操作系统线程的薄包装。实际上,这导致 Java 运行时能够通过将大量虚拟线程映射到少量真实 OS 线程来提供大量线程的错觉,这意味着每个请求线程样式的应用程序代码可以在虚拟线程中运行一个请求的整个持续时间内,虚拟线程只在 CPU 上执行计算时消耗一个 OS 线程。
让我们看看实际的代码:
private static void virtualThreadsDemo ( int numberOfThreads ) { try ( var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range( 0 , numberOfThreads).forEach(i -> { executor.submit(() -> { Thread.sleep(Duration .ofMillis( 1 )); 返回i; }); }); } }
Intel CPU (i5–6200U) 上的非常简单的基准测试显示创建 9000 个线程需要半秒 (0.5s),启动和执行一百万个虚拟线程只需五秒 (5s)。
这些数字令人印象深刻吗?好吧,就像在任何其他基准测试中一样,如果没有一些基准,就不可能说出来。因此,让我们使用平台线程进行相同的处理并查看比较。
private static void platformThreadsDemo ( int numberOfThreads ) { try ( var executor = Executors.newCachedThreadPool()) { IntStream.range( 0 , numberOfThreads).forEach(i -> { executor.submit(() -> { Thread.sleep(Duration .ofMillis( 1 )); 返回i; }); }); } }
启动 9000 个平台线程并没有真正显示出太大差异,运行时间是相同的,但是 100 万个线程测试花费了 11 秒 (11s),这是虚拟线程时间的两倍多。
由于 Hot Spot VM 的工作方式以及在基准测试之前达到最佳编译级别所需的预热时间,Java 中的基准测试不能简单地通过测量经过的时间来完成。
总而言之,虚拟线程是对 Java 平台的一个非常令人兴奋的补充,而且 Oracle 对简单性、易用性和互操作性的关注似乎会带来好处。
结构化并发
结构化并发就是通过将在不同线程中运行的多个任务分组为一个工作单元来简化编写、读取和维护起来很复杂的多线程代码。简单地说,这个想法是尽可能将单线程代码的简单性带到多线程工作流中。
在上一个主题的基础上构建结构化并发与虚拟线程完美配合,其中虚拟线程提供大量线程,结构化并发确保它们正确且稳健地协调。
虽然这个主题与多线程领域中的所有其他主题一样复杂并且需要相当多的时间才能掌握,但下面的代码片段应该是结构化并发的一个很好的例子。
try ( var scope = new StructuredTaskScope .ShutdownOnFailure()) { Future<User> mediumUser = scope.fork(() -> getMediumUser()); 未来<SubscriptionTier> subscriptionTier = scope.fork(() -> getSubscriptionTier()); Future<UserInterests> userInterests = scope.fork(() -> getUserInterests()); 作用域.join(); scope.throwIfFailed(IllegalArgumentException:: new ); 返回 新的 响应(mediumUser.resultNow(), subscriptionTier.resultNow(), userInterests.resultNow()); }
除了代码简单之外,这里真正强大的是一种统一的方式来处理在完全不同的(虚拟或平台)线程中运行的不同执行的错误场景。
请注意,运行此代码启用预览功能是不够的,因为此功能是孵化器功能,因此需要通过 VM 标志或 IDE 中的 GUI 选项启用这两个功能。
尝试虚拟线程和结构并发所需的 VM 标志是:
--enable-preview --add-modules jdk .incubator .concurrent
与开头段落联系起来,修订版 20 肯定会在设置下一个版本时朝着正确的方向前进,修订版 21 将于 2023 年晚些时候发布,它将获得长期支持,以成功顺利地将期待已久的 Project Loom 功能引入主流 Java 代码、库和框架生态系统。
标签: 编程,Java,Project Loom 来源: