JEP 446:作用域值(预览)
总结
引入作用域值,这是一种可以安全且高效地共享给方法的值,而无需使用方法参数。它们相比线程局部变量更为推荐,尤其是在使用大量虚拟线程时。这是一个预览 API。
实际上,作用域值是一个隐式方法参数。就好像一个调用序列中的每个方法都有一个额外的、不可见的参数。这些方法都没有声明这个参数,只有能够访问作用域值对象的方法才能访问其值(数据)。作用域值使得可以通过一系列未声明该数据参数且无法访问该数据的中间方法,安全地将数据从调用方传递到远处的被调用方。
历史
目标
-
易用性 — 提供一种编程模型,以便在线程内部以及与子线程之间共享数据,从而简化对数据流的推理。
-
可理解性 — 通过代码的语法结构使共享数据的生命周期可见。
-
健壮性 — 确保调用者共享的数据只能被合法的被调用者检索。
-
性能 — 允许共享数据是不可变的,以便支持大量线程之间的共享,并启用运行时优化。
非目标
-
此提案的目标并不是要改变 Java 编程语言。
-
此提案的目标并不是要强制迁移远离线程局部变量,或者弃用现有的
ThreadLocal
API。
动机
Java 应用程序和库被构造成类的集合,这些类包含方法。这些方法通过方法调用进行通信。
大多数方法允许调用者通过将数据作为参数传递给方法。当方法 A
希望方法 B
为其完成某些工作时,它会使用适当的参数调用 B
,而 B
可能会将其中的一些参数传递给 C
,以此类推。B
的参数列表中可能不仅需要包含 B
直接需要的内容,还需要包含 B
必须传递给 C
的内容。例如,如果 B
准备设置并执行一个数据库调用,它可能希望传入一个 Connection,即使 B
不会直接使用该 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 等,并将其与当前事务关联。所有框架操作都使用该上下文对象,但用户代码不会使用它(且与用户代码无关)。
实际上,该框架希望将其内部上下文从其 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
的变量。尽管看起来像一个普通变量,但线程局部变量每个线程都有一个当前值;具体使用的值取决于哪个线程调用其 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 425)的出现,线程局部变量的问题变得更加紧迫。虚拟线程是由 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许存在大量的虚拟线程。除了数量众多之外,虚拟线程的成本也足够低,能够表示任何并发的行为单元。这意味着一个 Web 框架可以为每个请求处理任务分配一个新的虚拟线程,同时仍然能够一次性处理成千上万甚至百万级别的请求。在当前的例子中,方法 Framework.serve
、Application.handle
和 Framework.readKey
都会在每个传入请求的新虚拟线程中执行。
如果这些方法在虚拟线程或传统的平台线程中执行,它们能够共享数据将非常有用。因为虚拟线程是 Thread
的实例,所以虚拟线程可以拥有线程局部变量;实际上,虚拟线程的短暂 非池化 特性使得上述长期内存泄漏问题变得不那么严重。(当线程快速终止时,调用线程局部变量的 remove()
方法是不必要的,因为终止会自动移除其线程局部变量。)然而,如果每百万个虚拟线程都有自己的线程局部变量副本,内存占用可能会很显著。
总的来说,线程本地变量在共享数据时的复杂性通常超过了需求,并且还伴随着无法避免的重大开销。Java 平台应提供一种方法,以维护成千上万个虚拟线程的可继承的每线程数据。如果这些每线程变量是不可变的,那么它们的数据可以被子线程高效地共享。此外,这些每线程变量的生命周期应该是有界的:通过每线程变量共享的任何数据,一旦最初共享该数据的方法执行完毕,就应该变得不可用。
描述
作用域值 允许在大型程序中的方法之间安全高效地共享数据,而无需借助方法参数。它是一个类型为 ScopedValue
的变量。通常,它被声明为 final
static
字段,因此可以轻松地从许多方法中访问。
与线程局部变量类似,作用域值具有多个与其关联的值,每个线程一个。所使用的特定值取决于哪个线程调用其方法。与线程局部变量不同的是,作用域值只写入一次,并且仅在执行线程期间的有限时间段内可用。
作用域值的使用方法如下所示。某些代码调用 ScopedValue.where
,提供一个作用域值以及要绑定到的对象。调用 run
时会绑定该作用域值,提供特定于当前线程的副本,然后执行作为参数传入的 lambda 表达式。在 run
方法调用的生命周期内,lambda 表达式或从该表达式直接或间接调用的任何方法都可以通过该值的 get()
方法读取作用域值。run
方法结束后,绑定会被销毁。
final static ScopedValue<...> V = ScopedValue.newInstance();
// In some method
ScopedValue.where(V, <value>)
.run(() -> { ... V.get() ... call methods ... });
// In a method called directly or indirectly from the lambda expression
... V.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 框架示例
前面展示的框架代码可以轻松重写为使用作用域值而不是线程局部变量。在(1)处,框架声明了一个作用域值而不是线程局部变量。在(2)处,serve
方法调用 ScopedValue.where
和 run
,而不是线程局部变量的 set
方法。
class Frameowrk {
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
会具有 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
就会看到 "hello"
绑定。bar
的主体无法更改该方法自身的绑定,但可以更改其被调用者所看到的绑定。这保证了新值共享的生命周期是有限的。
继承作用域值
在 Web 框架示例中,每个请求都由一个线程专门处理,因此同一个线程会执行一些框架代码,然后执行应用开发者编写的用户代码,接着再执行更多框架代码以访问数据库。然而,用户代码可以利用虚拟线程的轻量特性,通过创建自己的虚拟线程并在其中运行自己的代码。这些虚拟线程将成为请求处理线程的子线程。
在请求处理线程中运行的代码所共享的上下文数据需要对在子线程中运行的代码可用。否则,当在子线程中运行的用户代码调用框架方法时,将无法访问由在请求处理线程中运行的框架代码创建的 FrameworkContext
。为了实现跨线程共享,作用域值可以被子线程继承。
用户代码创建虚拟线程的首选机制是结构化并发 API(JEP 428),特别是 StructuredTaskScope
类。父线程中的作用域值会自动被使用 StructuredTaskScope
创建的子线程继承。子线程中的代码可以以极小的开销使用在父线程中为作用域值建立的绑定。与线程局部变量不同,父线程的作用域值绑定不会复制到子线程。
以下是作用域值继承在用户代码背后发生的示例。Server.serve
方法绑定 CONTEXT
并调用 Application.handle
,与之前相同。然而,Application.handle
中的用户代码使用 StructuredTaskScope.fork
(1, 2)并发地调用运行 readUserInfo()
和 fetchOffers()
方法,每个方法都在其自己的虚拟线程中。每个方法都可以使用 Framework.readKey
,它像之前一样查询作用域值 CONTEXT
(4)。这里不讨论用户代码的更多细节;更多信息请参见 JEP 428。
@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
这样的传统线程管理类不支持 ScopedValues 的继承,因为它们无法保证从某个父线程范围分叉出的子线程会在父线程离开该范围之前退出。
迁移到作用域值
在许多目前使用线程局部变量的场景中,作用域值可能非常有用且更受青睐。除了充当隐藏的方法参数外,作用域值还可以帮助实现:
-
可重入代码 — 有时检测递归是可取的,也许是因为某个框架不是可重入的,或者因为递归必须以某种方式进行限制。作用域值提供了一种实现方法:像平常一样使用
ScopedValue.where
和run
进行设置,然后在调用栈的深处,调用ScopedValue.isBound()
来检查它是否对当前线程有绑定。更复杂的情况下,可以通过反复重新绑定,使作用域值模拟一个递归计数器。 -
嵌套事务 — 在扁平化事务的情况下,检测递归也很有用:在事务进行过程中启动的任何事务都将成为最外层事务的一部分。
-
图形上下文 — 另一个例子出现在图形处理中,程序的各个部分之间通常需要共享一个绘图上下文。由于作用域值具有自动清理和可重入性,因此比线程局部变量更适合用于此场景。
一般来说,当线程局部变量的用途与作用域值的目标一致时(即单向传递不变的数据),我们建议迁移到作用域值。如果代码库以双向方式使用线程局部变量 —— 例如,调用栈深处的被调用者通过 ThreadLocal.set
向远处的调用者传递数据 —— 或者以完全非结构化的方式使用,则迁移不是一个可行的选择。
有一些场景适合使用线程本地变量。例如,缓存创建和使用成本较高的对象,比如 java.text.DateFormat
的实例。众所周知,DateFormat
对象是可变的,因此在没有同步的情况下不能在线程之间共享。通过一个在该线程生命周期内持久存在的线程本地变量,为每个线程提供其自己的 DateFormat
对象,通常是一种实用的方法。
替代方案
虽然在线程局部变量的内存占用、安全性和性能方面会付出一定代价,但可以使用线程局部变量来模拟范围值的许多特性。
我们尝试了 ThreadLocal
的一个修改版本,该版本支持范围值的部分特性。然而,携带线程局部变量的额外负担会导致实现过于繁琐,或者其核心功能的大部分 API 返回 UnsupportedOperationException
,或者两者兼而有之。因此,最好不要修改 ThreadLocal
,而是将范围值作为一个完全独立的概念引入。
作用域值的灵感来源于许多 Lisp 方言对动态作用域自由变量的支持方式;特别是这些变量在深度绑定、多线程运行时(如 Interlisp-D)中的行为。作用域值通过添加类型安全、不可变性、封装性以及在同一线程和跨线程中高效的访问能力,改进了 Lisp 的自由变量。