跳到主要内容

JEP 464:范围值(第二预览版)

概括

引入_作用域值_,它允许与同一线程中的子帧以及子线程管理不可变数据的共享。作用域值比线程局部变量更容易推理,并且空间和时间成本更低,特别是与虚拟线程结构化并发结合使用时。这是一个预览 API

历史

范围值通过JEP 429在 JDK 20 中孵化,并通过JEP 446成为 JDK 21 中的预览 API 。我们在此建议在 JDK 22 中重新预览 API,无需进行任何更改,以获得额外的经验和反馈。

目标

  • 易于使用——应该很容易推理数据流。

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

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

  • 性能——数据可以在大量线程之间有效地共享。

非目标

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

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

动机

Java 应用程序和库被构造为包含方法的类的集合。这些方法通过方法调用进行通信。

大多数方法允许调用者通过将数据作为参数传递来将数据传递给方法。当方法A希望方法B为它做一些工作时,它会B使用适当的参数进行调用,并且B可能会将其中一些参数传递给等等CB可能必须在其参数列表中不仅包括直接需要的东西,而且还包括必须传递的B东西BC。例如,如果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 中引入的_线程局部变量_来帮助在调用堆栈上的方法之间共享数据,而无需求助于方法参数。线程局部变量是类型的变量ThreadLocal。尽管看起来像普通变量,但线程局部变量每个线程都有一个当前值;使用的特定值取决于哪个线程调用其值getset方法来读取或写入其值。通常,线程局部变量被声明为最终静态字段,并且其可访问性设置为私有,从而允许共享仅限于来自单个代码库的单个类或一组类的实例。

下面的示例说明了两个框架方法(都在同一请求处理线程中运行)如何使用线程局部变量来共享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在框架调用用户代码以及用户代码回调框架方法时将 a 作为方法参数传递。线程局部变量充当隐藏的方法参数:调用CONTEXT.setinFramework.serve然后CONTEXT.get()in的线程Framework.readKey将自动看到它自己的CONTEXT变量的本地副本。实际上,该ThreadLocal字段充当用于查找FrameworkContext当前线程的值的键。

虽然在每个线程中设置了不同的值,但如果使用的是类而不是类,则ThreadLocals当前线程创建的另一个线程可以自动继承一个线程中当前设置的值。InheritableThreadLocal``ThreadLocal

线程局部变量的问题

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

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

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

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

迈向轻量级共享

随着虚拟线程的可用性(JEP 444),线程局部变量的问题变得更加紧迫。虚拟线程是JDK实现的轻量级线程。许多虚拟线程共享相同的操作系统线程,从而允许大量虚拟线程。除了数量充足之外,虚拟线程还足够便宜,可以表示任何并发的行为单元。这意味着 Web 框架可以将新的虚拟线程专门用于处理请求的任务,并且仍然能够同时处理数千或数百万个请求。在正在进行的示例中,方法Framework.serveApplication.handleFramework.readKey都将在每个传入请求的新虚拟线程中执行。

无论这些方法是在虚拟线程还是传统平台线程中执行,如果它们能够共享数据,这将非常有用。因为虚拟线程是 的实例Thread,所以虚拟线程可以具有线程局部变量;事实上,虚拟线程的短暂、非池化特性使得上面提到的长期内存泄漏问题变得不那么严重。 (当线程快速终止时,调用线程局部变量的remove()方法是不必要的,因为终止会自动删除其线程局部变量。)但是,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,则内存占用可能会增加具有重要意义。

总之,线程局部变量比共享数据通常所需的复杂性更高,而且成本也无法避免。 Java 平台应该提供一种方法来维护数千或数百万虚拟线程的可继承的每线程数据。如果这些每线程变量是不可变的,它们的数据就可以被子线程有效地共享。此外,这些每线程变量的生命周期应该受到限制:一旦最初共享数据的方法完成,通过每线程变量共享的任何数据都应该变得不可用。

描述

_作用域值_是一个容器对象,它允许方法与其同一线程内的直接和间接被调用者以及子线程安全有效地共享数据值,而无需借助方法参数。它是一个 类型的变量ScopedValue。通常,它被声明为一个final static字段,并且其可访问性被设置private为不能被其他类中的代码直接访问。

与线程局部变量一样,作用域值具有多个与其关联的值,每个线程一个值。使用的特定值取决于哪个线程调用其方法。与线程局部变量不同,作用域值写入一次,并且仅在线程执行期间的有限时间内可用。

使用范围值如下所示。某些代码调用ScopedValue.where,提供作用域值及其要绑定的对象。对run _绑定_作用域值的调用,提供特定于当前线程的副本,然后执行作为参数传递的 lambda 表达式。在调用的生命周期内run,lambda 表达式或从该表达式直接或间接调用的任何方法都可以通过值的get()方法读取作用域值。该方法完成后run,绑定将被销毁。

final static ScopedValue<...> NAME = ScopedValue.newInstance();

// In some method
ScopedValue.where(NAME, <value>)
.run(() -> { ... NAME.get() ... call methods ... });

// In a method called directly or indirectly from the lambda expression
... NAME.get() ...

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

“范围”的含义

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

另一种作用域称为_动态作用域_。事物的动态范围是指程序执行时可以使用该事物的部分。如果 方法a调用 方法b,而该方法又调用 方法c,则 的执行生命周期c包含在 的执行中b,而 的执行又包含在 的执行中a,即使这三个方法是不同的代码单元:

|
| +–– a
| |
| | +–– b
| | |
TIME | | +–– c
| | | |
| | | |__
| | |
| | |__
| |
| |__
|
v

_这是作用域值_所涉及的概念,因为在方法中绑定作用域值 Vrun会生成一个在程序执行时可由程序的某些部分访问的值,即直接或间接调用的方法run

这些方法的展开执行定义了一个动态范围;绑定在这些方法执行期间位于范围内,而不是其他地方。

具有范围值的 Web 框架示例

前面显示的框架代码可以轻松重写以使用作用域值而不是线程局部变量。在 (1) 处,框架声明了一个作用域值而不是线程局部变量。在 (2) 处,serve 方法调用ScopedValue.whererun而不是线程局部变量的set方法。

class Framework {
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单向数据共享。传递给的作用域值在调用的生命周期内绑定到相应的对象,因此在调用 from 的任何方法中都将读取该值。因此,当调用用户代码时,并且用户代码调用 时,从作用域值 (3) 中读取的值是线程中较早写入的值。serve``readKey``where``run``CONTEXT.get()``run``Framework.serve``Framework.readKey``Framework.serve

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

和以前一样,框架依赖 Java 的访问控制来限制对其内部数据的访问:字段CONTEXT具有私有访问权限,这允许框架在其两个方法之间内部共享信息。用户代码无法访问并且隐藏该信息。我们说该ScopedValue对象是一个_能力_对象,它赋予具有访问权限的代码绑定或读取值的能力。通常,它们ScopedValue将具有private访问权限,但有时它可能具有protected或包访问权限,以允许多个协作类读取和绑定该值。

重新绑定范围值

作用域值没有set()方法意味着调用者可以使用作用域值将值可靠地传递给同一线程中的被调用者。然而,有时其被调用者之一可能需要使用相同作用域的值来向其自己的被调用者传达不同的值。该ScopedValueAPI 允许为后续调用建立新的嵌套绑定:

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读取to be的值"hello",因为它是在 中建立的范围内的绑定foo。但随后bar建立一个嵌套范围来运行绑定到的baz位置。X``goodbye

请注意绑定如何"goodbye"仅在嵌套范围内有效。一旦返回, insidebaz的值将恢复为“hello”。的主体无法更改该方法本身看到的绑定,但可以更改其被调用者看到的绑定。退出后,恢复为未绑定状态。这种嵌套保证了共享新值的有限生命周期。X``bar``bar``foo``X

继承范围值

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

请求处理线程中运行的代码共享的上下文数据需要可供子线程中运行的代码使用。否则,当在子线程中运行的用户代码调用框架方法时,它将无法访问FrameworkContext由在请求处理线程中运行的框架代码创建的方法。为了启用跨线程共享,作用域值可以由子线程继承。

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

下面是用户代码中幕后发生的作用域值继承的示例。该Server.serve方法的绑定CONTEXT和调用Application.handle与以前一样。但是,Application.handle调用中的用户代码使用(1, 2)并行运行readUserInfo()fetchOffers()方法,每个方法都在其自己的虚拟线程中。StructuredTaskScope.fork每个方法都可以Framework.readKey像以前一样使用它来查阅范围值CONTEXT(4)。这里不讨论用户代码的更多细节;请参阅JEP 453了解更多信息。

@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 --------------------------------------------+

提供的 fork/join 模型意味着StructuredTaskScope绑定的动态范围仍然受到调用的生命周期的限制ScopedValue.where(...).run(...)Principal当子线程运行时,它将保留在范围内,并scope.join()确保子线程在run返回之前终止,从而破坏绑定。这避免了使用线程局部变量时出现的无限生命周期问题。诸如此类的遗留线程管理类ForkJoinPool不支持作用域值的继承,因为它们无法保证从某个父线程作用域分叉的子线程将在父线程离开该作用域之前退出。

迁移到范围值

在当今使用线程局部变量的许多场景中,作用域值可能是有用且更可取的。除了用作隐藏方法参数之外,作用域值还可以帮助:

  • 可重入代码——有时需要检测递归,可能是因为框架不可重入,或者因为必须以某种方式限制递归。作用域值提供了一种执行此操作的方法:像往常一样使用ScopedValue.where和进行设置run,然后在调用堆栈深处调用ScopedValue.isBound()以检查它是否具有当前线程的绑定。更详细地说,作用域值可以通过重复反弹来建模递归计数器。

  • 嵌套事务- 检测递归在扁平事务的情况下也很有用:事务正在进行时启动的任何事务都会成为最外层事务的一部分。

  • 图形上下文- 另一个例子发生在图形中,其中通常有一个绘图上下文要在程序的各个部分之间共享。作用域值由于其自动清理和重入性而比线程局部变量更适合这种情况。

一般来说,当线程局部变量的目的与作用域值的目标一致时,我们建议迁移到作用域值:单向传输不变的数据。如果代码库以双向方式使用线程局部变量(调用堆栈深处的被调用者通过以下方式将数据传输到远处的调用者ThreadLocal.set)或以完全非结构化的方式使用,则迁移不是一种选择。

有一些场景有利于线程局部变量。一个例子是缓存创建和使用成本高昂的对象,例如java.text.DateFormat.众所周知,对象的实例java.text.SimpleDateFormat是可变的,因此如果没有同步,就无法在线程之间共享它。SimpleDateFormat通过在线程生命周期内持续存在的线程局部变量为每个线程提供自己的对象通常是一种实用的方法。但如今,任何缓存 a 的代码SimpleDateFormat都可以转而使用,DateTimeFormatter因为它可以存储在static final字段中并在线程之间共享。

应用ScopedValue程序编程接口

完整的ScopedValueAPI 比上述的小子集更丰富。虽然此 JEP 仅提供使用 的示例ScopedValue<V>.where(V, <value>).run(aRunnable),但还有更多方法可以绑定作用域值。例如,API 还提供了一个Callable返回值的版本,也可能抛出一个Exception

try {
var result = ScopedValue.where(X, "hello").call(() -> bar());
catch (Exception e) {
handleFailure(e);
}
...

此外,还有绑定方法的缩写版本。例如,ScopedValue<V>.runWhere(V, <value>, aRunnable)是 的缩写形式ScopedValue<V>.where(V, <value>).run(aRunnable)。虽然这种简短形式有时很方便,但它一次只允许绑定一个作用域值。

完整范围的值 API 可在此处找到

备择方案

可以使用线程局部变量来模拟作用域值的许多功能,尽管会在内存占用、安全性和性能方面付出一些代价。

ThreadLocal我们尝试了支持范围值的一些特征的修改版本。然而,携带线程局部变量的额外包袱会导致实现过于繁重,或者导致 API 返回UnsupportedOperationException其大部分核心功能,或者两者兼而有之。因此,最好不要修改,ThreadLocal而是将范围值作为一个完全独立的概念引入。

作用域值的灵感来自于许多 Lisp 方言提供对动态作用域自由变量的支持;特别是,这些变量在深度绑定、多线程运行时(例如 Interlisp-D)中的行为方式。作用域值通过添加类型安全性、不变性、封装以及线程内和线程间的高效访问来改进 Lisp 的自由变量。