JEP 487: Scoped Values (Fourth Preview) | 作用域值(第四次预览)
摘要
引入 作用域值,它使方法能够在单个线程及其子线程之间共享不可变数据。与线程局部变量相比,作用域值更容易推理。它们还具有更低的空间和时间成本,特别是在与虚拟线程(JEP 444)和结构化并发(JEP 480)一起使用时。这是一个 预览 API。
历史
作用域值 API 由 JEP 429(JDK 20)提议进行孵化,由 JEP 446(JDK 21)提议作为预览,并随后由 JEP 464(JDK 22)和 JEP 481(JDK 23)改进和完善。
我们在此提议在 JDK 24 中再次将 API 作为预览提出,以获得额外的经验和反馈,并作出以下更改:
- 我们从
ScopedValue
类中移除了callWhere
和runWhere
方法,使得 API 完全成为 流畅的。绑定作用域值的唯一方法是通过ScopedValue.Carrier.call
和ScopedValue.Carrier.run
方法。
目标
易用性 —— 数据流应该易于理解。
可理解性 —— 共享数据的生命周期应能从代码的语法结构中明显看出。
健壮性 —— 调用者共享的数据只能由合法的被调用者检索。
性能 —— 应该能够跨大量线程高效地共享数据。
非目标
改变 Java 编程语言不是目标。
要求迁移离开线程局部变量或弃用现有的
ThreadLocal
API 也不是目标。
动机
Java 应用程序和库被构造成类的集合,这些类包含方法。这些方法通过方法调用来通信。
大多数方法允许调用者通过将数据作为参数传递给方法来将数据传递给方法。当方法 A
希望方法 B
为其做一些工作时,它会使用适当的参数调用 B
,而 B
可能会将其中的一些参数传递给 C
,依此类推。B
可能不仅需要在其参数列表中包括 B
直接需要的东西,还包括必须传递给 C
的东西。例如,如果 B
打算设置并执行数据库调用,即使 B
不直接使用连接,也可能希望传入一个 Connection。
大多数时候这种“传递间接被调用者所需的所有数据”的方法是共享数据最有效、最方便的方式。然而,有时在初始调用中传递每个间接被调用者可能需要的所有数据是不切实际的。
示例
在大型 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
方法来读取或写入其值。通常,线程局部变量被声明为 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
作为方法参数传递的需求。线程局部变量充当隐藏的方法参数:调用 CONTEXT.set
在 Framework.serve
中并随后在 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
的变量。通常声明为 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 编程语言中,变量声明的作用域是程序文本中合法引用该变量的空间 (JLS §6.3)。这种类型的作用域更准确地称为 词法作用域 或 静态作用域,因为可以通过查看程序文本中的 {
和 }
字符来静态理解变量处于作用域内的空间。
另一种类型的作用域称为 动态作用域。事物的动态作用域指的是程序在执行过程中可以使用该事物的部分。如果方法 a
调用方法 b
,而 b
又调用方法 c
,那么 c
的执行生命周期包含在 b
的执行中,而 b
的执行又包含在 a
的执行中,即使这三个方法是不同的代码单元:
|
| +–– a
| |
| | +–– b
| | |
TIME | | +–– c
| | | |
| | | |__
| | |
| | |__
| |
| |__
|
v
这就是 作用域值 所依赖的概念,因为在 run
(或 call
)方法中绑定的作用域值 V 会在程序执行时产生可以被某些部分访问的值,即直接或间接由 run
或 call
调用的方法。
这些方法展开的执行定义了一个动态作用域;在这些方法执行期间绑定处于作用域内,在其他任何地方都不在作用域内。
使用作用域值的 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
调用的任何方法中的 CONTEXT.get()
都将读取该值。相应地,当 Framework.serve
调用用户代码,用户代码再调用 Framework.readKey
时,从作用域值 (3) 中读取的值就是 Framework.serve
之前在该线程中写入的值。
run
建立的绑定只能用于从 run
调用的代码中。如果 CONTEXT.get()
出现在 Framework.serve
中调用 run
之后,则会抛出异常,因为 CONTEXT
在线程中不再绑定。
像以前一样,框架依赖于 Java 的访问控制来限制对其内部数据的访问:CONTEXT
字段具有私有访问权限,这使得框架能够在两个方法之间内部共享信息。该信息对用户代码不可访问且隐藏。我们说 ScopedValue
对象是一个赋予具有访问权限的代码绑定或读取值能力的 能力 对象。通常,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 480),特别是 StructuredTaskScope
类。使用 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.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());
... 使用结果 ...
} catch (Exception e) {
handleFailure(e);
}
...
此外,我们可以一次性绑定多个作用域值:
where(X, v).where(Y, w).run(() -> ... );
此示例运行一个操作,其中 X
绑定(或重新绑定)到 v
,并且 Y
绑定(或重新绑定)到 w
。这不仅比嵌套调用 ScopedValue ... where ... run
更高效,也更容易阅读。
完整的作用域值 API 可以在这里找到 链接。
替代方案
虽然在内存占用、安全性和性能方面可能会有一些代价,但是可以用线程局部变量模仿作用域值的许多特性。
我们尝试过修改版本的 ThreadLocal
,它支持作用域值的一些特性。然而,携带额外的负担导致实现变得不必要地沉重,或者 API 对大部分核心功能返回 UnsupportedOperationException
,或者两者兼而有之。因此,最好不修改 ThreadLocal
,而是引入作用域值作为一个完全独立的概念。
作用域值的设计灵感来源于许多 Lisp 方言如何提供对动态作用域自由变量的支持;特别是这些变量在如 Interlisp-D 这样的深度绑定、多线程运行时中的行为。作用域值通过添加类型安全、不可变性、封装以及跨线程内的高效访问来改进 Lisp 的自由变量。