跳到主要内容

JEP 487: Scoped Values(第四版预览)

QWen Max 中英对照 JEP 487: Scoped Values (Fourth Preview)

概述

介绍作用域值,它使方法能够在单个线程内与其调用者以及子线程共享不可变数据。作用域值比线程局部变量更容易理解。它们还具有更低的空间和时间成本,特别是在与虚拟线程(JEP 444)和结构化并发(JEP 480)一起使用时。这是一个预览 API

历史

scoped values API 由 JEP 429(JDK 20)提议进行孵化,由 JEP 446(JDK 21)提议进行预览,并随后由 JEP 464(JDK 22)和 JEP 481(JDK 23)进行了改进和完善。

我们在此提议在 JDK 24 中再次预览该 API,以便获得更多的经验和反馈,并做出进一步的更改:

  • 我们从 ScopedValue 类中移除了 callWhererunWhere 方法,使 API 完全流畅。使用一个或多个绑定的 scoped values 的唯一方法是通过 ScopedValue.Carrier.callScopedValue.Carrier.run 方法。

目标

  • 易用性 — 应该易于理解数据流。

  • 可理解性 — 从代码的语法结构中应该可以明显看出共享数据的生命周期。

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

  • 性能 — 数据应该能够高效地在大量线程之间共享。

非目标

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

  • 不以要求迁移出线程局部变量,或弃用现有的 ThreadLocal API 为目标。

动机

Java 应用程序和库是作为类的集合来构建的,这些类中包含方法。这些方法通过方法调用来通信。

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

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

一个例子

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

@Override
public void handle(Request request, Response response) {
// user code, called by framework
...
var userInfo = readUserInfo();
...
}

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

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

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

4. Framework.readKey <--------+ use context
3. Application.readUserInfo |
2. Application.handle |
1. Framework.serve ----------+ create context

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

@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 值。

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

线程局部变量的问题

不幸的是,线程局部变量有三个固有的设计缺陷。

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

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

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

朝着轻量级共享迈进

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

如果这些方法无论是在虚拟线程还是传统的平台线程中执行,都能够共享数据,那将非常有用。因为虚拟线程是 Thread 的实例,所以虚拟线程可以有线程局部变量;事实上,虚拟线程的短生命周期和非池化特性使得上述长期内存泄漏的问题变得不那么严重。(当一个线程快速终止时,调用线程局部变量的 remove 方法是不必要的,因为终止会自动移除其线程局部变量。)然而,如果一百万个虚拟线程中的每一个都有自己的一份线程局部变量副本,那么内存占用可能会非常大。

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

描述

作用域值是一种容器对象,它允许方法在同一线程中与其直接和间接调用者以及子线程安全高效地共享数据值,而无需借助方法参数。它是一种类型为 ScopedValue 的变量。它通常被声明为 static final 字段,并将其可访问性设置为 private,以便其他类中的代码无法直接访问它。

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

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

static final ScopedValue<...> NAME = ScopedValue.newInstance();

// In some method:
ScopedValue.where(NAME, <value>).run(() -> { ... NAME.get() ... call methods ... });

// In a method called directly or indirectly from the lambda expression:
... NAME.get() ...

代码的结构界定了线程可以读取其 scoped value 副本的时间段。这种有界的生命周期极大地简化了对线程行为的推理。从调用者到被调用者(无论是直接还是间接)的数据单向传输一目了然。没有 set 方法可以让远处的代码在任何时候改变 scoped value。这也有助于提高性能:使用 get 读取 scoped value 通常和读取局部变量一样快,无论调用者和被调用者之间的堆栈距离有多远。

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

import static java.lang.ScopedValue.where;

这允许我们将 ScopedValue.where(NAME, <value>).run(...) 缩短为 where(NAME, <value>).run(...)

"scoped" 的含义

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

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

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

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

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

带有作用域值的 Web 框架示例

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

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 对象是一个功能对象,它使具有访问权限的代码能够绑定或读取该值。通常 ScopedValue 将具有 private 访问权限,但有时它可能具有 protected 或包访问权限,以允许多个协作类读取和绑定该值。

重新绑定作用域值

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

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

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

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

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

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

请注意,"goodbye" 绑定仅在嵌套的作用域内生效。一旦 baz 返回,bar 内部的 X 值将恢复为 "hello"bar 的主体不能更改该方法本身看到的绑定,但可以更改其被调用者看到的绑定。在 foo 退出后,X 恢复为未绑定状态。这种嵌套保证了新值共享的有限生命周期。

继承 scoped 值

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

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

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

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

@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(); // Wait for both forks
return new Response(user.get(), order.get());
} catch (Exception ex) {
reportError(response, ex);
}
}

StructuredTaskScope.fork 确保在请求处理线程中进行的 scoped value CONTEXT 的绑定 —— 在 Framework.serve 中 —— 能够被子线程中的 CONTEXT.get 读取。下图展示了该绑定的动态范围是如何扩展到子线程中执行的所有方法的:

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

StructuredTaskScope 提供的 fork/join 模型意味着绑定的动态范围仍然由对 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());
... use result ...
catch (Exception e) {
handleFailure(e);
}
...

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

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

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

完整的 scoped value API 可以在这里找到。

替代方案

可以使用线程局部变量来模拟限定范围值的许多特性,尽管这样做会在内存占用、安全性和性能方面付出一些代价。

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

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