跳到主要内容

JEP 429:范围值(孵化器)

概括

引入_作用域值_,它可以在线程内和线程间共享不可变数据。它们优于线程局部变量,特别是在使用大量虚拟线程时。这是一个正在孵化的API

目标

  • 易用性——提供一个编程模型来在线程内和子线程之间共享数据,从而简化数据流的推理。

  • 可理解性——使共享数据的生命周期从代码的语法结构中可见。

  • 鲁棒性——确保调用者共享的数据只能由合法的被调用者检索。

  • 性能——将共享数据视为不可变,以便允许大量线程共享,并实现运行时优化。

非目标

  • 改变 Java 编程语言并不是目标。

  • 要求迁移线程局部变量或弃用现有 API 并不是我们的目标ThreadLocal

动机

大型 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在服务器组件调用用户代码以及用户代码调用数据访问组件时将 a 作为方法参数传递。线程局部变量充当一种隐藏方法参数:PRINCIPAL.set(...)调用Server.serve(...)然后再PRINCIPAL.get()调用的线程DBAccess.open()将自动看到它自己的PRINCIPAL变量化身。实际上,该ThreadLocal字段充当用于查找Principal当前线程的值的键。

线程局部变量的问题

不幸的是,线程局部变量有许多无法避免的设计缺陷:

  • 无约束的可变性——每个线程局部变量都是可变的:任何可以调用get()线程局部变量方法的代码都可以set(...)随时调用该变量的方法。 APIThreadLocal允许这样做是为了支持完全通用的通信模型,其中数据可以在组件之间的任何方向流动。然而,这可能会导致类似意大利面条的数据流,并且导致难以辨别哪个组件更新共享状态以及以什么顺序更新的程序。如上例所示,更常见的需求是从一个组件到其他组件的简单单向数据传输。

  • 无界生命周期— 一旦通过方法写入了线程局部变量的化身set(...),该化身将在线程的生命周期内保留,或者直到线程中的代码调用该remove()方法为止。不幸的是,开发人员经常忘记调用remove(),因此每个线程的数据通常保留的时间超过必要的时间。此外,对于依赖线程局部变量的无约束可变性的程序,可能没有明确的点可以安全地调用线程remove();这可能会导致长期内存泄漏,因为在线程退出之前不会对每个线程的数据进行垃圾收集。如果每个线程数据的写入和读取发生在线程执行期间的有限时间内,那就更好了,避免了泄漏的可能性。

  • 昂贵的继承——当使用大量线程时,线程局部变量的开销可能会更大,因为父线程的线程局部变量可以被子线程继承。 (事实上​​,线程局部变量对于一个线程来说并不是局部的。)当开发人员选择创建一个继承线程局部变量的子线程时,子线程必须为先前写入的每个线程局部变量分配存储空间。父线程。这会显着增加内存占用。子线程无法共享父线程使用的存储,因为线程局部变量是可变的,并且ThreadLocalAPI 要求一个线程中的变化在其他线程中不可见。这是不幸的,因为实际上子线程很少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() ...

代码的语法结构描述了线程可以读取其作用域值的时间段。这种有限的生命周期与不变性相结合,极大地简化了线程行为的推理。从调用者到被调用者的单向数据传输(直接和间接)一目了然。没有任何set(...)方法可以让远程代码随时更改作用域值。不变性也有助于提高性能:读取作用域值get()通常与读取局部变量一样快,无论调用者和被调用者之间的堆栈距离如何。

“范围”的含义

事物的_范围_是指它存在的空间——它可以使用的程度或范围。例如,在 Java 编程语言中,变量声明的范围是程序文本中的空间,在该空间中使用简单名称引用变量是合法的(JLS 6.3)。这种作用域更准确地称为_词法作用域_或_静态作用域_,因为可以通过在程序文本中查找{和字符来静态地理解变量在作用域中的空间。}

另一种作用域称为_动态作用域_。事物的动态范围是指程序执行时可以使用该事物的部分。这是_作用域值_所涉及的概念,因为在方法中绑定作用域值 Vrun(...)会产生 V 的化身,该化身可供程序执行时的某些部分使用,即由 直接或间接调用的方法run(...)。这些方法的展开执行定义了一个动态范围;化身在这些方法执行期间处于作用域内,而不是其他地方。

具有范围值的 Web 框架示例

前面显示的框架代码可以轻松重写以使用作用域值而不是线程局部变量。在 (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(...),因此PRINCIPAL.get()在调用 from 的任何方法中run(...)都将读取该值。因此,当Server.serve(...)调用用户代码时,并且用户代码调用 时DBAccess.open(),从作用域值 (3) 中读取的值是Server.serve(...)线程中较早写入的值。

由 建立的绑定run(...)仅在由 调用的代码中可用run(...)。如果PRINCIPAL.get()出现在Server.serve(...)调用之后run(...),则会抛出异常,因为PRINCIPAL不再绑定在线程中。

重新绑定范围值

作用域值的不变性意味着调用者可以使用作用域值将常量值可靠地传递给同一线程中的被调用者。然而,有时被调用者之一可能需要使用相同作用域的值来向线程中其自己的被调用者传达不同的值。 APIScopedValue允许为嵌套调用建立新的绑定。

作为示例,考虑 Web 框架的第三个组件:具有方法的日志记录组件void log(Supplier<String> formatter)。用户代码将 lambda 表达式传递给log(...)方法;如果启用了日志记录,该方法会formatter.get()调用计算 lambda 表达式,然后打印结果。尽管用户代码可能具有访问数据库的权限,但 lambda 表达式不应具有访问数据库的权限,因为它只需要格式化文本。因此,最初绑定的作用域值应在以下生命周期内Server.serve(...)反弹给来宾:Principal``formatter.get()

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(...)。它获取一个 guest Principal(1) 并将其作为作用域值PRINCIPAL(2) 的新绑定进行传递。在调用call(3) 的生命周期内,PRINCIPAL.get()将读取这个新值。因此,如果用户代码将恶意 lambda 表达式传递给log(...)执行DBAccess.open(),则签入将从中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(...)and的语法结构call(...)意味着重新绑定仅在 引入的嵌套动态作用域中可见call(...)。的主体log(...)无法更改该方法本身看到的绑定,但可以更改其被调用者(例如方法)看到的绑定formatter.get(...)。这保证了共享新值的有限生命周期。

继承范围值

Web 框架示例专用一个线程来处理每个请求,因此同一线程可以执行来自服务器组件的框架代码,然后执行来自应用程序开发人员的用户代码,然后执行来自数据访问组件的更多框架代码。然而,用户代码可以通过创建自己的虚拟线程并在其中运行自己的代码来利用虚拟线程的轻量级特性。这些虚拟线程将是请求处理线程的子线程。

在请求处理线程中运行的组件共享的数据需要可供在子线程中运行的组件使用。否则,当在子线程中运行的用户代码调用数据访问组件时,该组件(现在也在子线程中运行)将无法检查Principal在请求处理线程中运行的服务器组件共享的数据。为了启用跨线程共享,作用域值可以由子线程继承。

用户代码创建虚拟线程的首选机制是结构化并发 API ( JEP 428 ),特别是类StructuredTaskScope。父线程中的作用域值会自动被使用 创建的子线程继承StructuredTaskScope。子线程中的代码可以使用为父线程中的作用域值建立的绑定,并且开销最小。与线程局部变量不同,不会将父线程的作用域值绑定复制到子线程。

下面是在用户代码中幕后发生的作用域值继承的示例,采用Application.handle(...)from 调用的方法的变体Server.serve(...)。用户代码调用(1, 2)在它们自己的虚拟线程中同时StructuredTaskScope.fork(...)运行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(..) ---------------------------------------------+

提供的 fork/join 模型意味着StructuredTaskScope绑定的动态范围仍然受到调用的生命周期的限制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 的自由变量。