JEP 502:稳定值(预览版)
JEP 502:稳定值(预览版)
原文:JEP 502- Stable Values (Preview)
作者:
日期:2025-10-26
| 作者 | 佩尔·明伯格(Per Minborg)和毛里齐奥·奇马达莫雷(Maurizio Cimadamore) |
|---|---|
| 所有者 | 佩尔 - 奥克·明伯格(Per - Ake Minborg) |
| 类型 | 特性 |
| 范围 | SE |
| 状态 | 已关闭 / 已交付 |
| 发布版本 | 25 |
| 组件 | 核心库 /java.lang |
| 讨论组 | core - libs - dev@openjdk.org |
| 工作量 | S |
| 持续时间 | S |
| 相关内容 | JEP 526:延迟常量(第二预览版) |
| 审核人 | 亚历克斯·巴克利(Alex Buckley)、布莱恩·戈茨(Brian Goetz) |
| 批准人 | 马克·莱因霍尔德(Mark Reinhold) |
| 创建时间 | 2023 年 7 月 24 日 15:11 |
| 更新时间 | 2025 年 7 月 16 日 19:11 |
| 问题编号 | 8312611 |
摘要
引入用于“稳定值”的 API,稳定值是持有不可变数据的对象。JVM 将稳定值视为常量,这使得它能够实现与将字段声明为 final 时相同的性能优化。然而,与 final 字段相比,稳定值在初始化时机方面提供了更大的灵活性。这是一个 预览 API。
目标
- 通过分解应用程序状态的整体初始化,改善 Java 应用程序的启动性能。
- 在不造成显著性能损失的情况下,将稳定值的创建与其初始化解耦。
- 确保稳定值最多初始化一次,即使在多线程程序中也是如此。
- 让用户代码能够安全地享受以前仅 JDK 内部代码可用的常量折叠优化。
非目标
- 目标并非是为 Java 编程语言增强一种声明稳定值的方式。
- 目标并非是改变
final字段的语义。
动机
大多数 Java 开发者都听过“优先使用不可变”或“尽量减少可变性”的建议(《Effective Java》第三版,条目 17)。不可变性有诸多优点,因为不可变对象只能处于一种状态,因此可以在多个线程间自由共享。
Java 平台管理不可变性的主要工具是 final 字段。不幸的是,final 字段存在限制。它们必须被急切地设置,对于实例字段,在构造期间设置;对于 static 字段,在类初始化期间设置。此外,final 字段的初始化顺序由字段声明的 文本顺序 决定。这些限制在许多实际应用中限制了 final 的适用性。
实践中的不可变性
考虑一个通过日志记录对象记录事件的简单应用程序组件:
class OrderController {
private final Logger logger = Logger.create(OrderController.class);
void submitOrder(User user, List<Product> products) {
logger.info("order started");
...
logger.info("order submitted");
}
}
由于 logger 是 OrderController 类的 final 字段,只要创建 OrderController 实例,就必须急切地初始化此字段。这意味着创建一个新的 OrderController 可能会很慢——毕竟,获取日志记录器有时需要执行一些开销较大的操作,比如读取和解析配置数据,或者准备用于记录日志事件的存储。
此外,如果一个应用程序并非只有一个带有日志记录器的组件,而是有多个组件,那么整个应用程序的启动将会很慢,因为每个组件都会急切地初始化其日志记录器:
class Application {
static final OrderController ORDERS = new OrderController();
static final ProductRepository PRODUCTS = new ProductRepository();
static final UserService USERS = new UserService();
}
这种初始化工作不仅对应用程序的启动有不利影响,而且可能并非必要。毕竟,一些组件可能永远不需要记录事件,那么为什么要预先进行这些开销较大的工作呢?
为实现更灵活的初始化而采用可变性
基于这些原因,我们通常会尽可能延迟复杂对象的初始化,以便仅在需要时才创建它们。实现这一点的一种方法是放弃使用 final,并依靠可变字段来实现最多一次的变更:
class OrderController {
private Logger logger = null;
Logger getLogger() {
if (logger == null) {
logger = Logger.create(OrderController.class);
}
return logger;
}
void submitOrder(User user, List<Product> products) {
getLogger().info("order started");
...
getLogger().info("order submitted");
}
}
由于 logger 不再是 final 字段,我们可以将其初始化移至 getLogger 方法中。该方法会检查日志记录器对象是否已存在;如果不存在,就创建一个新的日志记录器对象并存储在 logger 字段中。虽然这种方法改善了应用程序的启动性能,但也存在一些缺点:
- 代码引入了一个微妙的新不变性:
OrderController中对logger字段的所有访问都必须通过getLogger方法进行。如果不遵守这个不变性,可能会暴露尚未初始化的字段,从而导致NullPointerException。 - 如果应用程序是多线程的,依赖可变字段会引发正确性和效率问题。例如,对
submitOrder方法的并发调用可能会导致创建多个日志记录器对象;即使这样做是正确的,也可能效率不高。 - 人们可能期望 JVM 通过诸如对已初始化的
logger字段进行 常量折叠 访问,或者省略getLogger方法中的logger == null检查,来优化对logger字段的访问。不幸的是,由于该字段不再是final,JVM 无法确定其内容在初始更新后永远不会改变。使用可变字段实现的灵活初始化效率不高。
迈向延迟不可变性
简而言之,Java 语言允许我们控制字段初始化的方式要么限制过多,要么限制过少。一方面,final 字段限制过多,要求在对象或类的生命周期早期进行初始化,这往往会降低应用程序的启动速度。另一方面,通过使用可变的非 final 字段实现灵活初始化,使得正确性推理变得更加困难。不可变性与灵活性之间的矛盾导致开发人员采用一些并不完善的技术,这些技术无法解决根本问题,还会导致代码更加脆弱且难以维护。(更多示例见 下文。)
我们所缺少的是一种方式,能够保证一个字段在使用时已经被初始化,其值最多计算一次,并且在并发情况下也是安全的。换句话说,我们需要一种实现“延迟不可变性”的方式。这将为 Java 运行时在调度和优化此类字段的初始化方面提供很大的灵活性,避免其他替代方法所带来的问题。对延迟不可变性的原生支持将填补不可变字段和可变字段之间的一个重要空白。
描述
稳定值是一个类型为 StableValue 的对象,它持有单个数据值,即其“内容”。稳定值必须在首次检索其内容之前的某个时间进行初始化,并且此后保持不可变。稳定值是实现延迟不可变性的一种方式。
以下是重写后的 OrderController 类,使用稳定值来存储其日志记录器:
class OrderController {
// 旧代码:
// private Logger logger = null;
// 新代码:
private final StableValue<Logger> logger = StableValue.of();
Logger getLogger() {
return logger.orElseSet(() -> Logger.create(OrderController.class));
}
void submitOrder(User user, List<Product> products) {
getLogger().info("order started");
...
getLogger().info("order submitted");
}
}
logger 字段持有一个稳定值,通过静态工厂方法 StableValue.of() 创建。最初,该稳定值未设置,即它不包含任何内容。
getLogger 方法对稳定值调用 logger.orElseSet(...) 来检索其内容。如果稳定值已经设置,则 orElseSet 方法返回其内容。如果稳定值未设置,则 orElseSet 方法使用通过调用提供的 lambda 表达式返回的值对其进行初始化,使稳定值变为已设置状态;然后该方法返回该值。因此,orElseSet 方法保证稳定值在使用前已被初始化。
即使稳定值一旦设置就不可变,但我们并非必须在构造函数中初始化其内容,如果是 static 稳定值,也无需在类初始化器中初始化。相反,我们可以按需进行初始化。此外,orElseSet 方法保证提供的 lambda 表达式仅计算一次,即使 logger.orElseSet(...) 被并发调用。此属性至关重要,因为 lambda 表达式的计算可能会产生副作用;例如,对 Logger.create(...) 的调用可能会在文件系统中创建一个新文件。
这是一个预览 API,默认情况下处于禁用状态
要使用稳定值 API,必须启用预览特性:
- 使用
javac --release 25 --enable-preview Main.java编译程序,并使用java --enable-preview Main运行它;或者, - 使用源代码启动器时,使用
java --enable-preview Main.java运行程序;或者 - 使用
jshell时,使用jshell --enable-preview启动它。
利用稳定值实现灵活初始化
稳定值为我们提供了与不可变 final 字段相同的初始化保证,同时保留了可变非 final 字段的灵活性。因此,它们填补了这两种字段之间的空白:
| 更新次数 | 更新位置 | 常量折叠? | 并发更新? | |
|---|---|---|---|---|
final 字段 | 1 | 构造函数或静态初始化器 | 是 | 否 |
StableValue | [0, 1] | 任意位置 | 是,更新后 | 是,由胜者决定 |
非 final 字段 | [0, ∞) | 任意位置 | 否 | 是 |
稳定值的灵活性使我们能够重新构想整个应用程序的初始化。特别是,我们可以从其他稳定值组合出稳定值。就像我们在 OrderController 组件中使用稳定值来存储日志记录器一样,我们也可以使用稳定值来存储 OrderController 组件本身以及相关组件:
class Application {
// 旧代码:
// static final OrderController ORDERS = new OrderController();
// static final ProductRepository PRODUCTS = new ProductRepository();
// static final UserService USERS = new UserService();
// 新代码:
static final StableValue<OrderController> ORDERS = StableValue.of();
static final StableValue<ProductRepository> PRODUCTS = StableValue.of();
static final StableValue<UserService> USERS = StableValue.of();
public static OrderController orders() {
return ORDERS.orElseSet(OrderController::new);
}
public static ProductRepository products() {
return PRODUCTS.orElseSet(ProductRepository::new);
}
public static UserService users() {
return USERS.orElseSet(UserService::new);
}
}
应用程序的启动时间得到改善,因为它不再预先初始化诸如 OrderController 之类的组件。相反,它通过相应稳定值的 orElseSet 方法按需初始化每个组件。此外,每个组件以相同的方式按需初始化其子组件,例如其 日志记录器。
此外,稳定值与 Java 运行时之间存在机械协同。在底层,稳定值的内容存储在一个使用 JDK 内部 @Stable 注解标注的非 final 字段中。此注解是 JDK 底层代码的一个常见特性。它表明,即使该字段不是 final,JVM 也可以相信该字段的值在首次且唯一的更新之后不会改变。这使得 JVM 能够将稳定值的内容视为常量,前提是引用该稳定值的字段是 final。因此,JVM 可以对通过多层稳定值访问不可变数据的代码(例如 Application.orders().getLogger())执行常量折叠优化。
因此,开发人员不再需要在灵活初始化和最佳性能之间做出选择。
在声明处指定初始化
到目前为止,我们的示例都是在稳定值的使用点进行初始化,例如在 getLogger 方法中调用 logger.orElseSet(...)。这使得可以使用 getLogger 方法中可用的信息来计算内容。然而,遗憾的是,这意味着对 logger 稳定值的所有访问都必须通过该方法进行。
在这种情况下,如果我们能在声明稳定值时指定如何初始化它,而不实际进行初始化,那就更方便了。我们可以通过使用“稳定供应商”来实现这一点:
class OrderController {
// 旧代码:
// private final StableValue<Logger> logger = StableValue.of();
//
// Logger getLogger() {
// return logger.orElseSet(() -> Logger.create(OrderController.class));
// }
// 新代码:
private final Supplier<Logger> logger
= StableValue.supplier(() -> Logger.create(OrderController.class));
void submitOrder(User user, List<Product> products) {
logger.get().info("order started");
...
logger.get().info("order submitted");
}
}
这里 logger 不再是一个稳定值,而是一个稳定供应商,即一个 Supplier,它提供底层稳定值的内容,该底层稳定值由一个原始 Supplier 创建,这个原始 Supplier 可以按需计算内容。当通过 StableValue.supplier(...) 首次创建稳定供应商时,底层稳定值的内容尚未初始化。
为了访问日志记录器,客户端调用 logger.get() 而不是 getLogger()。首次调用 logger.get() 时,会调用原始供应商,即提供给 StableValue.supplier(...) 的 lambda 表达式。它使用返回的值来初始化稳定供应商底层稳定值的内容,然后将结果返回给客户端。后续对 logger.get() 的调用会立即返回内容。
使用稳定供应商而不是稳定值提高了代码的可维护性。logger 字段的声明和初始化现在相邻,使代码更易读。OrderController 类不再需要记录每个日志记录器访问都必须通过 getLogger 方法这一不变性,我们现在可以删除该方法。
当然,JVM 可以对通过稳定供应商访问稳定值内容的代码执行常量折叠优化。
聚合稳定值
许多应用程序处理的集合,其元素本身就是延迟不可变数据,且具有相似的初始化逻辑。
例如,考虑一个应用程序,它创建的不是单个 OrderController,而是一个此类对象的池。不同的应用程序请求可以由不同的 OrderController 对象处理,在池中分担负载。池中对象不应提前创建,而应仅在应用程序需要新对象时创建。我们可以使用“稳定列表”来实现这一点:
class Application {
// 旧代码:
// static final OrderController ORDERS = new OrderController();
// 新代码:
static final List<OrderController> ORDERS
= StableValue.list(POOL_SIZE, _ -> new OrderController());
public static OrderController orders() {
long index = Thread.currentThread().threadId() % POOL_SIZE;
return ORDERS.get((int)index);
}
}
这里 ORDERS 不再是一个稳定值,而是一个稳定列表,即一个 List,其中每个元素都是底层稳定值的内容。当通过 StableValue.list(...) 创建稳定列表时,稳定列表有一个固定大小,在这种情况下是 POOL_SIZE。其列表元素底层的稳定值内容尚未初始化。
为了访问内容,客户端调用 ORDERS.get(...),并传入一个索引,而不是 ORDERS.orElseSet(...)。首次使用特定索引调用 ORDERS.get(...) 时,会调用初始化函数,在这种情况下是一个忽略索引并调用 OrderController() 构造函数的 lambda 函数。它使用生成的 OrderController 对象来初始化索引元素底层稳定值的内容,然后将该对象返回给客户端。后续使用相同索引对 ORDERS.get(...) 的调用会立即返回该元素的内容。
稳定列表的元素根据需要独立初始化。例如,如果应用程序在单个线程中运行,那么只会创建一个 OrderController 并添加到 ORDERS 中。
稳定列表保留了稳定供应商的许多优点,因为用于初始化列表元素的函数是在定义列表时提供的。和往常一样,JVM 可以对通过稳定列表访问稳定值内容的代码执行常量折叠优化。
替代方案
如今在 Java 代码中有多种方式来表达延迟不可变性。不幸的是,已知的技术都存在一些缺点,包括适用性有限、启动成本增加以及阻碍常量折叠优化。
类持有模式
一种常见的技术是所谓的 类持有模式。类持有模式通过利用 JVM 类初始化过程的延迟性,以最多一次的语义确保延迟不可变性:
class OrderController {
public static Logger getLogger() {
class Holder {
private static final Logger LOGGER = Logger.create(...);
}
return Holder.LOGGER;
}
}
虽然这种模式允许进行常量折叠优化,但它仅适用于 static 字段。此外,如果要处理多个字段,则每个字段都需要一个单独的持有类;这使得应用程序更难阅读、启动更慢且消耗更多内存。
双重检查锁定
另一种替代方案是 双重检查锁定模式。其基本思想是在变量值初始化后使用快速路径访问该变量的值,在假设罕见的变量值未设置的情况下使用慢速路径:
class OrderController {
private volatile Logger logger;
public Logger getLogger() {
Logger v = logger;
if (v == null) {
synchronized (this) {
v = logger;
if (v == null) {
logger = v = Logger.create(...);
}
}
}
return v;
}
}
由于 logger 是一个可变字段,这里无法应用常量折叠优化。更重要的是,为了使双重检查模式生效,logger 字段必须声明为 volatile。这保证了该字段的值在多个线程中能被一致地读取和更新。
数组上的双重检查锁定
实现一个能够支持延迟不可变值数组的双重检查锁定结构更加困难,因为无法声明其元素为 volatile 的数组。相反,客户端必须使用 VarHandle 对象显式地安排对数组元素的 volatile 访问:
class OrderController {
private static final VarHandle LOGGERS_HANDLE
= MethodHandles.arrayElementVarHandle(Logger[].class);
private final Object[] mutexes;
private final Logger[] loggers;
public OrderController(int size) {
this.mutexes = Stream.generate(Object::new).limit(size).toArray();
this.loggers = new Logger[size];
}
public Logger getLogger(int index) {
// 这里需要 volatile 来保证我们只看到完全初始化的元素对象
Logger v = (Logger)LOGGERS_HANDLE.getVolatile(loggers, index);
if (v == null) {
// 为每个索引使用不同的互斥对象
synchronized (mutexes[index]) {
// 这里普通读取就足够了,因为对元素的更新总是与这次读取在同一个互斥对象下进行
v = loggers[index];
if (v == null) {
// 这里需要 volatile 来与未来的 volatile 读取建立先行发生关系
LOGGERS_HANDLE.setVolatile(loggers, index,
v = Logger.create(... index ...));
}
}
}
return v;
}
}
这段代码复杂且容易出错:我们现在需要为每个数组元素使用一个单独的同步对象,并且每次访问时都必须记住指定正确的操作(getVolatile 或 setVolatile)。更糟糕的是,由于无法应用常量折叠优化,对数组元素的访问效率不高。
并发映射
延迟不可变性也可以通过诸如 ConcurrentHashMap 这样的线程安全映射,借助 computeIfAbsent 方法来实现:
class OrderController {
private final Map<Class<?>,Logger> logger = new ConcurrentHashMap<>();
public Logger getLogger() {
return logger.computeIfAbsent(OrderController.class, Logger::create);
}
}
JVM 无法确定映射条目的内容在首次添加到映射后不会被更新,所以这里无法应用常量折叠优化。此外,computeIfAbsent 方法不允许值为 null:如果计算函数返回 null,则不会向映射中添加新条目,这使得该解决方案在某些情况下不实用。
风险与假设
只有当 JVM 能够确定 final 字段仅能被更新一次时,它才会执行常量折叠优化。
不幸的是,核心反射 API 允许对实例 final 字段进行 任意更新,但属于 隐藏类 或 记录 的字段除外。从长远来看,作为向 默认完整性 这一更大转变的一部分,我们打算限制反射 API,以便所有实例 final 字段都能被信任。然而,在此之前,大多数实例 final 字段的可变性将限制稳定值所能实现的常量折叠优化。
幸运的是,反射 API 不允许对 static final 字段进行任意更新,所以跨此类字段的常量折叠不仅可行,而且很常见。因此,上面展示的将稳定值、供应商或列表存储在 static final 字段中的示例将具有良好的性能。