JEP 481:作用域值(第三次预览)
总结
历史
我们在此提议重新预览 JDK 23 中的 API,以获取更多的经验与反馈,并做出一项更改:
ScopedValue.callWhere
方法的操作参数类型现在是一个新的函数式接口,它允许 Java 编译器推断是否可能抛出受检异常。通过这一更改,不再需要ScopedValue.getWhere
方法,因此该方法已被移除。
目标
-
易用性 — 应该易于推断数据流。
-
可理解性 — 共享数据的生命周期应能从代码的语法结构中明显看出。
-
鲁棒性 — 调用者共享的数据只能由合法的被调用者检索。
-
性能 — 数据应在大量线程之间高效共享。
非目标
-
本提案的目标并非更改 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 等,并将其与当前事务关联。所有框架操作都会使用 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 引入的线程局部变量(thread-local variables)来帮助在调用栈中的方法之间共享数据,而无需依赖方法参数。线程局部变量是一种类型为 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
的变量。它通常声明为 final
static
字段,并且其可访问性设置为 private
,以防止其他类中的代码直接访问。
与线程局部变量类似,作用域值具有多个与其关联的值,每个线程一个。所使用的特定值取决于哪个线程调用其方法。与线程局部变量不同的是,作用域值只写入一次,并且仅在执行线程期间的有限时间段内可用。
作用域值的使用方法如下所示。某些代码调用 ScopedValue.runWhere
,传入一个作用域值以及它将要绑定的对象。调用 runWhere
时会绑定该作用域值,提供一个特定于当前线程的副本,然后执行作为参数传入的 lambda 表达式。在 runWhere
调用的生命周期内,lambda 表达式或从该表达式直接或间接调用的任何方法都可以通过该值的 get
方法读取作用域值。在 runWhere
方法执行完成后,绑定会被销毁。
final static ScopedValue<...> NAME = ScopedValue.newInstance();
// In some method
ScopedValue.runWhere(NAME, <value>,
() -> { ... 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(作用域值)所吸引的概念,因为在 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
之后,Framework.serve
中出现 CONTEXT.get()
,则会抛出异常,因为 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()); // prints hello
ScopedValue.runWhere(X, "goodbye", () -> 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 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(); // 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.runWhere
调用生命周期的限制。在子线程运行期间,Principal
会保持在范围内,并且 scope.join
确保子线程在 runWhere
返回并销毁绑定之前终止。这避免了使用线程局部变量时出现的无限制生命周期问题。传统的线程管理类(如 ForkJoinPool
)不支持作用域值的继承,因为它们无法保证从某个父线程作用域分叉出的子线程会在父线程离开该作用域之前退出。
迁移到作用域值
在许多目前使用线程局部变量的场景中,作用域值可能更有用且更可取。除了充当隐藏的方法参数外,作用域值还可以帮助实现:
-
可重入代码 — 有时检测递归是很有必要的,可能是因为某个框架不是可重入的,或者递归必须以某种方式进行限制。作用域值(Scoped Value)提供了一种实现方法:像平常一样使用
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
更高效,也更容易阅读。
完整的 Scoped Value API 可以在这里找到。
替代方案
虽然在线程局部变量的内存占用、安全性和性能方面会有一些损失,但可以使用线程局部变量来模拟作用域值的许多特性。
我们尝试了 ThreadLocal
的一个修改版本,该版本支持范围值的部分特性。然而,线程局部变量附带的额外包袱导致了一种实现过于繁琐,或者其核心功能的很大一部分返回 UnsupportedOperationException
的 API,或者两者兼而有之。因此,最好不要修改 ThreadLocal
,而是将范围值作为一个完全独立的概念引入。
作用域值的灵感来源于许多 Lisp 方言对动态作用域自由变量的支持方式;特别是这些变量在深度绑定、多线程运行时(例如 Interlisp-D)中的行为。作用域值通过添加类型安全、不可变性、封装性以及在同一线程和跨线程中高效的访问能力,改进了 Lisp 的自由变量。