JEP 464: Scoped Values (Second Preview) | 作用域值(第二次预览)
摘要
引入 作用域值,它能够在同一线程中的子帧以及子线程之间实现对不可变数据的受管理共享。作用域值比线程局部变量更容易理解,并且具有更低的空间和时间成本,特别是在与 虚拟线程 和 结构化并发 结合使用时。这是一个 预览 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 不会直接使用该连接。
大多数时候,这种“传递你的间接被调用者需要的东西”方法是共享数据的最有效和最方便的方式。然而,有时在初始调用中传递每个间接被调用者可能需要的所有数据是不切实际的。
一个例子
在大型 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
方法来读取或写入其值。通常,线程局部变量被声明为最终静态字段,并且其可访问性设置为私有,允许共享仅限于来自单个代码库的单个类或一组类的实例。
下面是一个示例,说明两个框架方法(都在同一请求处理线程中运行)如何使用线程局部变量来共享 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()
方法。即使线程局部变量中的对象由于其每个字段都被声明为最终而不可变,这仍然是正确的。ThreadLocal
API 允许这样做是为了支持完全通用的通信模型,其中数据可以在方法之间以任何方向流动。这可能导致类似意大利面条的数据流,并导致难以辨别哪个方法更新共享状态以及以什么顺序更新的程序。如上面的示例所示,更常见的需求是从一个方法到其他方法的简单单向数据传输。无限制的生命周期——一旦通过
set()
方法设置了线程的线程局部变量的副本,该值将在线程的生命周期内保留,或者直到线程中的代码调用remove
方法。不幸的是,开发人员经常忘记调用remove()
,因此每个线程的数据通常会保留比必要的时间更长。特别是,如果使用线程池,在一个任务中设置的线程局部变量的值如果未正确清除,可能会意外泄漏到一个不相关的任务中,潜在地导致危险的安全漏洞。此外,对于依赖线程局部变量的无约束可变性的程序,可能没有明确的点可以让线程安全地调用remove()
;这可能会导致长期内存泄漏,因为每个线程的数据在线程退出之前不会被垃圾回收。如果每个线程的数据的写入和读取在线程执行的有限时间段内发生,避免泄漏的可能性会更好。昂贵的继承——当使用大量线程时,线程局部变量的开销可能会更糟,因为父线程的线程局部变量可以被子线程继承。(实际上,线程局部变量并不是一个线程本地的。)当开发人员选择创建一个继承线程局部变量的子线程时,子线程必须为父线程中先前写入的每个线程局部变量分配存储空间。这可能会增加显著的内存占用。子线程不能共享父线程使用的存储空间,因为
ThreadLocal
API 要求在一个线程中更改线程局部变量的副本在其他线程中不可见。这很不幸,因为在实践中,子线程很少在其继承的线程局部变量上调用set
方法。
迈向轻量级共享
随着虚拟线程(JEP 444)的出现,线程局部变量的问题变得更加紧迫。虚拟线程是由 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,允许有非常大量的虚拟线程。除了数量丰富之外,虚拟线程足够便宜,可以表示任何并发行为单元。这意味着一个 Web 框架可以为处理请求的任务专门分配一个新的虚拟线程,并且仍然能够同时处理数千或数百万个请求。在正在进行的示例中,方法 Framework.serve
、Application.handle
和 Framework.readKey
将在每个传入请求的新虚拟线程中执行。
对于这些方法来说,无论它们在虚拟线程还是传统平台线程中执行,能够共享数据都是有用的。因为虚拟线程是 Thread
的实例,所以虚拟线程可以有线程局部变量;实际上,虚拟线程的短暂、非池化 性质使得上面提到的长期内存泄漏问题不那么严重。(当线程快速终止时,调用线程局部变量的 remove()
方法是不必要的,因为终止会自动删除其线程局部变量。)然而,如果一百万个虚拟线程中的每个都有自己的线程局部变量副本,内存占用可能会很大。
总之,线程局部变量具有比通常共享数据所需的更多复杂性,并且具有无法避免的显著成本。Java 平台应该提供一种方法来为数千或数百万个虚拟线程维护可继承的每个线程数据。如果这些每个线程变量是不可变的,它们的数据可以被子线程高效共享。此外,这些每个线程变量的生命周期应该是有界的:一旦最初共享数据的方法完成,通过每个线程变量共享的任何数据都应该变得不可用。
描述
作用域值 是一个容器对象,它允许一个方法与同一线程中的直接和间接被调用者以及子线程安全有效地共享数据值,而无需依赖方法参数。它是类型为 ScopedValue
的变量。通常,它被声明为 final
static
字段,并且其可访问性设置为 private
,以便其他类中的代码不能直接访问它。
与线程局部变量一样,作用域值与多个值相关联,每个线程一个值。使用的特定值取决于哪个线程调用其方法。与线程局部变量不同,作用域值只写入一次,并且仅在线程执行的有限时间段内可用。
作用域值的使用如下所示。一些代码调用 ScopedValue.where
,提供一个作用域值和要与之绑定的对象。对 run
的调用 绑定 了作用域值,提供了特定于当前线程的副本,然后执行作为参数传递的 lambda 表达式。在 run
调用的生命周期内,lambda 表达式或从该表达式直接或间接调用的任何方法可以通过值的 get()
方法读取作用域值。在 run
方法完成后,绑定被销毁。
final static ScopedValue<...> NAME = ScopedValue.newInstance();
// 在某个方法中
ScopedValue.where(NAME, <value>)
.run(() -> {... NAME.get()... 调用方法... });
// 在从 lambda 表达式直接或间接调用的方法中
... NAME.get()...
代码的结构描绘了线程可以读取其作用域值副本的时间段。这个有界的生命周期极大地简化了对线程行为的推理。从调用者到被调用者(包括直接和间接的)的单向数据传输一目了然。没有 set
方法允许远处的代码随时更改作用域值。这也有助于提高性能:使用 get()
读取作用域值通常与读取局部变量一样快,无论调用者和被调用者之间的栈距离如何。
“作用域”的含义
一个事物的 作用域 是它存在的空间——它可以被使用的范围或区间。例如,在 Java 编程语言中,变量声明的作用域是程序文本中可以用简单名称引用该变量的空间(JLS 6.3)。这种作用域更准确地称为 词法作用域 或 静态作用域,因为变量的作用域空间可以通过在程序文本中查找 {
和 }
字符来静态理解。
另一种作用域称为 动态作用域。一个事物的动态作用域是指程序在执行时可以使用该事物的部分。如果方法 a
调用方法 b
,而方法 b
又调用方法 c
,那么 c
的执行生命周期包含在 b
的执行中,而 b
的执行又包含在 a
的执行中,即使这三个方法是不同的代码单元:
|
| +–– a
| |
| | +–– b
| | |
TIME | | +–– c
| | | |
| | | |__
| | |
| | |__
| |
| |__
|
v
这就是 作用域值 所涉及的概念,因为在 run
方法中绑定一个作用域值 V 会产生一个在程序执行时某些部分可以访问的值,即由 run
直接或间接调用的方法。
这些方法的展开执行定义了一个动态作用域;绑定在这些方法的执行期间在作用域内,在其他地方不在作用域内。
使用作用域值的 Web 框架示例
前面展示的框架代码可以很容易地重写为使用作用域值而不是线程局部变量。在(1)处,框架声明一个作用域值而不是线程局部变量。在(2)处,serve
方法调用 ScopedValue.where
和 run
而不是线程局部变量的 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);
}
...
}
where
和 run
一起提供了从 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
将具有私有访问权限,但有时它可能具有受保护的或包访问权限,以允许多个协作类读取和绑定值。
重新绑定作用域值
作用域值没有 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()); // 打印 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
内部 X
的值恢复为 "hello"
。bar
的主体不能更改该方法本身看到的绑定,但可以更改其被调用者看到的绑定。在 foo
退出后,X
恢复为未绑定状态。这种嵌套保证了新值共享的有界生命周期。
继承作用域值
Web 框架示例为每个请求分配一个线程,因此相同的线程执行一些框架代码,然后执行应用程序开发人员的用户代码,然后执行更多框架代码以访问数据库。然而,用户代码可以利用虚拟线程的轻量级性质,通过创建自己的虚拟线程并在其中运行自己的代码。这些虚拟线程将是请求处理线程的子线程。
在请求处理线程中运行的代码共享的上下文数据需要对在子线程中运行的代码可用。否则,当在子线程中运行的用户代码调用框架方法时,它将无法访问由在请求处理线程中运行的框架代码创建的 FrameworkContext
。为了实现跨线程共享,作用域值可以被子线程继承。
用户代码创建虚拟线程的首选机制是结构化并发 API(JEP 453),特别是类 StructuredTaskScope
。父线程中的作用域值由使用 StructuredTaskScope
创建的子线程自动继承。子线程中的代码可以使用在父线程中为作用域值建立的绑定,开销最小。与线程局部变量不同,父线程的作用域值绑定不会复制到子线程。
这是一个在用户代码中幕后发生作用域值继承的示例。Server.serve
方法像以前一样绑定 CONTEXT
并调用 Application.handle
。然而,Application.handle
中的用户代码使用 StructuredTaskScope.fork
并发地运行 readUserInfo()
和 fetchOffers()
方法,每个方法在自己的虚拟线程中(1,2)。每个方法都可以使用 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(); // 等待两个分支
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.where(...).run(...)
的调用的生命周期界定。Principal
将在子线程运行时在作用域内,并且 scope.join()
确保子线程在 run
可以返回之前终止,销毁绑定。这避免了使用线程局部变量时看到的无界生命周期问题。像 ForkJoinPool
这样的传统线程管理类不支持作用域值的继承,因为它们不能保证从某个父线程作用域分叉的子线程将在父线程离开该作用域之前退出。
Migrating to scoped values
Scoped values are likely to be useful and preferable in many scenarios where thread-local variables are used today. Beyond serving as hidden method parameters, scoped values may assist with:
Re-entrant code — Sometimes it is desirable to detect recursion, perhaps because a framework is not re-entrant or because recursion must be limited in some way. A scoped value provides a way to do this: Set it up as usual, with
ScopedValue.where
andrun
, and then deep in the call stack, callScopedValue.isBound()
to check if it has a binding for the current thread. More elaborately, the scoped value can model a recursion counter by being repeatedly rebound.Nested transactions — Detecting recursion can also be useful in the case of flattened transactions: Any transaction started while a transaction is in progress becomes part of the outermost transaction.
Graphics contexts — Another example occurs in graphics, where there is often a drawing context to be shared between parts of the program. Scoped values, because of their automatic cleanup and re-entrancy, are better suited to this than thread-local variables.
In general, we advise migration to scoped values when the purpose of a thread-local variable aligns with the goal of a scoped value: one-way transmission of unchanging data. If a codebase uses thread-local variables in a two-way fashion — where a callee deep in the call stack transmits data to a faraway caller via ThreadLocal.set
— or in a completely unstructured fashion, then migration is not an option.
There are a few scenarios that favor thread-local variables. An example is caching objects that are expensive to create and use, such as instances of java.text.DateFormat
. Notoriously, an instance of java.text.SimpleDateFormat
object is mutable, so it cannot be shared between threads without synchronization. Giving each thread its own SimpleDateFormat
object, via a thread-local variable that persists for the lifetime of the thread, has often been a practical approach. Today, though, any code caching a SimpleDateFormat
could move to using DateTimeFormatter
because it can be stored in a static final
field and shared between threads.
The ScopedValue
API
The full ScopedValue
API is richer than the small subset described above. While this JEP only presents examples that use ScopedValue<V>.where(V, <value>).run(aRunnable)
, there are more ways to bind a scoped value. For example, the API also provides a Callable
version which returns a value and may also throw an Exception
:
try {
var result = ScopedValue.where(X, "hello").call(() -> bar());
catch (Exception e) {
handleFailure(e);
}
...
Additionally, there are abbreviated versions of the binding methods. For example, ScopedValue<V>.runWhere(V, <value>, aRunnable)
is a short form of ScopedValue<V>.where(V, <value>).run(aRunnable)
. While this short form is sometimes convenient, it only allows a single scoped value to be bound at a time.
The full scoped value API is to be found here
替代方案
It is possible to emulate many of the features of scoped values with thread-local variables, albeit at some cost in memory footprint, security, and performance.
We experimented with a modified version of ThreadLocal
that supports some of the characteristics of scoped values. However, carrying the additional baggage of thread-local variables results in an implementation that is unduly burdensome, or an API that returns UnsupportedOperationException
for much of its core functionality, or both. It is better, therefore, not to modify ThreadLocal
but to introduce scoped values as an entirely separate concept.
Scoped values were inspired by the way that many Lisp dialects provide support for dynamically scoped free variables; in particular, how such variables behave in a deep-bound, multi-threaded runtime such as Interlisp-D. Scoped values improve on Lisp's free variables by adding type safety, immutability, encapsulation, and efficient access within and across threads.