JEP 487:范围值(第四个预览版)
概括
引入_范围值_,使方法能够与线程内的调用方以及子线程共享不可变数据。范围值比线程局部变量更容易推理。它们还具有较低的空间和时间成本,尤其是与虚拟线程 ( JEP 444 ) 和结构化并发 ( JEP 480 ) 一起使用时。这是一个预览 API。
历史
范围值 API 由JEP 429 (JDK 20)提出孵化,由JEP 446 (JDK 21)提出预览,随后由JEP 464(JDK 22)和JEP 481(JDK 23)进行改进和完善。
我们在此建议在 JDK 24 中再次重新预览 API,以便获得更多经验和反馈,并进行另一项更改:
- 我们从类中删除了
callWhere
和方法,使 API 完全流畅。使用一个或多个绑定范围值的唯一方法是通过和方法。runWhere``ScopedValue``ScopedValue.Carrier.call``ScopedValue.Carrier.run
目标
-
易于使用——应该易于推理数据流。
-
可理解性——共享数据的生命周期应该从代码的语法结构中显而易见。
-
稳健性——只有合法的被调用者才能检索调用者共享的数据。
-
性能——数据应该能够在大量线程之间有效共享。
非目标
-
改变 Java 编程语言并不是我们的目标。
-
我们的目标并不是要求迁移出线程局部变量或者弃用现有的
ThreadLocal
API。
动机
Java 应用程序和库的结构是包含方法的类的集合。这些方法通过方法调用进行通信。
大多数方法允许调用者通过将数据作为参数传递来将数据传递给方法。当方法A
需要方法B
为其执行某些工作时,它会B
使用适当的参数进行调用,并B
可能将其中一些参数传递给C
,等等。B
可能必须在其参数列表中不仅包含直接需要的内容,还包含必须传递给 的B
内容。例如,如果准备设置并执行数据库调用,则可能需要传入一个 Connection,即使不直接使用 Connection。B``C``B``B
大多数情况下,这种“传递间接调用方所需的数据”方法是共享数据最有效、最方便的方法。但是,有时在初始调用中传递每个间接调用方可能需要的所有数据是不切实际的。
一个例子
在大型 Java 程序中,将控制权从一个组件(“框架”)转移到另一个组件(“应用程序代码”)并返回是一种常见模式。例如,Web 框架可以接受传入的 HTTP 请求,然后调用应用程序处理程序来处理它。然后,应用程序处理程序可以调用框架从数据库读取数据或调用其他 HTTP 服务。
@Override
public void handle(Request request, Response response) {
// user code, called by framework
...
var userInfo = readUserInfo();
...
}
private UserInfo readUserInfo() {
// call framework
return (UserInfo)framework.readKey("userInfo", context);
}
框架可能会维护一个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
。尽管看起来像普通变量,但线程局部变量每个线程都有一个当前值;使用的特定值取决于哪个线程调用其get
或set
方法来读取或写入其值。通常,线程局部变量被声明为字段static final
,其可访问性设置为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 static final 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
在框架调用用户代码时以及用户代码调用框架方法时将作为方法参数传递的需要。线程局部变量充当隐藏的方法参数:调用CONTEXT.set
inFramework.serve
然后调用CONTEXT.get
in的线程Framework.readKey
将自动看到其自己的变量本地副本CONTEXT
。实际上,该ThreadLocal
字段充当用于查找FrameworkContext
当前线程值的键。
虽然ThreadLocals
在每个线程中设置了一个不同的值,但是当前在一个线程中设置的值可以被当前线程使用该类InheritableThreadLocal
而不是ThreadLocal
该类创建的另一个线程自动继承。
线程局部变量的问题
不幸的是,线程局部变量有三个固有的设计缺陷。
-
不受约束的可变性— 每个线程局部变量都是可变的:任何可以调用
get
线程局部变量方法的代码都可以set
随时调用该变量的方法。即使线程局部变量中的对象由于其每个字段都被声明为 final 而不可变,情况仍然如此。APIThreadLocal
允许这样做是为了支持完全通用的通信模型,其中数据可以在方法之间以任何方向流动。这可能导致意大利面条式的数据流,并且程序中很难辨别哪个方法更新共享状态以及以何种顺序更新。如上例所示,更常见的需求是从一种方法到其他方法的简单单向数据传输。 -
无限制的生命周期— 一旦通过方法设置了线程的线程局部变量副本
set
,则设置的值将保留在线程的整个生命周期内,或者直到线程中的代码调用该remove
方法。不幸的是,开发人员经常忘记调用remove
,因此每个线程的数据通常会保留比必要更长的时间。特别是,如果使用线程池,如果没有正确清除,则在一个任务中设置的线程局部变量的值可能会意外泄漏到不相关的任务中,从而可能导致危险的安全漏洞。此外,对于依赖于线程局部变量不受约束的可变性的程序,可能没有明确的点可以让线程安全地调用remove
;这可能会导致长期内存泄漏,因为每个线程的数据在线程退出之前不会被垃圾收集。如果每个线程数据的写入和读取发生在线程执行期间的有限时间内,那就更好了,可以避免泄漏的可能性。 -
昂贵的继承——当使用大量线程时,线程局部变量的开销可能会更大,因为父线程的线程局部变量可以被子线程继承。(实际上,线程局部变量并不是某个线程的局部变量。)当开发人员选择创建继承线程局部变量的子线程时,子线程必须为先前在父线程中写入的每个线程局部变量分配存储空间。这会增加大量的内存占用。子线程无法共享父线程使用的存储空间,因为 API
ThreadLocal
要求更改线程的线程局部变量副本不能在其他线程中看到。这很不幸,因为实际上子线程很少调用set
其继承的线程局部变量上的方法。
迈向轻量级共享
随着虚拟线程 ( JEP 444 )的推出,线程局部变量的问题变得更加紧迫。虚拟线程是 JDK 实现的轻量级线程。许多虚拟线程共享同一个操作系统线程,允许使用大量虚拟线程。除了数量众多之外,虚拟线程还足够便宜,可以表示任何并发行为单元。这意味着 Web 框架可以将新的虚拟线程专用于处理请求的任务,并且仍然能够同时处理数千或数百万个请求。在正在进行的示例中,方法Framework.serve
、Application.handle
和Framework.readKey
都将在针对每个传入请求的新虚拟线程中执行。
如果这些方法能够共享数据,无论它们是在虚拟线程还是传统平台线程中执行,都会很有用。因为虚拟线程是的实例Thread
,所以虚拟线程可以具有线程局部变量;事实上,虚拟线程的短暂、非池化特性使上述长期内存泄漏问题不那么严重。(remove
当线程快速终止时,调用线程局部变量的方法是不必要的,因为终止会自动删除其线程局部变量。)但是,如果一百万个虚拟线程中的每一个都有自己的线程局部变量副本,则内存占用量可能很大。
总之,线程局部变量比通常共享数据所需的复杂度更高,并且不可避免地会产生大量成本。Java 平台应提供一种方法来维护数千或数百万个虚拟线程的可继承线程数据。如果这些线程变量是不可变的,则子线程可以有效地共享它们的数据。此外,这些线程变量的生命周期应该受到限制:一旦最初共享数据的方法完成,通过线程变量共享的任何数据都应变得不可用。
描述
_范围值_是一个容器对象,允许方法安全高效地与同一线程内的直接和间接调用者以及子线程共享数据值,而无需借助方法参数。它是 类型的变量ScopedValue
。它通常被声明为字段static final
,并且其可访问性设置为private
,以便其他类中的代码无法直接访问它。
与线程局部变量一样,作用域值具有多个与之关联的值,每个线程一个。使用的特定值取决于哪个线程调用其方法。与线程局部变量不同,作用域值只写入一次,并且仅在线程执行期间的一段有限时间内可用。
范围值使用方法如下所示。某些代码调用ScopedValue.where
,提供范围值和要绑定到的对象。方法的链式调用会run
绑定_范围_值,提供特定于当前线程的副本,然后运行作为参数传递的 lambda 表达式。在调用的生命周期内run
,lambda 表达式或从该表达式直接或间接调用的任何方法都可以通过值的get
方法读取范围值。run
方法完成后,绑定将被销毁。
static final 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
通常与读取局部变量一样快,无论调用者和被调用者之间的堆栈距离如何。
在其余的例子中,我们假设ScopedValue.where
已经被静态导入,如下所示:
import static java.lang.ScopedValue.where;
这使我们能够缩短ScopedValue.where(NAME, <value>).run(...)
至where(NAME, <value>).run(...)
“范围”的含义
事物的作用域是其生存的空间 — 即可以使用的范围或范围。例如,在 Java 编程语言中,变量声明的作用域是程序文本中可以使用简单名称引用变量的空间(JLS §6.3 )。这种作用域更准确地称为_词法作用域_或_静态作用域_,因为可以通过在程序文本中查找和字符来静态地了解变量在作用域中的位置。{``}
另一种作用域称为_动态作用域_。事物的动态作用域是指程序执行时可以使用该事物的部分。如果方法a
调用方法b
,而方法又调用方法c
,则的执行生命周期c
包含在的执行中b
,而的执行生命周期又包含在的执行中a
,尽管这三个方法是不同的代码单元:
|
| +–– a
| |
| | +–– b
| | |
TIME | | +–– c
| | | |
| | | |__
| | |
| | |__
| |
| |__
|
v
_这就是范围值_所吸引的概念,因为在run
(或)方法中绑定范围值 Vcall
会产生一个值,该值可由程序执行时的某些部分访问,即由 或 直接或间接调用的run
方法call
。
这些方法的展开执行定义了一个动态范围;绑定在这些方法执行期间处于范围内,而不在其他地方。
具有范围值的 Web 框架示例
前面显示的框架代码可以轻松重写为使用范围值而不是线程局部变量:
class Framework {
private static final ScopedValue<FrameworkContext> CONTEXT
= ScopedValue.newInstance(); // (1)
void serve(Request request, Response response) {
var context = createContext(request);
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);
}
}
在 (1) 处,框架声明了一个作用域值,而不是线程局部变量。在 (2) 处,serve
方法调用,where ... run
而不是线程局部变量的set
方法。
方法提供从方法到方法run
的单向数据共享。传递给 的范围值在调用的生命周期内绑定到相应的对象,因此在从 调用的任何方法中都会读取该值。因此,当调用用户代码时,并且用户代码调用 时,从范围值 (3) 读取的值是线程中先前写入的值。serve``readKey``run``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
方法意味着调用者可以使用范围值可靠地将值传递给同一线程中的被调用者。但是,有时它的一个被调用者可能需要使用相同的范围值将不同的值传递给自己的被调用者。该ScopedValue
API 允许为后续调用建立新的嵌套绑定:
private static final ScopedValue<String> X = ScopedValue.newInstance();
void foo() {
where(X, "hello").run(() -> bar());
}
void bar() {
System.out.println(X.get()); // prints hello
where(X, "goodbye").run(() -> 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
的值将恢复为“hello”。主体无法更改该方法本身看到的绑定,但可以更改其调用者看到的绑定。退出后,恢复为未绑定。这种嵌套保证了共享新值的有限生命周期。X``bar``bar``foo``X
继承范围值
Web 框架示例专门为处理每个请求设置一个线程,因此同一个线程会执行一些框架代码,然后执行来自应用程序开发人员的用户代码,然后执行更多框架代码来访问数据库。但是,用户代码可以通过创建自己的虚拟线程并在其中运行自己的代码来利用虚拟线程的轻量级特性。这些虚拟线程将是请求处理线程的子线程。
请求处理线程中运行的代码所共享的上下文数据需要可供子线程中运行的代码使用。否则,当子线程中运行的用户代码调用框架方法时,它将无法访问FrameworkContext
请求处理线程中运行的框架代码所创建的内容。为了实现跨线程共享,子线程可以继承范围值。
用户代码创建虚拟线程的首选机制是结构化并发 API ( JEP 480 ),具体来说是 类StructuredTaskScope
。父线程中的作用域值会自动被使用 创建的子线程继承StructuredTaskScope
。子线程中的代码可以以最小的开销使用为父线程中的作用域值建立的绑定。与线程局部变量不同,不会将父线程的作用域值绑定复制到子线程。
以下是在用户代码中后台发生的作用域值继承的示例。Server.serve
方法绑定CONTEXT
和调用Application.handle
与之前一样。但是,Application.handle
调用中的用户代码使用 (1, 2) 同时运行readUserInfo
和fetchOffers
方法,每个方法都在自己的虚拟线程中运行StructuredTaskScope.fork
。每个方法都可以使用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 --------------------------------------------+
提供的 fork/join 模型StructuredTaskScope
意味着绑定的动态范围仍然受对 的调用的生存期限制ScopedValue.run
。Principal
在子线程运行时, 将保持在范围内,并scope.join
确保子线程在run
返回之前终止,从而破坏绑定。 这避免了使用线程局部变量时出现的无限制生存期问题。 传统的线程管理类(例如)ForkJoinPool
不支持继承范围值,因为它们无法保证从某个父线程范围分叉的子线程将在父线程离开该范围之前退出。
迁移到范围值
在当今使用线程局部变量的许多场景中,作用域值可能很有用且更可取。除了用作隐藏方法参数之外,作用域值还可以帮助:
-
可重入代码— 有时需要检测递归,可能是因为框架不可重入,或者因为必须以某种方式限制递归。范围值提供了一种方法来实现这一点:像往常一样使用 进行设置
ScopedValue.run
,然后在调用堆栈深处调用ScopedValue.isBound
以检查它是否具有当前线程的绑定。更详细地说,范围值可以通过反复重新绑定来模拟递归计数器。 -
嵌套事务——在扁平事务的情况下,检测递归也很有用:在事务进行过程中启动的任何事务都将成为最外层事务的一部分。
-
图形上下文— 另一个例子出现在图形中,其中经常有一个绘图上下文在程序的各个部分之间共享。由于作用域值具有自动清理和可重入性,因此它们比线程局部变量更适合这种情况。
一般而言,当线程局部变量的目的与范围值的目标一致时,我们建议迁移到范围值:单向传输不变的数据。如果代码库以双向方式(调用堆栈深处的被调用者通过ThreadLocal.set
)或以完全非结构化的方式使用线程局部变量,则迁移不是一种选择。
有一些场景适合使用线程局部变量。一个例子是缓存创建和使用成本高昂的对象。java.text.SimpleDateFormat
例如,对象创建成本高昂,而且众所周知,对象也是可变的,因此它们不能在没有同步的情况下在线程之间共享。因此,SimpleDateFormat
通过线程局部变量为每个线程提供自己的对象(该变量在线程的整个生命周期内持续存在),通常是一种实用的方法。(不过,今天,任何缓存SimpleDateFormat
对象的代码都可以转而使用较新的java.util.time.DateTimeFormatter
,它可以存储在static final
字段中并在线程之间共享。)
APIScopedValue
完整的ScopedValue
API 比上面描述的子集更丰富。上面我们只展示了使用的示例ScopedValue<V>.where(V, <value>).run(...)
,但 API 还提供了一种call
返回值并可能引发异常的方法:
try {
var result = where(X, "hello").call(() -> bar());
... use result ...
catch (Exception e) {
handleFailure(e);
}
...
此外,我们可以在调用站点绑定多个范围值:
where(X, v).where(Y, w).run(() -> ... );
X
此示例运行绑定(或反弹)到 的操作v
,并Y
绑定(或反弹)到w
。这比嵌套调用 更高效,也更易于阅读ScopedValue ... where ... run
。
替代方案
可以使用线程局部变量来模拟范围值的许多特性,但会在内存占用、安全性和性能方面付出一些代价。
我们尝试了一个修改后的版本ThreadLocal
,它支持部分作用域值的特征。但是,携带线程局部变量的额外负担会导致实现过于繁重,或者 API 返回UnsupportedOperationException
其大部分核心功能,或两者兼而有之。因此,最好不要修改,ThreadLocal
而是将作用域值作为一个完全独立的概念引入。
范围值的设计灵感来源于许多 Lisp 方言对动态范围自由变量的支持;特别是,这些变量在深度绑定、多线程运行时(如Interlisp-D)中的行为。范围值通过添加类型安全性、不变性、封装以及线程内和跨线程的有效访问,改进了 Lisp 的自由变量。