JEP 429:作用域值(孵化器)
总结
引入了作用域值,它能够在线程内部和跨线程之间共享不可变数据。相比于线程局部变量,尤其是在使用大量虚拟线程时,作用域值是更优的选择。这是一个孵化中的 API。
目标
- 易用性 — 提供一种编程模型,以便在线程内部以及与子线程之间共享数据,从而简化对数据流的推理。
- 可理解性 — 通过代码的语法结构使共享数据的生命周期可见。
- 鲁棒性 — 确保调用者共享的数据只能被合法的被调用者检索。
- 性能 — 将共享数据视为不可变,以便允许多个线程共享,并实现运行时优化。
非目标
- 本提案的目标并非改变 Java 编程语言。
- 本提案的目标并非要求迁移远离线程局部变量,也并非弃用现有的
ThreadLocal
API。
动机
大型 Java 程序通常由不同且互补的组件构成,这些组件之间需要共享数据。例如,一个 Web 框架可能包含一个服务器组件(以 每个请求一个线程的风格 实现)和一个数据访问组件(负责处理持久化)。在整个框架中,用户身份验证和授权依赖于在组件之间共享的 Principal
对象。服务器组件会为每个处理请求的线程创建一个 Principal
,而数据访问组件则引用该线程的 Principal
来控制对数据库的访问。
下图显示了框架处理两个请求的框架,每个请求都在各自的线程中。请求处理流程自下而上,从服务器组件(Server.serve(...)
)到用户代码(Application.handle(...)
),再到数据访问组件(DBAccess.open()
)。数据访问组件确定线程是否被允许访问数据库,具体如下:
- 在线程 1 中,服务器组件创建的
ADMIN
主体允许数据库访问。虚线表示该主体将与数据访问组件共享,数据访问组件会检查该主体并继续调用DBAccess.newConnection()
。 - 在线程 2 中,服务器组件创建的
GUEST
主体不允许数据库访问。数据访问组件检查该主体后,确定用户代码不得继续执行,并抛出InvalidPrincipalException
异常。
Thread 1 Thread 2
-------- --------
8. DBAccess.newConnection() 8. throw new InvalidPrincipalException()
7. DBAccess.open() <----------+ 7. DBAccess.open() <----------+
... | ... |
... Principal(ADMIN) ... Principal(GUEST)
2. Application.handle(..) | 2. Application.handle(..) |
1. Server.serve(..) ----------+ 1. Server.serve(..) ----------+
通常,调用者和被调用者之间通过将数据作为方法参数传递来共享数据,但对于在服务器组件和数据访问组件之间共享的 Principal
来说,这是不可行的,因为服务器组件会首先调用不受信任的用户代码。我们需要一种更好的方式,将数据从服务器组件共享到数据访问组件,而不是将其嵌入到一系列不受信任的方法调用中。
用于共享的线程局部变量
传统上,开发者使用自 Java 1.2 引入的线程局部变量来帮助组件在不使用方法参数的情况下共享数据。线程局部变量是类型为 ThreadLocal
的变量。尽管看起来像一个普通变量,但线程局部变量有多个实例,每个线程一个;使用的特定实例取决于哪个线程调用其 get()
或 set(...)
方法来读取或写入其值。一个线程中的代码会自动读取和写入其对应的实例,而另一个线程中的代码则自动读取和写入其自己独立的实例。通常,线程局部变量被声明为 final
static
字段,以便可以从许多组件轻松访问它。
以下是一个示例,展示了服务器组件和数据访问组件如何在同一个请求处理线程中使用线程本地变量来共享一个 Principal
。服务器组件首先声明一个线程本地变量 PRINCIPAL
(1)。当在请求处理线程中执行 Server.serve(...)
时,它会将一个适当的 Principal
写入线程本地变量(2),然后调用用户代码。如果用户代码调用了 DBAccess.open()
,数据访问组件会读取线程本地变量(3)以获取请求处理线程的 Principal
。只有当 Principal
表明具有适当权限时,才允许进行数据库访问(4)。
class Server {
final static ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>(); // (1)
void serve(Request request, Response response) {
var level = (request.isAuthorized() ? ADMIN : GUEST);
var principal = new Principal(level);
PRINCIPAL.set(principal); // (2)
Application.handle(request, response);
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (3)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...); // (4)
}
}
使用线程局部变量可以避免在服务器组件调用用户代码时,以及用户代码调用数据访问组件时,将 Principal
作为方法参数传递。线程局部变量充当了一种隐藏的方法参数:如果一个线程在 Server.serve(...)
中调用了 PRINCIPAL.set(...)
,然后在 DBAccess.open()
中调用了 PRINCIPAL.get()
,那么该线程将自动获取其自身的 PRINCIPAL
变量实例。实际上,ThreadLocal
字段充当了一个键,用于查找当前线程的 Principal
值。
线程局部变量的问题
不幸的是,线程局部变量存在许多无法避免的设计缺陷:
- 无约束的可变性 —— 每个线程局部变量都是可变的:任何能够调用线程局部变量
get()
方法的代码,都可以在任何时候调用该变量的set(...)
方法。ThreadLocal
API 允许这样做是为了支持一种完全通用的通信模型,其中数据可以在组件之间以任何方向流动。然而,这可能导致类似意大利面的数据流,并且使程序难以辨别哪个组件在什么顺序下更新了共享状态。更常见的需求(如上面的例子所示)是从一个组件向其他组件进行简单的单向数据传输。 - 无限制的生命周期 —— 一旦某个线程的线程局部变量的实例通过
set(...)
方法被写入,该实例将保留到线程的生命周期结束,或者直到线程中的代码调用了remove()
方法。不幸的是,开发人员常常忘记调用remove()
,因此每个线程的数据通常会比需要的时间保留得更久。此外,对于依赖线程局部变量无约束可变性的程序,可能没有明确的时间点可以安全地让线程调用remove()
;这可能会导致长期的内存泄漏,因为每个线程的数据直到线程退出时才会被垃圾回收。如果能够在执行线程期间的一个有界时间段内完成每个线程数据的读写操作,就可以避免泄漏的可能性,这将更好。 - 昂贵的继承 —— 当使用大量线程时,线程局部变量的开销可能更大,因为父线程的线程局部变量可以被子线程继承。(实际上,线程局部变量并不局限于一个线程。)当开发人员选择创建一个继承线程局部变量的子线程时,子线程必须为父线程中先前写入的每个线程局部变量分配存储空间。这可能会显著增加内存占用。子线程无法共享父线程使用的存储空间,因为线程局部变量是可变的,而
ThreadLocal
API 要求在一个线程中的修改不会在其他线程中可见。这是不幸的,因为在实践中,子线程很少对其继承的线程局部变量调用set(...)
方法。
轻量级共享
随着虚拟线程(JEP 425)的出现,线程局部变量的问题变得更加紧迫。虚拟线程是由 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,从而允许存在大量的虚拟线程。除了数量众多外,虚拟线程的成本也足够低,可以表示任何并发行为单元。这意味着一个 Web 框架可以为处理请求的任务分配一个新的虚拟线程,同时仍然能够一次性处理成千上万甚至百万级别的请求。在当前的示例中,Server.serve(...)
、Application.handle(...)
和 DBAccess.open()
方法都会为每个传入的请求在一个新的虚拟线程中执行。
显然,这些方法无论是在虚拟线程还是传统的平台线程中执行,能够共享数据将非常有用。因为虚拟线程是 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() ...
代码的语法结构明确了线程可以读取其作用域值(scoped value)实例的时间段。这种有限的生命周期与不可变性相结合,极大地简化了对线程行为的推理。从调用方到被调用方(包括直接和间接调用)的单向数据传输一目了然。不存在允许远端代码随时更改作用域值的 set(...)
方法。不可变性还有助于提升性能:使用 get()
读取作用域值通常与读取局部变量一样快,无论调用方与被调用方之间的栈距离如何。
“scoped” 的含义
一个事物的 作用域 是它存在的空间 —— 即它可以被使用的范围。例如,在 Java 编程语言中,变量声明的作用域是程序文本中可以合法地使用简单名称引用该变量的空间(JLS 6.3)。这种作用域更准确地称为 词法作用域 或 静态作用域,因为通过查看程序文本中的 {
和 }
字符,就可以静态地理解变量在何处属于作用域内。
另一种作用域被称为动态作用域。一个事物的动态作用域指的是程序执行过程中可以使用该事物的那些部分。这是作用域值所涉及的概念,因为在 run(...)
方法中绑定一个作用域值 V 会产生 V 的一个实例,该实例在程序执行期间可以被某些部分使用,即直接或间接由 run(...)
调用的方法。这些方法的逐步执行定义了一个动态作用域;该实例在这些方法执行期间处于作用域内,在其他任何地方都不在作用域内。
使用作用域值的 Web 框架示例
前面展示的框架代码可以轻松重写为使用作用域值(scoped value)而不是线程局部变量(thread-local variable)。在 (1) 处,服务器组件声明了一个作用域值而不是线程局部变量。在 (2) 处,服务器组件调用 ScopedValue.where(...)
和 run(...)
,而不是线程局部变量的 set(...)
方法。
class Server {
final static ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance(); // (1)
void serve(Request request, Response response) {
var level = (request.isAdmin() ? ADMIN : GUEST);
var principal = new Principal(level);
ScopedValue.where(PRINCIPAL, principal) // (2)
.run(() -> Application.handle(request, response));
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (3)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...);
}
}
where(...)
和 run(...)
共同提供了从服务器组件到数据访问组件的单向数据共享。传递给 where(...)
的作用域值在 run(...)
调用的生命周期内绑定到相应的对象,因此从 run(...)
调用的任何方法中调用 PRINCIPAL.get()
都会读取该值。因此,当 Server.serve(...)
调用用户代码,且用户代码调用 DBAccess.open()
时,从作用域值(3)读取的值就是之前在该线程中由 Server.serve(...)
写入的值。
run(...)
建立的绑定只能在从 run(...)
调用的代码中使用。如果在调用 run(...)
之后,PRINCIPAL.get()
出现在 Server.serve(...)
中,则会抛出异常,因为 PRINCIPAL
在该线程中不再绑定。
重新绑定作用域值
作用域值的不可变性意味着调用者可以使用作用域值在同一线程中可靠地向其被调用者传递一个常量值。然而,有时其中一个被调用者可能需要使用相同的作用域值在线程中向自己的被调用者传递不同的值。ScopedValue
API 允许为嵌套调用建立新的绑定。
例如,考虑 Web 框架的第三个组件:一个包含方法 void log(Supplier<String> formatter)
的日志记录组件。用户代码将一个 lambda 表达式传递给 log(...)
方法;如果启用了日志记录,该方法会调用 formatter.get()
来计算 lambda 表达式的值,然后打印结果。尽管用户代码可能有权访问数据库,但 lambda 表达式不应有此权限,因为它只需要格式化文本。因此,在 Server.serve(...)
中最初绑定的作用域值应在 formatter.get()
的生命周期内重新绑定到访客 Principal
:
8. InvalidPrincipalException()
7. DBAccess.open() <--------------------------+ X---------+
... | |
... Principal(GUEST) |
4. Supplier.get() | |
3. Logger.log(() -> { DBAccess.open(); }) ----+ Principal(ADMIN)
2. Application.handle(..) |
1. Server.serve(..) ---------------------------------------+
以下是使用重新绑定的 log(...)
代码。它获取一个访客 Principal
(1),并将其作为作用域值 PRINCIPAL
的新绑定传递(2)。在 call
调用的生命周期内(3),PRINCIPAL.get()
将读取这个新值。因此,如果用户代码向 log(...)
传递了一个执行 DBAccess.open()
的恶意 lambda 表达式,DBAccess.open()
中的检查将从 PRINCIPAL
读取访客 Principal
并抛出 InvalidPrincipalException
异常。
class Logger {
void log(Supplier<String> formatter) {
if (loggingEnabled) {
var guest = Principal.createGuest(); // (1)
var message = ScopedValue.where(Server.PRINCIPAL, guest) // (2)
.call(() -> formatter.get()); // (3)
write(logFile, "%s %s".format(timeStamp(), message));
}
}
}
(我们在此使用 call(...)
而不是 run(...)
来调用格式化程序,因为需要 lambda 表达式的结果。)where(...)
和 call(...)
的语法结构意味着重新绑定仅在由 call(...)
引入的嵌套动态作用域中可见。log(...)
的主体无法更改该方法自身的绑定,但可以更改其被调用者(例如 formatter.get(...)
方法)所看到的绑定。这保证了新值共享的生命周期是有限的。
继承作用域值
Web 框架示例会为每个请求分配一个线程,因此同一个线程可以执行来自服务器组件的框架代码,然后执行来自应用开发者的用户代码,再执行来自数据访问组件的更多框架代码。然而,用户代码可以通过创建自己的虚拟线程并在其中运行自己的代码,来利用虚拟线程的轻量特性。这些虚拟线程将成为请求处理线程的子线程。
在请求处理线程中运行的组件所共享的数据需要可供在子线程中运行的组件使用。否则,当在子线程中运行的用户代码调用数据访问组件时,该组件(现在同样在子线程中运行)将无法检查由在请求处理线程中运行的服务器组件共享的 Principal
。为了实现跨线程共享,作用域值可以被子线程继承。
用户代码创建虚拟线程的首选机制是结构化并发 API(JEP 428),特别是 StructuredTaskScope
类。父线程中的作用域值会被 StructuredTaskScope
创建的子线程自动继承。子线程中的代码可以使用父线程中为作用域值建立的绑定,且开销极小。与线程局部变量不同,父线程的作用域值绑定不会复制到子线程。
以下是一个示例,展示了在用户代码背后发生的范围值继承,这是从 Server.serve(...)
调用的 Application.handle(...)
方法的一个变体。用户代码调用了 StructuredTaskScope.fork(...)
(1,2),以在其自己的虚拟线程中并发运行 findUser()
和 fetchOrder()
方法。每个方法都调用了数据访问组件(3),该组件像之前一样查询了范围值 PRINCIPAL
(4)。这里不讨论用户代码的更多细节;更多信息请参见 JEP 428。
class Application {
Response handle() throws ExecutionException, InterruptedException {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> findUser()); // (1)
Future<Integer> order = scope.fork(() -> fetchOrder()); // (2)
scope.join().throwIfFailed(); // Wait for both forks
return new Response(user.resultNow(), order.resultNow());
}
}
String findUser() {
... DBAccess.open() ... // (3)
}
}
class DBAccess {
DBConnection open() {
var principal = Server.PRINCIPAL.get(); // (4)
if (!principal.canOpen()) throw new InvalidPrincipalException();
return newConnection(...);
}
}
StructuredTaskScope.fork(...)
确保了在请求处理线程中对作用域值 PRINCIPAL
的绑定 —— 当 Server.serve(...)
调用 ScopedValue.where(...)
时 —— 会自动对子线程中的 PRINCIPAL.get()
可见。下图展示了该绑定的动态作用域如何扩展到子线程中执行的所有方法:
Thread 1 Thread 2
-------- --------
8. DBAccess.newConnection()
7. DBAccess.open() <----------+
... |
... Principal(ADMIN)
4. Application.findUser() |
3. StructuredTaskScope.fork(..) |
2. Application.handle(..) |
1. Server.serve(..) ---------------------------------------------+
StructuredTaskScope
提供的 fork/join 模型意味着绑定的动态范围仍然受到 ScopedValue.where(...).run(...)
调用生命周期的限制。在子线程运行期间,Principal
会一直保持在范围内,而 scope.join()
确保了子线程会在 run(...)
返回并销毁绑定之前终止。这就避免了使用线程局部变量时出现的无限制生命周期问题。
迁移到作用域值
在许多目前使用线程局部变量的场景中,作用域值可能更有用且更可取。除了充当隐藏的方法参数外,作用域值还可以帮助实现:
- 可重入代码 — 有时检测递归是很有必要的,可能是因为某个框架不是可重入的,或者递归必须以某种方式受到限制。作用域值提供了一种实现方法:像平常一样使用
ScopedValue.where(...)
和run(...)
设置它,然后在调用栈深处,调用ScopedValue.isBound()
来检查当前线程是否绑定了值。更复杂一点,可以通过重复绑定,将作用域值建模为递归计数器。 - 嵌套事务 — 在扁平化事务的情况下,检测递归也很有用:任何在事务进行过程中启动的事务都会成为最外层事务的一部分。
- 图形上下文 — 另一个例子出现在图形处理中,通常需要在程序的不同部分之间共享绘图上下文。由于作用域值具有自动清理和可重入性,它们比线程局部变量更适合这种场景。
通常来说,当线程局部变量的用途与作用域值的目标一致时(即单向传递不变的数据),我们建议迁移到作用域值。如果代码库以双向方式使用线程局部变量 —— 例如,调用栈深处的被调用方通过 ThreadLocal.set(...)
向远处的调用方传递数据 —— 或者以完全非结构化的方式使用,则迁移不是一个可行的选择。
有一些场景适合使用线程本地变量。例如,缓存创建和使用成本较高的对象,比如 java.text.DateFormat
的实例。众所周知,DateFormat
对象是可变的,因此在没有同步的情况下不能在线程之间共享。通过一个在该线程生命周期内持久存在的线程本地变量,为每个线程提供自己的 DateFormat
对象,通常是一种实用的方法。
替代方案
虽然在线程局部变量的内存占用、安全性和性能方面会有一些损失,但可以使用线程局部变量来模拟作用域值的许多特性。
我们尝试了一个修改版的 ThreadLocal
,它支持范围值的部分特性。然而,携带线程局部变量的额外负担会导致实现过于繁琐,或者其核心功能的大部分 API 返回 UnsupportedOperationException
,或者两者兼而有之。因此,最好不要修改 ThreadLocal
,而是将范围值作为一个完全独立的概念引入。
作用域值的灵感来源于许多 Lisp 方言对动态作用域自由变量的支持方式;特别是这些变量在深度绑定、多线程运行时(如 Interlisp-D)中的行为。作用域值通过添加类型安全、不可变性、封装性以及在线程内和跨线程的高效访问,改进了 Lisp 的自由变量。