JEP 481: Scoped Values (Third Preview) | 作用域值(第三次预览)
摘要
引入“作用域值”,它使一个方法能够在一个线程内与其被调用者以及子线程共享不可变数据。作用域值比线程局部变量更容易理解。它们还具有更低的空间和时间成本,特别是与虚拟线程(JEP 444)和结构化并发(JEP 480)一起使用时。这是一个 预览 API。
历史
作用域值 API 通过 JEP 429 在 JDK 20 中孵化,通过 JEP 446 在 JDK 21 中成为预览 API,并通过 JEP 464 在 JDK 22 中重新预览。
我们在此提议在 JDK 23 中重新预览该 API,以获得更多经验和反馈,有一个变化:
ScopedValue.callWhere
方法的操作参数类型现在是一个新的函数式接口,它允许 Java 编译器推断是否可能抛出已检查异常。有了这个变化,ScopedValue.getWhere
方法不再需要并被删除。
目标
- 易用性——应该很容易理解数据流。
- 可理解性——共享数据的生命周期应该从代码的语法结构中显而易见。
- 健壮性——调用者共享的数据应该只能由合法的被调用者检索。
- 性能——数据应该能够在大量线程之间高效共享。
非目标
- 改变 Java 编程语言不是目标。
- 不要求从线程局部变量迁移,也不打算弃用现有的
ThreadLocal
API。
动机
Java 应用程序和库被构建为包含方法的类的集合。这些方法通过方法调用进行通信。
大多数方法允许调用者通过将数据作为参数传递来将数据传递给方法。当方法 A 想要方法 B 为它做一些工作时,它用适当的参数调用 B,B 可能会将其中一些参数传递给 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
的变量。尽管看起来像一个普通变量,但线程局部变量每个线程有一个当前值;使用的特定值取决于哪个线程调用其 get
或 set
方法来读取或写入其值。通常,线程局部变量被声明为 final static
字段,并且其可访问性设置为 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 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.serve
、Application.handle
和 Framework.readKey
在处理每个传入请求时都会在一个新的虚拟线程中执行。
如果这些方法能够在虚拟线程或传统平台线程中执行时共享数据,那将是很有用的。因为虚拟线程是 Thread
的实例,所以虚拟线程可以有线程局部变量;实际上,虚拟线程的短暂、非池化 性质使得上面提到的长期内存泄漏问题不那么严重。(当一个线程快速终止时,调用线程局部变量的 remove
方法是不必要的,因为终止会自动删除其线程局部变量。)然而,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,那么内存占用可能会很大。
总之,线程局部变量比通常共享数据所需的复杂性更高,并且存在无法避免的重大成本。Java 平台应该提供一种方法来为数千或数百万个虚拟线程维护可继承的每个线程数据。如果这些每个线程变量是不可变的,它们的数据可以被子线程有效地共享。此外,这些每个线程变量的生命周期应该是有界的:一旦最初共享数据的方法完成,通过每个线程变量共享的任何数据都应该变得不可用。
描述
作用域值 是一个容器对象,它允许一个方法与其在同一线程中的直接和间接被调用者以及子线程安全有效地共享数据值,而无需使用方法参数。它是类型为 [ScopedValue](https://cr.openjdk.org/~alanb/sv-20240517/java.base/java/lang/ScopedValue.html)
的变量。它通常被声明为 final
static
字段,并且其可访问性设置为 private
,以便其他类中的代码不能直接访问它。
像线程局部变量一样,作用域值有多个与之关联的值,每个线程一个。使用的特定值取决于哪个线程调用其方法。与线程局部变量不同,作用域值只写入一次,并且在线程执行的一个有界时间段内可用。
如下所示使用作用域值。一些代码调用 ScopedValue.runWhere
,提供一个作用域值和它要绑定的对象。对 runWhere
的调用 绑定 了作用域值,提供了一个特定于当前线程的副本,然后执行作为参数传递的 lambda 表达式。在 runWhere
调用的生命周期内,lambda 表达式或从该表达式直接或间接调用的任何方法可以通过值的 get
方法读取作用域值。在 runWhere
方法完成后,绑定被销毁。
final static ScopedValue<...> NAME = ScopedValue.newInstance();
// 在某个方法中
ScopedValue.runWhere(NAME, <value>,
() -> {... NAME.get()... 调用方法... });
// 在从 lambda 表达式直接或间接调用的方法中
... NAME.get()...
代码的结构划定了线程可以读取其作用域值副本的时间段。这个有界的生命周期极大地简化了对线程行为的推理。从调用者到被调用者(包括直接和间接的)的单向数据传输一目了然。没有 set
方法允许远处的代码在任何时候更改作用域值。这也有助于提高性能:使用 get
读取作用域值通常与读取局部变量一样快,无论调用者和被调用者之间的栈距离如何。
“作用域”的含义
一个事物的 作用域 是它存在的空间——它可以被使用的范围或区间。例如,在 Java 编程语言中,变量声明的作用域是程序文本中可以使用简单名称引用该变量的空间(JLS §6.3)。这种作用域更准确地称为 词法作用域 或 静态作用域,因为变量的作用域空间可以通过在程序文本中查找 {
和 }
字符来静态理解。
另一种作用域称为 动态作用域。一个事物的动态作用域是指程序在执行时可以使用该事物的部分。如果方法 a
调用方法 b
,而方法 b
又调用方法 c
,那么 c
的执行生命周期包含在 b
的执行中,而 b
的执行又包含在 a
的执行中,即使这三个方法是不同的代码单元:
|
| +–– a
| |
| | +–– b
| | |
TIME | | +–– c
| | | |
| | | |__
| | |
| | |__
| |
| |__
|
v
这就是 作用域值 所引用的概念,因为在 runWhere
方法中绑定一个作用域值 V 会产生一个在程序执行时某些部分可以访问的值,即由 runWhere
直接或间接调用的方法。
这些方法的展开执行定义了一个动态作用域;在这些方法的执行期间,绑定是在作用域内的,而在其他任何地方都不在作用域内。
使用作用域值的 Web 框架示例
前面显示的框架代码可以很容易地重写为使用作用域值而不是线程局部变量。在(1)处,框架声明一个作用域值而不是线程局部变量。在(2)处,serve
方法调用 ScopedValue.runWhere
而不是线程局部变量的 set
方法。
class Framework {
private final static ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance(); // (1)
void serve(Request request, Response response) {
var context = createContext(request);
ScopedValue.runWhere(CONTEXT, context, // (2)
() -> Application.handle(request, response));
}
public PersistedObject readKey(String key) {
var context = CONTEXT.get(); // (3)
var db = getDBConnection(context);
db.readKey(key);
}
}
runWhere
方法提供了从 serve
方法到 readKey
方法的单向数据共享。传递给 runWhere
的作用域值在 runWhere
调用的生命周期内绑定到相应的对象,因此在从 runWhere
调用的任何方法中的 CONTEXT.get()
将读取该值。因此,当 Framework.serve
调用用户代码,并且用户代码调用 Framework.readKey
时,从作用域值(3)读取的值是 Framework.serve
在该线程中早些时候写入的值。
由 runWhere
建立的绑定仅在从 runWhere
调用的代码中可用。如果在对 runWhere
的调用之后,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.runWhere(X, "hello", () -> bar());
}
void bar() {
System.out.println(X.get()); // 打印 hello
ScopedValue.runWhere(X, "goodbye", () -> 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 480),特别是类 [StructuredTaskScope](https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/util/concurrent/StructuredTaskScope.html)
。父线程中的作用域值会被使用 StructuredTaskScope
创建的子线程自动继承。子线程中的代码可以使用在父线程中为作用域值建立的绑定,开销最小。与线程局部变量不同,父线程的作用域值绑定不会被复制到子线程。
这里是一个在用户代码中幕后发生作用域值继承的示例。Server.serve
方法绑定 CONTEXT
并像以前一样调用 Application.handle
。然而,Application.handle
中的用户代码使用 StructuredTaskScope.fork
(1,2)并发地运行 readUserInfo
和 fetchOffers
方法,每个方法在自己的虚拟线程中。每个方法可能使用 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(); // 等待两个分支
return new Response(user.get(), order.get());
} catch (Exception ex) {
reportError(response, ex);
}
}
StructuredTaskScope.fork
确保在请求处理线程中建立的作用域值 CONTEXT
的绑定——在 Framework.serve 中——被子线程中的 CONTEXT.get
读取。下面的图展示了绑定的动态作用域如何扩展到子线程中执行的所有方法:
线程 1 线程 2
-------- --------
5. Framework.readKey <----------+
|
CONTEXT
4. Application.readUserInfo |
3. StructuredTaskScope.fork |
2. Application.handle |
1. Server.serve --------------------------------------------+
StructuredTaskScope
提供的 fork/join 模型意味着绑定的动态作用域仍然由对 ScopedValue.runWhere
的调用的生命周期界定。在子线程运行时,Principal
将保持在作用域内,并且 scope.join
确保子线程在 runWhere
返回之前终止,从而销毁绑定。这避免了使用线程局部变量时看到的无限制生命周期的问题。像 ForkJoinPool
这样的传统线程管理类不支持作用域值的继承,因为它们不能保证从某个父线程作用域派生的子线程在父线程离开该作用域之前退出。
迁移到作用域值
在当今使用线程局部变量的许多场景中,作用域值可能是有用的且更可取的。除了作为隐藏的方法参数外,作用域值可能有助于:
可重入代码——有时需要检测递归,可能是因为框架不可重入或者因为递归必须以某种方式受到限制。作用域值提供了一种实现此目的的方法:像往常一样使用
ScopedValue.runWhere
进行设置,然后在调用栈的深处,调用ScopedValue.isBound
以检查它是否对当前线程有绑定。更复杂地,作用域值可以通过反复重新绑定来模拟递归计数器。嵌套事务——在扁平事务的情况下,检测递归也可能很有用:在事务进行中启动的任何事务都成为最外层事务的一部分。
图形上下文——另一个例子出现在图形中,在程序的部分之间通常有一个绘图上下文需要共享。由于作用域值的自动清理和可重入性,它们比线程局部变量更适合于此。
一般来说,当线程局部变量的目的与作用域值的目标一致时,我们建议迁移到作用域值:单向传输不变的数据。如果一个代码库以双向方式使用线程局部变量——在调用栈深处的被调用者通过 ThreadLocal.set
将数据传输给远处的调用者——或者以完全无结构的方式使用,那么迁移就不是一个选择。
有一些场景更倾向于线程局部变量。一个例子是缓存创建和使用成本很高的对象。例如,java.text.SimpleDateFormat
对象的创建成本很高,而且众所周知,它们也是可变的,因此如果不同步,它们不能在线程之间共享。因此,通过一个在线程的生命周期内持续存在的线程局部变量为每个线程提供自己的 SimpleDateFormat
对象,通常是一种实用的方法。(然而,如今,任何缓存 SimpleDateFormat
对象的代码都可以迁移到使用更新的 java.util.time.DateTimeFormatter
,它可以存储在 static final
字段中并在线程之间共享。)
ScopedValue
API
完整的 ScopedValue
API 比上面描述的小部分更丰富。虽然这里我们只展示了使用 ScopedValue<V>.runWhere(V, <value>, aRunnable)
的例子,但还有更多绑定作用域值的方法。例如,API 还提供了一个版本,它返回一个值并且可能也抛出一个 Exception
:
try {
var result = ScopedValue.callWhere(X, "hello", () -> bar());
catch (Exception e) {
handleFailure(e);
}
...
此外,还有一些绑定方法的版本可以在一个调用点绑定多个作用域值。
下面的例子运行一个操作,将 k1 绑定(或重新绑定)为 v1,将 k2 绑定(或重新绑定)为 v2:
ScopedValue.where(k1, v1).where(k2, v2).run(
() ->... );
这比嵌套调用 ScopedValue.runWhere
更高效且更易于阅读。
完整的作用域值 API 可以在 这里 找到。
替代方案
虽然在内存占用、安全性和性能方面会有一些成本,但可以使用线程局部变量模拟作用域值的许多特性。
我们尝试了一个支持作用域值一些特性的修改版 ThreadLocal
。然而,携带线程局部变量的额外负担会导致实现过于繁重,或者 API 在其大部分核心功能上返回 UnsupportedOperationException
,或者两者兼而有之。因此,最好不要修改 ThreadLocal
,而是引入作用域值作为一个完全独立的概念。
作用域值的灵感来自许多 Lisp 方言为动态作用域的自由变量提供支持的方式;特别是,在像 Interlisp-D 这样的深度绑定、多线程运行时中,这些变量的行为方式。作用域值通过添加类型安全、不可变性、封装以及在线程内和线程间的高效访问来改进 Lisp 的自由变量。