跳到主要内容

JEP 464:作用域值(第二次预览)

QWen Max 中英对照 JEP 464: Scoped Values (Second Preview)

总结

引入了作用域值(scoped values),它能够以受控的方式共享不可变数据,既可以在同一线程的子帧之间共享,也可以在子线程之间共享。与线程局部变量相比,作用域值更容易推理,并且具有更低的空间和时间开销,尤其是在与虚拟线程(Virtual Threads)结构化并发(Structured Concurrency)结合使用时。这是一个预览 API

历史

作用域值通过 JEP 429 在 JDK 20 中孵化,并通过 JEP 446 在 JDK 21 中成为预览 API。我们在此提议在 JDK 22 中重新预览该 API,不做任何更改,以获取更多的经验与反馈。

目标

  • 易用性 — 应该易于推断数据流。

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

  • 鲁棒性 — 调用者共享的数据只能被合法的被调用者检索。

  • 性能 — 数据可以高效地在大量线程之间共享。

非目标

  • 不以改变 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() {
return (UserInfo)framework.readKey("userInfo", context);// call framework
}

该框架可能会维护一个 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 方法来读取或写入其值。通常,线程局部变量被声明为 final static 字段,并将其访问权限设置为私有,从而将共享限制在单个类或来自单一代码库的一组类的实例中。

以下示例展示了两个框架方法如何在同一个请求处理线程中使用线程本地变量来共享 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 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 444)的出现,线程局部变量的问题变得更加紧迫。虚拟线程是由 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许存在大量的虚拟线程。除了数量庞大之外,虚拟线程的成本也足够低,可以表示任何并发的行为单元。这意味着一个 Web 框架可以为每个请求处理任务分配一个新的虚拟线程,同时仍然能够一次处理成千上万甚至百万级别的请求。在当前的示例中,方法 Framework.serveApplication.handleFramework.readKey 都会在每个传入请求的全新虚拟线程中执行。

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

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

描述

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

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

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

final static 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() ...

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

“scoped” 的含义

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

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

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

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

这些方法的展开执行定义了一个动态范围;该绑定在这些方法的执行期间处于范围内,在其他任何地方都不在范围内。

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

前面展示的框架代码可以轻松重写为使用作用域值(scoped value)而不是线程局部变量(thread-local variable)。在 (1) 处,框架声明了一个作用域值而不是线程局部变量。在 (2) 处,serve 方法调用了 ScopedValue.whererun,而不是线程局部变量的 set 方法。

class Framework {
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 调用的代码中使用。如果在调用 run 之后,CONTEXT.get() 出现在 Framework.serve 中,则会抛出异常,因为 CONTEXT 在该线程中已不再绑定。

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

重新绑定作用域值

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

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

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

void bar() {
System.out.println(X.get()); // prints hello
ScopedValue.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 恢复为未绑定状态。这种嵌套确保了新值共享的生命周期是有限的。

继承作用域值

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

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

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

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

@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 确保了在请求处理线程中对作用域值 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.where(...).run(...) 调用生命周期的限制。在子线程运行期间,Principal 会一直保持在范围内,而 scope.join() 确保了子线程会在 run 返回之前终止,从而销毁绑定。这避免了使用线程局部变量时出现的无限制生命周期问题。像 ForkJoinPool 这样的传统线程管理类不支持作用域值的继承,因为它们无法保证从某个父线程作用域分叉出的子线程会在父线程离开该作用域之前退出。

迁移到作用域值

在许多当前使用线程局部变量的场景中,作用域值可能非常有用并且是更优的选择。除了充当隐藏的方法参数外,作用域值还可以帮助实现:

  • 可重入代码 — 有时检测递归是很有必要的,可能是因为某个框架不是可重入的,或者因为递归必须以某种方式进行限制。作用域值(Scoped Value)提供了一种实现方式:像平常一样使用 ScopedValue.whererun 进行设置,然后在调用栈深处,调用 ScopedValue.isBound() 来检查当前线程是否绑定了该值。更复杂的情况下,可以通过重复绑定,使作用域值模拟一个递归计数器。

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

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

通常而言,当线程局部变量的用途与作用域值的目标一致时(即单向传递不变的数据),我们建议迁移到作用域值。如果代码库以双向方式使用线程局部变量(例如,调用栈深处的被调用者通过 ThreadLocal.set 向远处的调用者传递数据),或者以完全非结构化的方式使用,则迁移是不可行的。

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

ScopedValue API

完整的 ScopedValue API 比上面描述的小子集更加丰富。虽然本 JEP 仅展示了使用 ScopedValue<V>.where(V, <value>).run(aRunnable) 的示例,但绑定作用域值的方式还有更多。例如,API 还提供了一个 Callable 版本,该版本返回一个值并且可能抛出一个 Exception

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

此外,绑定方法还有简写版本。例如,ScopedValue<V>.runWhere(V, <value>, aRunnable)ScopedValue<V>.where(V, <value>).run(aRunnable) 的简写形式。虽然这种简写形式有时很方便,但它一次只允许绑定一个作用域值。

完整的 Scoped Value API 可以在这里找到。

替代方案

虽然在线程局部变量的内存占用、安全性和性能方面会有一些损失,但可以使用线程局部变量来模拟范围值的许多特性。

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

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