JEP 502: Stable Values (Preview)
Author | Per Minborg & Maurizio Cimadamore |
Owner | Per-Ake Minborg |
Type | Feature |
Scope | SE |
Status | Candidate |
Component | core-libs / java.lang |
Discussion | core dash libs dash dev at openjdk dot org |
Effort | S |
Duration | S |
Reviewed by | Alex Buckley, Brian Goetz |
Created | 2023/07/24 15:11 |
Updated | 2025/02/04 11:54 |
Issue | 8312611 |
Summary
Introduce an API for stable values, which are objects that hold immutable data. Stable values are treated as constants by the JVM, enabling the same performance optimizations that are enabled by declaring a field final
. Compared to final
fields, however, stable values offer greater flexibility as to the timing of their initialization. This is a preview API.
Goals
-
Improve the startup of Java applications by breaking up the monolithic initialization of application state.
-
Decouple the creation of stable values from their initialization, without significant performance penalties.
-
Guarantee that stable values are initialized at most once, even in multi-threaded programs.
-
Enable user code to safely enjoy constant-folding optimizations previously available only to JDK-internal code.
Non-goals
-
It is not a goal to enhance the Java programming language with a means to declare stable values.
-
It is not a goal to alter the semantics of
final
fields.
Motivation
Most Java developers have heard the advice to "prefer immutability" or "minimize mutability" (Effective Java, Third Edition, Item 17). Immutability confers many advantages, since an immutable object can be only in one state and therefore can be shared freely across multiple threads.
The Java Platform’s main tool for managing immutability is final
fields. Unfortunately, final
fields have restrictions. They must be set eagerly, either during construction for instance fields or during class initialization for static
fields. Moreover, the order in which final
fields are initialized is determined by the textual order in which the fields are declared. Such limitations restrict the applicability of final
in many real-world applications.
Immutability in practice
Consider a simple application component that records events via a logger object:
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");
}
}
Since logger
is a final
field of the OrderController
class, this field must be initialized eagerly, whenever an instance of OrderController
is created. This means that creating a new OrderController
can be slow — after all, obtaining a logger sometimes entails expensive operations such as reading and parsing configuration data, or preparing the storage where logging events will be recorded.
Furthermore, if an application is composed of not just one component with a logger but several components, then the application as a whole will start slowly because each and every component will eagerly initialize its logger:
class Application {
static final OrderController ORDERS = new OrderController();
static final ProductRepository PRODUCTS = new ProductRepository();
static final UserService USERS = new UserService();
}
This initialization work is not only detrimental to the application’s startup, but it might not be necessary. After all, some components might never need to log an event, so why do all this expensive work up front?
Embracing mutability for more flexible initialization
For these reasons, we often delay the initialization of complex objects to as late a time as possible, so that they are created only when needed. One way to achieve this is to abandon final
and rely on mutable fields to express at-most-once mutation:
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");
}
}
Since logger
is no longer a final
field, we can move its initialization into the getLogger
method. This method checks whether a logger object already exists; if not, it creates a new logger object and stores that in the logger
field. While this approach improves application startup, it has some drawbacks:
-
The code has a subtle new invariant: All accesses to the
logger
field inOrderController
must be mediated via thegetLogger
method. Failure to respect this invariant might expose a not-yet-initialized field, which will result in aNullPointerException
. -
Relying on mutable fields raises correctness and efficiency issues if the application is multi-threaded. For example, concurrent calls to the
submitOrder
method could result in multiple logger objects being created; even if doing so is correct, it is likely not efficient. -
One might expect the JVM to optimize access to the
logger
field by, e.g., constant-folding access to an already-initializedlogger
field, or by eliding thelogger == null
check in thegetLogger
method. Unfortunately, since the field is no longerfinal
, the JVM cannot trust its content never to change after its initial update. Flexible initialization implemented with mutable fields is not efficient.
Toward deferred immutability
In a nutshell, the ways in which the Java language allows us to control field initialization are either too constrained or too unconstrained. On the one hand, final
fields are too constrained, requiring initialization to occur early in the lifetime of an object or a class, which often degrades application startup. On the other hand, flexible initialization via the use of mutable non-final
fields makes it more difficult to reason about correctness. The tension between immutability and flexibility leads developers to adopt imperfect techniques that do not address the fundamental problem and result in code that is even more brittle and difficult to maintain. (Further examples are shown below.)
What we are missing is a way to promise that a field will be initialized by the time it is used, with a value that is computed at most once and, furthermore, safely with respect to concurrency. In other words, we need a way to defer immutability. This would give the Java runtime broad flexibility in scheduling and optimizing the initialization of such fields, avoiding the penalties that plague the alternatives. First-class support for deferred immutability would fill an important gap between immutable and mutable fields.
Description
A stable value is an object, of type StableValue
, that holds a single data value, its content. A stable value must be initialized some time before its content is first retrieved, and it is immutable thereafter. A stable value is a way to achieve deferred immutability.
Here is the OrderController
class, rewritten to use a stable value for its logger:
class OrderController {
// OLD:
// private Logger logger = null;
// NEW:
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");
}
}
The logger
field holds a stable value, created with the static factory method StableValue.of()
. Initially the stable value is unset, i.e., it holds no content.
The getLogger
method calls logger.orElseSet(...)
on the stable value to retrieve its content. If the stable value was already set then the orElseSet
method returns its content. If the stable value is unset then the orElseSet
method initializes it with the value returned by invoking the provided lambda expression, causing the stable value to become set; the method then returns that value. The orElseSet
method thus guarantees that a stable value is initialized before it is used.
Even though the stable value, once set, is immutable, we are not forced to initialize its content in a constructor or, for a static
stable value, in a class initializer. Rather, we can initialize it on demand. Furthermore, the orElseSet
method guarantees that the provided lambda expression is evaluated only once, even when logger.orElseSet(...)
is invoked concurrently. This property is crucial, since the evaluation of the lambda expression may have side effects; e.g., the call to Logger.create(...)
might create a new file in the filesystem.
This is a preview API, disabled by default
To use the Stable Value API, you must enable preview features:
-
Compile your program with
javac --release 25 --enable-preview Main.java
, and run it withjava --enable-preview Main
; or, -
When using the source code launcher, run your program with
java --enable-preview Main.java
; or -
When using
jshell
, start it withjshell --enable-preview
.
Flexible initialization with stable values
Stable values give us the same guaranteed initialization as immutable final
fields, while retaining the flexibility of mutable non-final
fields. They therefore fill the gap between these two kinds of fields:
Update count | Update location | Constant folding? | Concurrent updates? | |
---|---|---|---|---|
final field |
1 | Constructor or static initializer | Yes | No |
StableValue |
[0, 1] | Anywhere | Yes, after update | Yes, by winner |
Non-final field |
[0, ∞) | Anywhere | No | Yes |
The flexibility of stable values enables us to re-imagine the initialization of entire applications. In particular, we can compose stable values from other stable values. Just as we used a stable value to store the logger in the OrderController
component, we can also use a stable value to store the OrderController
component itself, and related components:
class Application {
// OLD:
// static final OrderController ORDERS = new OrderController();
// static final ProductRepository PRODUCTS = new ProductRepository();
// static final UserService USERS = new UserService();
// NEW:
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);
}
}
The application's startup time improves because it no longer initializes its components, such as OrderController
, up front. Rather, it initializes each component on demand, via the orElseSet
method of the corresponding stable value. Each component, moreover, initializes its sub-components, such as its logger, on demand in the same way.
There is, furthermore, mechanical sympathy between stable values and the Java runtime. Under the hood, the content of a stable value is stored in a non-final
field annotated with the JDK-internal @Stable
annotation. This annotation, a common feature of low-level JDK code, asserts that the VM may trust the field, even though non-final
, not to change after the VM considers its value for optimization after its initial and only update. This allows the JVM to treat the content of a stable value as a constant, provided that the field which refers to the stable value is final
. Thus the JVM can perform constant-folding optimizations for code that accesses immutable data through multiple levels of stable values, e.g., Application.orders().getLogger()
.
Consequently, developers no longer have to choose between flexible initialization and peak performance.
Specifying initialization at the declaration site
Our examples have, thus far, initialized stable values at their point of use by, e.g., calling logger.orElseSet(...)
in the getLogger
method. This allows the content to be computed using information available to the getLogger
method. Unfortunately, however, it means that all access to the logger
stable value must go through that method.
In this case it would be more convenient if we could specify how to initialize the stable value when we declare it, without actually initializing it. We can do this by using a stable supplier:
class OrderController {
// OLD:
// private final StableValue<Logger> logger = StableValue.of();
//
// Logger getLogger() {
// return logger.orElseSet(() -> Logger.create(OrderController.class));
// }
// NEW:
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");
}
}
Here logger
is no longer a stable value, but, rather, a stable supplier, i.e., a Supplier
of the content of an underlying stable value created from an original Supplier
which can compute the content on demand. When a stable supplier is first created, via StableValue.supplier(...)
, the content of the underlying stable value is not yet initialized.
To access the logger, clients call logger.get()
rather than getLogger()
. The first invocation of logger.get()
invokes the original supplier, i.e., the lambda expression provided to StableValue.supplier(...)
. It uses the resulting value to initialize the content of the stable supplier's underlying stable value, and then returns the result to the client. Subsequent invocations of logger.get()
return the content immediately.
Using a stable supplier, rather than a stable value, improves maintainability. The declaration and initialization of the logger
field are now adjacent, resulting in more readable code. The OrderController
class no longer has to document the invariant that every logger access must go through the getLogger
method, which we can now remove.
The JVM can, of course, perform constant-folding optimizations on code that accesses the content of stable values through stable suppliers.
Aggregating stable values
Many applications work with collections whose elements are themselves deferred immutable data, sharing similar initialization logic.
Consider, e.g., an application that creates not a single OrderController
but a pool of such objects. Different application requests can be served by different OrderController
objects, sharing the load across the pool. Objects in the pool should not be created eagerly, but only when a new object is needed by the application. We can achieve this using a stable list:
class Application {
// OLD:
// static final OrderController ORDERS = new OrderController();
// NEW:
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);
}
}
Here ORDERS
is no longer a stable value, but, rather a stable list, i.e., a List
in which each element is the content of an underlying stable value. When a stable list is created, via StableValue.list(...)
, the stable list has a fixed size, in this case POOL_SIZE
. The contents of the stable values underlying its list elements are not yet initialized.
To access the content, clients call ORDERS.get(...)
, passing it an index, rather than ORDERS.orElseSet(...)
. The first invocation of ORDERS.get(...)
with a particular index invokes the initialization function, in this case expressed as a lambda function that ignores the index and invokes the constructor new OrderController()
. It uses the resulting OrderController
object to initialize the content of the indexed element's underlying stable value, and then returns that object to the client. Subsequent invocations of ORDERS.get(...)
with the same index return the element's content immediately.
The elements of a stable list are initialized independently, as they are needed. For example, if the application runs in a single thread then only one OrderController
will ever be created and added to ORDERS.
Stable lists retain many of the benefits of stable suppliers, since the function used to initialize the list's elements is provided when defining the list. The JVM can, as usual, perform constant-folding optimizations on code that accesses the content of stable values through stable lists.
Alternatives
There are many ways to express deferred immutability in Java code today. Unfortunately, the known techniques come with disadvantages that include limited applicability, increased startup cost, and the hindrance of constant-folding optimizations.
Class-holder idiom
A common technique is the so-called class-holder idiom. The class-holder idiom ensures deferred immutability with at-most-once semantics by leveraging the laziness of the JVM's class initialization process:
class OrderController {
public static Logger getLogger() {
class Holder {
private static final Logger LOGGER = Logger.create(...);
}
return Holder.LOGGER;
}
}
While this idiom allows constant-folding optimizations, it is only applicable to static
fields. Moreover, if several fields are to be handled then a separate holder class is required for each field; this makes applications harder to read, slower to start up, and consume more memory.
Double-checked locking
Another alternative is the double-checked locking idiom. The basic idea here is to use a fast path to access a variable's value after it has been initialized, and a slow path in the assumed-rare case that a variable's value appears unset:
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;
}
}
Since logger
is a mutable field, constant-folding optimizations cannot be applied here. More importantly, for the double-checked idiom to work, the logger
field must be declared volatile
. This guarantees that the field's value is read and updated consistently across multiple threads.
Double-checked locking on arrays
Implementing a double-checked locking construct capable of supporting arrays of deferred immutable values is more difficult, since there is no way to declare an array whose elements are volatile
. Instead, a client must arrange for volatile
access to array elements explicitly, using a VarHandle
object:
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 is needed here to guarantee we only
// see fully initialized element objects
Logger v = (Logger)LOGGERS_HANDLE.getVolatile(loggers, index);
if (v == null) {
// Use distinct mutex objects for each index
synchronized (mutexes[index]) {
// A plain read suffices here since updates to an element
// always take place under the same mutex as for this read
v = loggers[index];
if (v == null) {
// Volatile is needed here to establish a happens-before
// relation with future volatile reads
LOGGERS_HANDLE.setVolatile(loggers, index,
v = Logger.create(... index ...));
}
}
}
return v;
}
}
This code is convoluted and error-prone: We now need a separate synchronization object for each array element, and we must remember to specify the correct operation (getVolatile
or setVolatile
) upon each access. To make matters worse, access to the array's elements is not efficient because constant-folding optimizations cannot be applied.
Concurrent map
Deferred immutability can also be achieved with thread-safe maps such as ConcurrentHashMap
, via the computeIfAbsent
method:
class OrderController {
private final Map<Class<?>,Logger> logger = new ConcurrentHashMap<>();
public Logger getLogger() {
return logger.computeIfAbsent(OrderController.class, Logger::create);
}
}
The JVM cannot trust the content of a map entry to not be updated after it is first added to the map, so constant-folding optimizations cannot be applied here. Moreover, the computeIfAbsent
method is null-hostile: If the computing function returns null
then no new entry is added to the map, which makes this solution impractical in some cases.
Risks and assumptions
The JVM can perform constant-folding optimizations only when it can trust that final
fields can be updated only once.
Unfortunately, the core reflection API allows instance final
fields to be updated arbitrarily, except for fields that are members of hidden classes or records. In the long term we intend to limit the reflection API so that all instance final
fields can be trusted, as part of the broader shift toward integrity by default. Until then, however, the mutability of most instance final
fields will limit the constant-folding optimizations enabled by stable values.
Fortunately, the reflection API does not allow static
final
fields to be updated arbitrarily, so constant folding across such fields is not only possible but routine. Thus the examples shown above that store stable values, suppliers, or lists in static
final
fields will have good performance.