Skip to content
微信扫码关注公众号

JEP 429: Scoped Values (Incubator) | 限定值(孵化中)

摘要

引入 作用域值,它们能够在单个线程内部及其子线程间共享不可变数据。相比于线程局部变量,作用域值特别适用于大量虚拟线程的情况。这是一个处于孵化阶段的 API(参见 OpenJDK JEP 11)。

目标

  • 易用性 — 提供一种编程模型以在单个线程内部及子线程间共享数据,从而简化对数据流的推理。

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

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

  • 性能 — 将共享数据视为不可变的,以便于大量线程之间共享,并且能够启用运行时优化。

非目标

  • 不改变 Java 编程语言本身。

  • 不强制迁移远离线程局部变量,也不废弃现有的 ThreadLocal API。

动机

大型 Java 程序通常包含需要相互间共享数据的不同且互补的组件。例如,一个 Web 框架可能包括一个采用“每个请求一个线程”风格 (JEP 425) 实现的服务器组件,以及一个处理持久化的数据访问组件。在整个框架中,用户认证和授权依赖于组件间共享的一个 Principal 对象。服务器组件为处理每个请求的线程创建一个 Principal 对象,而数据访问组件则引用线程的 Principal 对象来控制对数据库的访问。

下图展示了框架处理两个请求的过程,每个请求都在其独立的线程中。请求处理流程向上,从服务器组件 (Server.serve(...)) 到用户代码 (Application.handle(...)) 再到数据访问组件 (DBAccess.open())。数据访问组件确定线程是否被允许访问数据库,具体如下:

  • 在线程 1 中,服务器组件创建的 ADMIN 类型的 Principal 允许访问数据库。虚线表示该 Principal 将被共享给数据访问组件,后者检查该 Principal 并继续调用 DBAccess.newConnection()

  • 在线程 2 中,服务器组件创建的 GUEST 类型的 Principal 不允许访问数据库。数据访问组件检查 Principal 后,判断用户代码不应继续执行,并抛出 InvalidPrincipalException 异常。

plaintext
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)。

java
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(...) 方法完成后,绑定被销毁。

java
final static ScopedValue<...> V = ScopedValue.newInstance();

// 在某个方法中
ScopedValue.where(V, <value>)
           .run(() -> { ... V.get() ... call methods ... });

// 在直接或间接从 lambda 表达式调用的方法中
... V.get() ...

代码的语法结构界定了线程可以读取其作用域值实例的时间段。这个有限的生命周期加上不可变性极大地简化了对线程行为的推理。从调用者到被调用者(直接和间接)的一次性数据传输一目了然。没有 set(...) 方法允许远处的代码随时更改作用域值。不可变性也有助于提高性能:使用 get() 读取作用域值通常与读取局部变量一样快,无论调用者和被调用者之间的栈距离有多远。

“作用域”的含义

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

另一种类型的作用域称为 动态作用域。事物的动态作用域指的是程序执行过程中可以使用该事物的部分。这就是 作用域值 所涉及的概念,因为在一个 run(...) 方法中绑定一个作用域值 V 会产生一个 V 的实例,该实例可以在程序执行过程中由某些部分使用,具体地说,就是直接或间接由 run(...) 调用的方法。这些方法的执行定义了一个动态作用域;该实例在这些方法的执行期间有效,而在其他地方则无效。

使用作用域值的 Web 框架示例

前面展示的框架代码可以很容易地改写为使用作用域值而不是线程局部变量。在 (1),服务器组件声明了一个作用域值而不是线程局部变量。在 (2),服务器组件调用 ScopedValue.where(...)run(...) 而不是线程局部变量的 set(...) 方法。

java
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(...) 调用的代码中可用。如果 PRINCIPAL.get() 出现在 Server.serve(...)run(...) 调用之后的位置,将会抛出异常,因为此时 PRINCIPAL 已不再在线程中绑定。

重新绑定作用域值

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

作为一个例子,考虑 Web 框架的第三个组件:一个具有方法 void log(Supplier<String> formatter) 的日志记录组件。用户代码向 log(...) 方法传递一个 lambda 表达式;如果启用了日志记录,则该方法会调用 formatter.get() 来评估 lambda 表达式,然后打印结果。虽然用户代码可能有权限访问数据库,但是 lambda 表达式不应该有这样的权限,因为它只需要格式化文本。因此,最初在 Server.serve(...) 中绑定的作用域值应该在 formatter.get() 的生命期内重新绑定为访客 Principal

plaintext
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(...) 传递了一个恶意的 lambda 表达式,该表达式执行 DBAccess.open(),那么 DBAccess.open() 中的检查将从 PRINCIPAL 读取访客 Principal 并抛出 InvalidPrincipalException

java
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(...) 来调用 formatter,因为需要 lambda 表达式的结果。)where(...)call(...) 的语法结构意味着重新绑定仅在 call(...) 引入的嵌套动态作用域中可见。log(...) 方法的主体不能改变该方法自身所见的绑定,但可以改变其被调用者(如 formatter.get(...) 方法)所见的绑定。这保证了新值共享的生命周期是有限的。

继承作用域值

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

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

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

下面是在用户代码中发生的、作用域值继承的一个示例,这是一个 Application.handle(...) 方法的变化版本,该方法被 Server.serve(...) 调用。用户代码调用 StructuredTaskScope.fork(...)(1, 2)来并行运行 findUser()fetchOrder() 方法,这些方法在各自的虚拟线程中运行。每个方法都调用数据访问组件(3),该组件像之前那样查询作用域值 PRINCIPAL(4)。此处不讨论用户代码的更多细节;更多信息请参阅 JEP 428

java
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() 自动可见。下图展示了绑定的动态作用域是如何扩展到子线程中执行的所有方法的:

plaintext
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,它支持作用域值的一些特性。但是,携带额外的线程局部变量负担会导致一个实施过于繁琐的实现,或者是一个核心功能大量返回 UnsupportedOperationException 的 API,或者两者兼有。因此,最好是不要修改 ThreadLocal,而是将作用域值作为一个完全独立的概念引入。

作用域值受到了许多 Lisp 方言中动态作用域自由变量的支持方式的启发;特别是,这些变量在如 Interlisp-D 这样的深度绑定、多线程运行时环境中的行为。与 Lisp 中的自由变量相比,作用域值通过添加类型安全性、不可变性、封装以及在同一线程和跨线程之间的高效访问进行了改进。