Skip to content

JEP 446: Scoped Values (Preview) | 作用域值(预览)

摘要

引入 作用域值,即可以安全且高效地共享给方法而无需使用方法参数的值。它们比线程局部变量更受青睐,尤其是在使用大量虚拟线程时。这是一个 预览 API

实际上,作用域值是一个 隐式方法参数。就好像在一系列调用中的每个方法都有一个额外的、不可见的参数。没有一个方法声明这个参数,并且只有能够访问作用域值对象的方法才能访问其值(数据)。作用域值使得可以通过一系列中间方法将数据从调用者安全地传递到遥远的被调用者,这些中间方法不声明数据的参数并且无法访问数据。

历史

作用域值在 JDK 20 中通过 JEP 429 进行孵化。在 JDK 21 中,这个特性不再处于孵化阶段;相反,它是一个 预览 API

目标

  • 易用性——提供一种编程模型,以便在一个线程内以及与子线程共享数据,从而简化对数据流的推理。

  • 易理解性——从代码的语法结构中使共享数据的生命周期可见。

  • 健壮性——确保调用者共享的数据只能被合法的被调用者检索。

  • 性能——允许共享数据是不可变的,以便允许大量线程共享,并实现运行时优化。

非目标

  • 改变 Java 编程语言不是目标。

  • 要求从线程局部变量迁移或弃用现有的 ThreadLocal API 不是目标。

动机

Java 应用程序和库被构建为包含方法的类的集合。这些方法通过方法调用进行通信。

大多数方法允许调用者通过将数据作为参数传递来将数据传递给方法。当方法 A 希望方法 B 为它做一些工作时,它用适当的参数调用 B,并且 B 可能将其中一些参数传递给 C 等等。B 可能不仅需要在其参数列表中包含 B 直接需要的东西,还需要包含 B 必须传递给 C 的东西。例如,如果 B 要设置并执行数据库调用,它可能希望传入一个连接,即使 B 不会直接使用该连接。

大多数时候,这种“传递你的间接被调用者需要的东西”方法是共享数据的最有效和最方便的方式。然而,有时在初始调用中传递每个间接被调用者可能需要的所有数据是不切实际的。

一个例子

在大型 Java 程序中,将控制从一个组件(“框架”)转移到另一个(“应用程序代码”)然后再返回是一种常见模式。例如,一个 Web 框架可以接受传入的 HTTP 请求,然后调用一个应用程序处理程序来处理它。应用程序处理程序可能然后调用框架从数据库读取数据或调用其他 HTTP 服务。

java
@Override
public void handle(Request request, Response response) { // 用户代码;由框架调用
   ...
    var userInfo = readUserInfo();
   ...
}

private UserInfo readUserInfo() {
    return (UserInfo)framework.readKey("userInfo", context);// 调用框架
}

框架可能需要维护一个 FrameworkContext 对象,其中包含已认证的用户 ID、事务 ID 等,并将其与当前事务相关联。所有框架操作都使用上下文对象,但它被用户代码未使用(并且与之无关)。

实际上,框架希望将其内部上下文从其 serve 方法(它调用用户的 handle 方法)传递到其 readKey 方法:

console
4. Framework.readKey <--------+ 使用上下文
3. Application.readUserInfo   |
2. Application.handle         |
1. Framework.serve  ----------+ 创建上下文

最简单的方法是将对象作为参数传递给调用链中的所有方法:

java
@Override
void handle(Request request, Response response, FrameworkContext context) {
   ...
    var userInfo = readUserInfo(context);
   ...
}

private UserInfo readUserInfo(FrameworkContext context) {
    return (UserInfo)framework.readKey("userInfo", context);
}

用户代码无法 协助 正确处理上下文对象。在最坏的情况下,它可能会通过混淆上下文来干扰;在最好的情况下,它需要为所有可能最终回调到框架的方法添加另一个参数的负担。如果在框架的重新设计期间出现传递上下文的需求,添加它不仅需要直接客户端——那些直接调用框架方法的用户方法或那些直接被框架调用的方法——更改其签名,而且所有中间方法也需要更改,即使上下文是框架的内部实现细节并且用户代码不应该与之交互。

线程局部变量用于共享

开发人员传统上使用在 Java 1.2 中引入的 线程局部变量 来帮助在调用栈上的方法之间共享数据而无需诉诸于方法参数。线程局部变量是类型为 ThreadLocal 的变量。尽管看起来像一个普通变量,但线程局部变量每个线程有一个当前值;使用的特定值取决于哪个线程调用其 getset 方法来读取或写入其值。一个线程中的代码自动读取和写入其值,而另一个线程中的代码自动读取和写入其自己不同的实例化。通常,线程局部变量被声明为 final static 字段,以便可以从不同的方法轻松访问,并且是 private 的,以便客户端(用户)代码不能直接访问它。

这里是一个示例,说明两个框架方法,都在同一个请求处理线程中运行,如何使用线程局部变量来共享一个 FrameworkContext

框架声明一个线程局部变量 CONTEXT(1)。当 Framework.serve 在请求处理线程中执行时,它将一个合适的 FrameworkContext 写入线程局部变量(2),然后调用用户代码。如果用户代码调用 Framework.readKey,该方法读取线程局部变量(3)以获得请求处理线程的 FrameworkContext

java
public class Framework {
    private final Application application;
    public Framework(Application app) { this.application = app; }

    private final static ThreadLocal<FrameworkContext> CONTEXT
                       = new ThreadLocal<>();  // (1)

    void serve(Request request, Response response) {
        var context = createContext(request);
        CONTEXT.set(context);                  // (2)
        Application.handle(request, response);
    }

    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();            // (3)
        var db = getDBConnection(context);
        db.readKey(key);
    }
}

使用线程局部变量避免了在框架调用用户代码以及用户代码回调框架方法时将 FrameworkContext 作为方法参数传递的需要。线程局部变量充当一个隐藏的方法参数:一个在 Framework.serve 中调用 CONTEXT.set 然后在 Framework.readKey 中调用 CONTEXT.get 的线程将自动看到其自己的 CONTEXT 变量的本地副本。实际上,ThreadLocal 字段充当一个键,用于查找当前线程的 FrameworkContext 值。

虽然 ThreadLocals 在每个线程中有不同的实例化,但如果使用 InheritableThreadLocal 类而不是 ThreadLocal 类,当前线程创建的另一个线程可以自动继承当前线程中设置的线程局部变量的值。

线程局部变量的问题

不幸的是,线程局部变量有许多无法避免的设计缺陷:

  • 无约束的可变性——每个线程局部变量都是可变的:任何可以调用线程局部变量的 get() 方法的代码都可以在任何时候调用该变量的 set 方法。即使线程局部变量中的对象由于其每个字段都被声明为 final 而不可变,这仍然是正确的。ThreadLocal API 允许这样做是为了支持完全通用的通信模型,其中数据可以在方法之间以任何方向流动。这可能导致像意大利面条一样的数据流,并导致在程序中很难辨别哪个方法更新共享状态以及以什么顺序更新。如上面的例子所示,更常见的需求是从一个方法到其他方法的简单单向数据传输。

  • 无界的生命周期——一旦通过 set 方法设置了线程的线程局部变量的副本,其设置的值将在线程的生命周期内保留,或者直到线程中的代码调用 remove 方法。不幸的是,开发人员经常忘记调用 remove(),所以每个线程的数据经常被保留得比必要的时间更长。特别是,如果使用线程池,在一个任务中设置的线程局部变量的值如果没有正确清除,可能会意外地泄漏到一个不相关的任务中,潜在地导致危险的安全漏洞。此外,对于依赖于线程局部变量的无约束可变性的程序,可能没有一个明确的点,在该点线程调用 remove() 是安全的;这可能导致长期的内存泄漏,因为每个线程的数据在线程退出之前不会被垃圾回收。如果每个线程的数据的写入和读取发生在线程执行的有界时间段内,避免泄漏的可能性,那就更好了。

  • 昂贵的继承——当使用大量线程时,线程局部变量的开销可能更糟,因为父线程的线程局部变量可以被子线程继承。(实际上,线程局部变量不是一个线程本地的。)当开发人员选择创建一个继承线程局部变量的子线程时,子线程必须为父线程中先前写入的每个线程局部变量分配存储。这可能会增加显著的内存占用。子线程不能共享父线程使用的存储,因为 ThreadLocal API 要求在一个线程中更改线程局部变量的副本在其他线程中不可见。这是不幸的,因为在实践中,子线程很少在其继承的线程局部变量上调用 set 方法。

迈向轻量级共享

随着虚拟线程(JEP 425)的出现,线程局部变量的问题变得更加紧迫。虚拟线程是由 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,允许有非常大量的虚拟线程。除了数量丰富之外,虚拟线程足够便宜,可以表示任何并发行为单元。这意味着一个 Web 框架可以为处理请求的任务专门分配一个新的虚拟线程,并且仍然能够同时处理数千或数百万个请求。在正在进行的示例中,方法 Framework.serveApplication.handleFramework.readKey 在处理每个传入请求时都将在一个新的虚拟线程中执行。

对于这些方法来说,无论它们在虚拟线程还是传统平台线程中执行,能够共享数据都是有用的。因为虚拟线程是 Thread 的实例,所以虚拟线程可以有线程局部变量;实际上,虚拟线程的短暂的 非池化 性质使得上面提到的长期内存泄漏问题不那么严重。(当一个线程快速终止时,调用线程局部变量的 remove() 方法是不必要的,因为终止会自动删除其线程局部变量。)然而,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,那么内存占用可能会很大。

总之,线程局部变量具有比通常共享数据所需的更多复杂性,并且具有无法避免的显著成本。Java 平台应该提供一种方法来为数千或数百万个虚拟线程维护可继承的每个线程数据。如果这些每个线程变量是不可变的,它们的数据可以被子线程高效地共享。此外,这些每个线程变量的生命周期应该是有界的:一旦最初共享数据的方法完成,通过每个线程变量共享的任何数据都应该变得不可用。

描述

一个 作用域值 允许在一个大型程序中的方法之间安全且高效地共享数据,而无需诉诸于方法参数。它是类型为 ScopedValue 的变量。通常,它被声明为 final static 字段,以便可以从许多方法轻松访问。

像线程局部变量一样,一个作用域值有多个与之相关联的值,每个线程一个。使用的特定值取决于哪个线程调用其方法。与线程局部变量不同,作用域值只写入一次,并且仅在线程执行的有界时间段内可用。

作用域值的使用如下所示。一些代码调用 ScopedValue.where,提供一个作用域值和它要绑定的对象。对 run 的调用 绑定 了作用域值,提供一个特定于当前线程的副本,然后执行作为参数传递的 lambda 表达式。在 run 调用的生命周期内,lambda 表达式或从该表达式直接或间接调用的任何方法可以通过值的 get() 方法读取作用域值。在 run 方法完成后,绑定被销毁。

java
final static ScopedValue<...> V = ScopedValue.newInstance();

// 在某个方法中
ScopedValue.where(V, <value>)
          .run(() -> {... V.get()... 调用方法... });

// 在从 lambda 表达式直接或间接调用的方法中
... V.get()...

代码的结构描绘了一个线程可以读取其作用域值副本的时间段。这个有界的生命周期极大地简化了对线程行为的推理。从调用者到被调用者(直接和间接)的单向数据传输一目了然。没有 set 方法允许遥远的代码在任何时候更改作用域值。这也有助于性能:使用 get() 读取作用域值通常与读取局部变量一样快,无论调用者和被调用者之间的栈距离如何。

“作用域”的含义

一个事物的 作用域 是它存在的空间——它可以被使用的范围或区间。例如,在 Java 编程语言中,变量声明的作用域是程序文本中可以使用简单名称引用该变量的空间(JLS 6.3)。这种作用域更准确地称为 词法作用域静态作用域,因为变量的作用域空间可以通过在程序文本中查找 {} 字符静态地理解。

另一种作用域称为 动态作用域。一个事物的动态作用域是指程序在执行时可以使用该事物的部分。如果方法 a 调用方法 b,而方法 b 又调用方法 c,那么 c 的执行生命周期包含在 b 的执行中,而 b 的执行又包含在 a 的执行中,即使这三个方法是不同的代码单元:

console
  |
  |   +–– a
  |   |
  |   |  +–– b
  |   |  |
TIME  |  |  +–– c
  |   |  |  |
  |   |  |  |__
  |   |  |
  |   |  |__
  |   |
  |   |__
  |
  v

这是 作用域值 所吸引的概念,因为在 run 方法中绑定一个作用域值 V 会产生一个在程序执行时某些部分可以访问的值,即由 run 直接或间接调用的方法。

这些方法的展开执行定义了一个动态作用域;绑定在这些方法的执行期间在作用域内,而在其他任何地方都不在作用域内。

使用作用域值的 Web 框架示例

前面展示的框架代码可以很容易地重写为使用作用域值而不是线程局部变量。在(1)处,框架声明一个作用域值而不是线程局部变量。在(2)处,serve 方法调用 ScopedValue.whererun 而不是线程局部变量的 set 方法。

java
class Frameowrk {
    private final static ScopedValue<FrameworkContext> CONTEXT
                        = ScopedValue.newInstance();   // (1)

    void serve(Request request, Response response) {
        var context = createContext(request);
        ScopedValue.where(CONTEXT, context)            // (2)
                  .run(() -> Application.handle(request, response));
    }

    public PersistedObject readKey(String key) {
        var context = CONTEXT.get();            // (3)
        var db = getDBConnection(context);
        db.readKey(key);
    }

   ...
}

whererun 一起提供了从 serve 方法到 readKey 方法的单向数据共享。传递给 where 的作用域值在 run 调用的生命周期内绑定到相应的对象,因此在从 run 调用的任何方法中的 CONTEXT.get() 将读取该值。因此,当 Framework.serve 调用用户代码,并且用户代码调用 Framework.readKey 时,从作用域值读取的值(3)是 Framework.serve 在该线程中早些时候写入的值。

run 建立的绑定仅在从 run 调用的代码中可用。如果 CONTEXT.get() 出现在 Framework.serve 中对 run 的调用之后,将会抛出异常,因为 CONTEXT 在该线程中不再绑定。

和以前一样,框架依赖于 Java 的访问控制来限制对其内部数据的访问:CONTEXT 字段具有私有访问权限,这允许框架在其两个方法之间内部共享信息。该信息对于用户代码是不可访问的并且是隐藏的。我们说 ScopedValue 对象是一个 能力 对象,它给予具有权限访问它的代码绑定或读取值的能力。通常,ScopedValue 将具有私有访问权限,但有时它可能具有受保护的或包级访问权限,以允许多个协作类读取和绑定值。

重新绑定作用域值

作用域值没有 set() 方法意味着调用者可以使用作用域值可靠地将一个常量值传达给同一线程中的被调用者。然而,在某些情况下,其中一个被调用者可能需要使用相同的作用域值将不同的值传达给它自己的被调用者。ScopedValue API 允许为后续调用建立一个新的嵌套绑定:

java
private static final ScopedValue<String> X = ScopedValue.newInstance();

void foo() {
    ScopedValue.where(X, "hello").run(() -> bar());
}

void bar() {
    System.out.println(X.get()); // 打印 hello
    ScopedValue.where(X, "goodbye").run(() -> baz());
    System.out.println(X.get()); // 打印 hello
}

void baz() {
    System.out.println(X.get()); // 打印 goodbye
}

bar 读取 X 的值为 "hello",因为这是在 foo 中建立的作用域中的绑定。但是然后 bar 建立一个嵌套作用域来运行 baz,在那里 X 绑定到 "goodbye"

请注意,"goodbye" 绑定仅在嵌套作用域内有效。一旦 baz 返回,bar 看到 "hello" 绑定。bar 的主体不能改变该方法本身看到的绑定,但可以改变其被调用者看到的绑定。这保证了新值共享的有界生命周期。

继承作用域值

Web 框架示例为处理每个请求专门分配一个线程,因此同一个线程先执行一些框架代码,然后执行应用程序开发者的用户代码,接着再执行更多框架代码以访问数据库。然而,用户代码可以利用虚拟线程的轻量级特性,通过创建自己的虚拟线程并在其中运行自己的代码。这些虚拟线程将是请求处理线程的子线程。

在请求处理线程中运行的代码共享的上下文数据需要对在子线程中运行的代码可用。否则,当在子线程中运行的用户代码调用框架方法时,它将无法访问由在请求处理线程中运行的框架代码创建的 FrameworkContext。为了实现跨线程共享,作用域值可以被子线程继承。

用户代码创建虚拟线程的首选机制是结构化并发 API(JEP 428),特别是类 StructuredTaskScope。父线程中的作用域值会被使用 StructuredTaskScope 创建的子线程自动继承。子线程中的代码可以使用在父线程中为作用域值建立的绑定,且开销最小。与线程局部变量不同,父线程的作用域值绑定不会被复制到子线程。

下面是一个在用户代码中幕后发生作用域值继承的示例。Server.serve 方法像以前一样绑定 CONTEXT 并调用 Application.handle。然而,Application.handle 中的用户代码使用 StructuredTaskScope.fork 并发地运行 readUserInfo()fetchOffers() 方法,每个方法在自己的虚拟线程中(1、2)。每个方法可能会使用 Framework.readKey,它像以前一样查询作用域值 CONTEXT(4)。这里不讨论用户代码的更多细节;有关更多信息,请参见 JEP 428

java
@Override
public Response handle(Request request, Response response) {
      try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
          Supplier<UserInfo>    user   = scope.fork(() -> readUserInfo()); // (1)
          Supplier<List<Offer>> offers = scope.fork(() -> fetchOffers());   // (2)
          scope.join().throwIfFailed();  // 等待两个分支
          return new Response(user.get(), order.get());
      } catch (Exception ex) {
          reportError(response, ex);
      }
}

StructuredTaskScope.fork 确保在请求处理线程中建立的作用域值 CONTEXT 的绑定——在 Framework.serve(...)——被子线程中的 CONTEXT.get() 读取。下面的图展示了绑定的动态作用域如何扩展到在子线程中执行的所有方法:

console
线程 1                        线程 2
--------                        --------
                                5. Framework.readKey <----------+
                                                                |
                                                              CONTEXT
                                4. Application.readUserInfo     |
3. StructuredTaskScope.fork                                     |
2. Application.handle                                           |
1. Server.serve     --------------------------------------------+

StructuredTaskScope 提供的 fork/join 模型意味着绑定的动态作用域仍然由对 ScopedValue.where(...).run(...) 的调用的生命周期界定。在子线程运行期间,Principal 将保持在作用域内,并且 scope.join() 确保子线程在 run 返回之前终止,从而销毁绑定。这避免了使用线程局部变量时出现的无界生命周期问题。像 ForkJoinPool 这样的传统线程管理类不支持作用域值的继承,因为它们不能保证从某个父线程作用域分叉出的子线程会在父线程离开该作用域之前退出。

迁移到作用域值

在当今使用线程局部变量的许多场景中,作用域值可能是有用且更可取的。除了充当隐藏方法参数之外,作用域值可能有助于:

  • 可重入代码——有时检测递归是可取的,也许是因为框架不可重入或者因为递归必须以某种方式受到限制。作用域值提供了一种实现方法:像往常一样使用 ScopedValue.whererun 进行设置,然后在调用栈深处,调用 ScopedValue.isBound() 来检查它是否对当前线程有绑定。更复杂地,作用域值可以通过反复重新绑定来模拟递归计数器。

  • 嵌套事务——在扁平化事务的情况下,检测递归也很有用:在事务进行期间启动的任何事务都成为最外层事务的一部分。

  • 图形上下文——另一个例子出现在图形中,在程序的各个部分之间通常需要共享一个绘图上下文。由于作用域值的自动清理和可重入性,它们比线程局部变量更适合这种情况。

一般来说,当线程局部变量的目的与作用域值的目标一致时,我们建议迁移到作用域值:单向传输不变的数据。如果一个代码库以双向方式使用线程局部变量——在调用栈深处的被调用者通过 ThreadLocal.set 将数据传输给遥远的调用者——或者以完全无结构的方式使用,那么迁移就不是一个选项。

有一些场景更倾向于线程局部变量。一个例子是缓存创建和使用成本很高的对象,例如 java.text.DateFormat 的实例。众所周知,DateFormat 对象是可变的,因此如果不同步,它不能在线程之间共享。通过在整个线程的生命周期中持续存在的线程局部变量为每个线程提供自己的 DateFormat 对象通常是一种实用的方法。

替代方案

可以使用线程局部变量模拟作用域值的许多特性,尽管在内存占用、安全性和性能方面会有一些成本。

我们尝试了一个支持作用域值一些特性的修改版 ThreadLocal。然而,携带线程局部变量的额外负担会导致实现过于繁重,或者 API 在其大部分核心功能上返回 UnsupportedOperationException,或者两者兼而有之。因此,最好不要修改 ThreadLocal,而是将作用域值作为一个完全独立的概念引入。

作用域值的灵感来自许多 Lisp 方言为动态作用域自由变量提供支持的方式;特别是,在像 Interlisp-D 这样的深度绑定、多线程运行时中,这样的变量是如何表现的。作用域值通过添加类型安全、不可变性、封装以及在线程内部和跨线程的高效访问来改进 Lisp 的自由变量。