JEP 506:作用域值

原文:JEP 506- Scoped Values
作者:
日期:2025-10-26

作者安德鲁·黑利(Andrew Haley)、安德鲁·丁恩(Andrew Dinn)
所有者安德鲁·黑利(Andrew Haley)
类型特性
范围SE
状态已关闭 / 已交付
发布版本25
组件核心库
讨论组loom - dev@openjdk.org
相关内容JEP 487:作用域值(第四次预览)
审核人艾伦·贝特曼(Alan Bateman)
批准人保罗·桑多兹(Paul Sandoz)
创建时间2025 年 3 月 24 日 10:10
更新时间2025 年 6 月 6 日 13:57
问题编号8352695

摘要

引入“作用域值”,它使方法能够在线程内与其被调用方法共享不可变数据,并且能与子线程共享。作用域值比线程局部变量更易于理解。它们在空间和时间成本上也更低,尤其是与虚拟线程(JEP 444)和结构化并发(JEP 505)一起使用时。

历史

作用域值 API 最初由 JEP 429(JDK 20)提议进行孵化,后由 JEP 446(JDK 21)提议进行预览,随后在 JEP 464(JDK 22)、JEP 481(JDK 23)以及 JEP 487(JDK 24)中得到改进和完善。

我们在此提议在 JDK 25 中确定作用域值 API,有一处小改动:ScopedValue.orElse 方法不再接受 null 作为参数。

目标

  • 易用性 — 数据流应该易于理解。
  • 可理解性 — 共享数据的生命周期应该从代码的语法结构中清晰可见。
  • 健壮性 — 调用者共享的数据应该只能由合法的被调用者检索。
  • 性能 — 数据应该能够在大量线程间高效共享。

非目标

  • 无意更改 Java 编程语言。
  • 无意要求从线程局部变量迁移,或弃用现有的 ThreadLocal API。

动机

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

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

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

一个示例

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

@Override
public void handle(Request request, Response response) {
    // 用户代码,由框架调用
   ...
    var userInfo = readUserInfo();
   ...
}
private UserInfo readUserInfo() {
    // 调用框架
    return (UserInfo)framework.readKey("userInfo", context);
}
  

框架可能会维护一个 FrameworkContext 对象,其中包含经过身份验证的用户 ID、事务 ID 等,并将其与当前事务相关联。所有框架操作都使用 FrameworkContext 对象,但用户代码不使用(且与用户代码无关)。

实际上,框架必须能够将其内部上下文从其 serve 方法(该方法调用用户的 handle 方法)传递到其 readKey 方法:

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

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

@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 方法来读取或写入其值。通常,线程局部变量被声明为 static final 字段,并且其访问权限设置为 private,这样共享就被限制在单个代码库中的单个类或一组类的实例中。

以下是两个框架方法(都在同一个请求处理线程中运行)如何使用线程局部变量来共享 FrameworkContext 的示例。框架声明了一个线程局部变量 CONTEXT(1)。当 Framework.serve 在请求处理线程中执行时,它将合适的 FrameworkContext 写入线程局部变量(2),然后调用用户代码。如果用户代码调用 Framework.readKey,该方法会读取线程局部变量(3)以获取请求处理线程的 FrameworkContext

public class Framework {
    private final Application application;
    public Framework(Application app) { this.application = app; }
    private static final 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 值。

虽然 ThreadLocal 在每个线程中都设置有不同的值,但当前线程中当前设置的值可以通过使用 InheritableThreadLocal 类而非 ThreadLocal 类,由当前线程创建的另一个线程自动继承。

线程局部变量的问题

不幸的是,线程局部变量存在三个内在的设计缺陷。

  • 无约束的可变性 — 每个线程局部变量都是可变的:任何能够调用线程局部变量 get 方法的代码,都可以随时调用该变量的 set 方法。即使由于线程局部变量中的对象的每个字段都声明为 final 而使其本身不可变,情况依然如此。ThreadLocal API 允许这样做,是为了支持一个完全通用的通信模型,在这个模型中数据可以在方法之间任意流动。这可能导致数据流动像意大利面条一样混乱,使得在程序中很难分辨哪个方法以何种顺序更新共享状态。如上面示例所示,更常见的需求是数据从一个方法到其他方法的简单单向传输。
  • 无限制的生命周期 — 一旦通过 set 方法设置了线程局部变量的线程副本,设置的值将在线程的生命周期内保留,或者直到线程中的代码调用 remove 方法。不幸的是,开发者常常忘记调用 remove,所以每个线程的数据往往保留的时间比必要的时间长。特别是,如果使用线程池,在一个任务中设置的线程局部变量的值,如果没有正确清除,可能会意外泄漏到不相关的任务中,这可能会导致严重的安全漏洞。此外,对于依赖线程局部变量无约束可变性的程序,可能没有明确的安全点让线程调用 remove;这可能会导致长期的内存泄漏,因为每个线程的数据在线程退出之前不会被垃圾回收。如果每个线程的数据的写入和读取发生在线程执行期间的有限时间段内,避免泄漏的可能性,那将会更好。
  • 昂贵的继承开销 — 当使用大量线程时,线程局部变量的开销可能会更严重,因为父线程的线程局部变量可以被子线程继承。(实际上,线程局部变量并非只局限于一个线程。)当开发者选择创建一个继承线程局部变量的子线程时,子线程必须为父线程中先前写入的每个线程局部变量分配存储空间。这会显著增加内存占用。子线程不能共享父线程使用的存储空间,因为 ThreadLocal API 要求更改一个线程的线程局部变量副本,在其他线程中不可见。这很遗憾,因为实际上子线程很少对其继承的线程局部变量调用 set 方法。

迈向轻量级共享

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

对于这些方法而言,无论它们在虚拟线程还是传统平台线程中执行,能够共享数据都是很有用的。由于虚拟线程是 Thread 的实例,虚拟线程可以拥有线程局部变量;实际上,虚拟线程短暂且 [非池化](https://openjdk.org/jeps/444#Do - not - pool - virtual - threads) 的特性,使得上述长期内存泄漏的问题不那么严重。(当线程快速终止时,无需调用线程局部变量的 remove 方法,因为终止会自动移除其线程局部变量。)然而,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,内存占用可能会很显著。

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

描述

“作用域值”是一个容器对象,它允许一个数据值在同一线程内被一个方法及其直接和间接被调用方法安全且高效地共享,并且能与子线程共享,而无需借助方法参数。它是类型为 ScopedValue 的变量。它通常被声明为 static final 字段,其访问权限设置为 private,这样其他类中的代码就无法直接访问它。

与线程局部变量类似,作用域值有多个与之关联的值,每个线程一个。所使用的具体值取决于哪个线程调用其方法。与线程局部变量不同的是,作用域值只写入一次,并且仅在线程执行期间的有限时间段内可用。

作用域值的使用方式如下。某些代码调用 ScopedValue.where,传入一个作用域值以及要绑定的对象。对 run 方法的链式调用会“绑定”该作用域值,提供一个特定于当前线程的副本,然后运行作为参数传递的 lambda 表达式。在 run 调用的生命周期内,该 lambda 表达式或从该表达式直接或间接调用的任何方法,都可以通过该值的 get 方法读取作用域值。run 方法结束后,绑定将被销毁。

static final ScopedValue<...> NAME = ScopedValue.newInstance();
// 在某个方法中:
ScopedValue.where(NAME, <value>).run(() -> { ... NAME.get() ... 调用方法 ... });
// 在从 lambda 表达式直接或间接调用的方法中:
... NAME.get() ...
  

代码结构划定了线程可以读取其作用域值副本的时间段。这种有限的生命周期极大地简化了对线程行为的理解。从调用者到直接和间接被调用者的数据单向传输一目了然。不存在 set 方法让其他地方的代码随时更改作用域值。这也有助于提高性能:使用 get 读取作用域值的速度通常与读取局部变量一样快,无论调用者和被调用者之间的栈距离有多远。

在接下来的示例中,我们假设 ScopedValue.where 已被静态导入,如下所示:

import static java.lang.ScopedValue.where;
  

这使我们可以将 ScopedValue.where(NAME, <value>).run(...) 缩短为 where(NAME, <value>).run(...)

“作用域”的含义

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

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

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

这就是“作用域值”所依据的概念,因为在 run(或 call)方法中绑定一个作用域值 V 会生成一个值,在程序执行时,该值可被程序的某些部分访问,即由 runcall 直接或间接调用的方法。

这些方法的逐步执行定义了一个动态作用域;绑定在这些方法的执行期间有效,其他地方无效。

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

前面展示的框架代码可以很容易地重写为使用作用域值而不是线程局部变量:

class Framework {
    private static final ScopedValue<FrameworkContext> CONTEXT
                        = ScopedValue.newInstance();    // (1)
    void serve(Request request, Response response) {
        var context = createContext(request);
        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);
    }
}
  

在(1)处,框架声明了一个作用域值而不是线程局部变量。在(2)处,serve 方法调用 where... run 而不是线程局部变量的 set 方法。

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

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

与之前一样,框架依赖 Java 的访问控制来限制对其内部数据的访问:CONTEXT 字段具有私有访问权限,这使得框架能够在其两个方法之间内部共享信息。该信息对用户代码不可访问且隐藏。我们说 ScopedValue 对象是一个 [“能力”](https://en.wikipedia.org/wiki/Capability - based_security) 对象,它赋予有权限访问它的代码绑定或读取该值的能力。通常 ScopedValue 将具有 private 访问权限,但有时它可能具有 protected 或包访问权限,以允许多个协作类读取和绑定该值。

重新绑定作用域值

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

private static final ScopedValue<String> X = ScopedValue.newInstance();
void foo() {
   where(X, "hello").run(() -> bar());
}
void bar() {
    System.out.println(X.get()); // 打印 hello
    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 内部 X 的值就会恢复为 "hello"bar 的主体无法更改该方法自身看到的绑定,但可以更改其被调用者看到的绑定。foo 退出后,X 恢复为未绑定状态。这种嵌套保证了新值共享的有限生命周期。

继承作用域值

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

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

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

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

@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 确保在请求处理线程(在 Framework.serve 中)中对作用域值 CONTEXT 的绑定,能被子线程中的 CONTEXT.get 读取。以下图表展示了绑定的动态作用域如何扩展到子线程中执行的所有方法:

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

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

迁移到作用域值

在如今许多使用线程局部变量的场景中,作用域值可能会很有用且更受青睐。除了作为隐藏的方法参数,作用域值还可以在以下方面提供帮助:

  • 可重入代码 — 有时希望检测递归,这可能是因为框架不可重入,或者因为必须以某种方式限制递归。作用域值提供了一种实现此目的的方法:像往常一样使用 ScopedValue.run 进行设置,然后在调用栈深处,调用 ScopedValue.isBound 来检查它是否为当前线程建立了绑定。更复杂一点,作用域值可以通过重复重新绑定来模拟递归计数器。
  • 嵌套事务 — 在扁平事务的情况下,检测递归也可能有用:在一个事务进行过程中启动的任何事务都将成为最外层事务的一部分。
  • 图形上下文 — 另一个例子出现在图形处理中,程序的不同部分之间通常需要共享一个绘图上下文。由于作用域值具有自动清理和可重入性,它们比线程局部变量更适合这种情况。

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

有一些场景更适合使用线程局部变量。一个例子是缓存创建和使用成本较高的对象。例如,java.text.SimpleDateFormat 对象创建成本较高,而且众所周知它也是可变的,所以如果不同步,它们就不能在不同线程之间共享。因此,通过一个在线程生命周期内持续存在的线程局部变量,为每个线程提供其自己的 SimpleDateFormat 对象,通常是一种可行的方法。(不过如今,任何缓存 SimpleDateFormat 对象的代码都可以改用更新的 java.util.time.DateTimeFormatter,它可以存储在 static final 字段中并在不同线程之间共享。)

ScopedValue API

完整的 ScopedValue API 比上面描述的子集更丰富。上面我们只展示了使用 ScopedValue<V>.where(V, <value>).run(...) 的示例,但该 API 还提供了一个 call 方法,它返回一个值并且可能抛出异常:

try {
        var result = where(X, "hello").call(() -> bar());
        ... 使用result ...
    catch (Exception e) {
        handleFailure(e);
    }
        ...
  

此外,我们可以在调用点绑定多个作用域值:

where(X, v).where(Y, w).run(() -> ... );
  

此示例运行一个操作,其中 X 绑定(或重新绑定)到 vY 绑定(或重新绑定)到 w。这比嵌套调用 ScopedValue... where... run 更高效且更易读。

完整的作用域值 API 可以在 这里 找到。

替代方案

虽然在内存占用、安全性和性能方面会有一定代价,但可以使用线程局部变量来模拟作用域值的许多特性。

我们尝试过对 ThreadLocal 进行修改,使其支持作用域值的一些特性。然而,携带线程局部变量的额外负担会导致实现过度复杂,或者其核心功能的大部分 API 返回 UnsupportedOperationException,甚至两者皆有。因此,最好不要修改 ThreadLocal,而是将作用域值作为一个完全独立的概念引入。

我们还尝试过一种支持 AutoCloseable 接口的作用域值绑定版本,这样就可以在 try - with - resources 结构中使用。但我们最终放弃了这个想法,因为依赖用户代码在正确的时间调用 close 方法,无法保证正确的操作。而且,即使通过 try - with - resources 结构按设计调用了该方法,操作也可能会立即因 StackOverflowError 而失败,使程序处于不一致的状态。即使在出现 StackOverflowError 的情况下,完整性也比 try - with - resources 的便利性更重要。此 API 中 runcall 方法使用的函数式接口使我们能够在保证完整性的同时,仍然保持相当的易用性。

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