Summary
Introduce an API for stable values, which are objects that represent immutable data. Stable values are treated as constants by the JVM, enabling the same performance optimizations that are possible by marking a field final
. Yet, stable values offer greater flexibility as to the timing of initialization compared to final
fields. This is a preview API.
Goals
- Improve the startup of Java applications by breaking up monolithic initialization of the application state.
- Decouple the creation of a stable value from its initialization, without significant performance penalties.
- Guarantee that stable values are initialized at most once, even in a multi-threaded program.
- Allow user-defined code to safely enjoy constant-folding optimizations previously only available to JDK internals.
Non-goals
- It is not a goal to provide Java language support for declaring stable values.
- It is not a goal to alter the semantics of
final
fields.
Motivation
Most Java developers have heard the advice "prefer immutability" or "minimize mutability" (Effective Java, Third Edition, Item 17). Immutability confers many advantages, as immutable objects can be only in one state, and can therefore be freely shared across multiple threads.
Java's main tool for managing immutability is final
fields (and more recently, record
classes). Unfortunately, final
fields come with restrictions; final
fields must be set eagerly either by the end of the constructor (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
As an effective and yet nearly ubiquitous example, consider a simple application component that needs to record relevant events through 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");
}
}
As logger
is a final
field of the OrderController
class, this field must be initialized eagerly, when a new instance of OrderController
is created. This means that creating a new OrderController is rather slow -- after all, obtaining a logger often 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();
}
All this initialization is not only detrimental to the application startup; it might also be redundant. After all, the application might never need to log an event, so why pay the cost for this expensive initialization upfront?
Embracing mutability for more flexible initialization
For these reasons, an application will typically want to move the initialization of complex objects as much forward in time as possible, so that objects are only created when needed. One way to achieve that 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");
}
}
As logger
is no longer a final
field, we can move its initialization into the OrderController.getLogger method
. This method checks whether a logger object already exists. If not, a new logger object is created and stored in the logger
field. While this approach improves application startup, it comes with some drawbacks:
- The application 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 poses correctness issues if the application is multi-threaded. For example, concurrent calls to
submitOrder
can result in multiple logger objects being created. - One might expect the JVM to optimize access to the
logger
field, e.g., by constant-folding access to an already-initializedlogger
field, or by eliding thelogger == null
check ingetLogger
. Unfortunately, since the field is no longerfinal
, the JVM cannot trust its contents to never change after its initial update. As such, flexible initialization implemented with mutable fields is not efficient.
Towards deferred immutability
In a nutshell, the ways in which the Java language allows developers to control field updates are either too constrained or too unconstrained. On the one hand, final
fields are too constrained, requiring initialization to occur at a precise moment in the lifetime of an object or a class; this often comes at the expense of application startup. On the other hand, achieving initialization flexibility through the use of mutable non-final
fields limits our ability to reason about the correctness of our application. The tension between immutability and flexibility of initialization leads developers to adopt imperfect workarounds that do not address the underlying problem and result in code that is even more brittle and difficult to maintain (some examples of this are shown in the Alternatives section).
What developers 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 in a multi-threaded application. In other words, a way to defer immutability. This would give the Java runtime maximum opportunity to stage and optimize its computation, thus avoiding the penalties that plague the workarounds.
First-class support for deferred immutability would fill an important gap between mutable and immutable fields.
Description
A stable value is an object, of type StableValue
, that represents immutable data and that is initialized at a time of the developer's choice. Once the data has been initialized, the stable value acts as a constant for the rest of the program's execution. In effect, a stable value is a way of achieving deferred immutability.
The order controller class can now be 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.computeIfUnset(() -> 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, meaning it has no underlying data. The stable value itself can be used, e.g., by passing the reference to logger
to another method, but there is no way to use the underlying data.
The getLogger
method calls logger.computeIfUnset
on the stable value to retrieve its underlying data. If the stable value is unset, then computeIfUnset
initializes the underlying data, causing the stable value to become set; the underlying data is then returned to the client. In other words, computeIfUnset
guarantees that a stable value's underlying data is initialized before it is used.
Even though the stable value, once set, is immutable, we are not forced to initialize its underlying data in a constructor (or, for a static
stable value, in the class initializer). Rather, we can initialize it on demand. Furthermore, computeIfUnset
guarantees that the lambda expression provided is evaluated only once, even when logger.computeIfUnset
is invoked concurrently. This property is crucial as evaluation of the lambda expression may have side effects, e.g., the call above to Logger.create
may result in storage resources being prepared.
This is a preview API, disabled by default
To use the Stable Value API, you must enable preview features as follows:
- Compile the program with
javac --release 24 --enable-preview Main.java
and run it withjava --enable-preview Main
; or, - When using the source code launcher, run the program with
java --source 24 --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:
.storage-kinds { table, th, td { border: 1px solid black; border-collapse: collapse; text-align: center; } tr:nth-child(3) { background-color: #f2f2f2; } th { background-color: #f2f2f2; } }
The flexibility of stable values lets us 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 in the OrderController
component, we can also use a stable value to store the OrderController
component itself:
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.computeIfUnset(OrderController::new); }
public static ProductRepository products() { return PRODUCTS.computeIfUnset(ProductRepository::new); }
public static UserService users() { return USERS.computeIfUnset(UserService::new); }
}
Application startup is improved because its components, such as an OrderController
, no longer need to be initialized up front. Rather, we initialize each component on demand, via computeIfUnset
, and each component initializes its sub-components on demand, e.g., the logger of OrderController
shown earlier.
Furthermore, there is mechanical sympathy between stable values and the Java runtime: under the hood, the underlying data of a stable value is modeled as a non-final
field annotated with the special @Stable
annotation. This annotation, a common feature of low-level JDK code, guarantees that the field, even if non-final
, can be trusted not to change after its initial (and only) update. This allows the JVM to treat the underlying data of a stable value as a constant, provided that the field which refers to the stable value is final
.
This means 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.
Easier access to underlying data
Our examples have shown the underlying data of a stable value being initialized in an arbitrary location, e.g., calling logger.computeIfUnset
in the getLogger
method, far from the declaration of the logger
field. This allows the underlying data to be initialized using any application state available to the getLogger
method, but unfortunately, it means that all access to the stable value logger
has to go through getLogger
.
It would be more convenient if the logger
field's declaration could specify how the underlying data of a stable value is to be initialized, but without actually creating a stable value or initializing its data. This can be done by turning logger
from a stable value into a stable supplier:
class OrderController {
/* Old:
private final StableValue<Logger> logger = StableValue.of(); */
/* New: */
private final Supplier<Logger> logger =
StableValue.ofSupplier( () -> Logger.create(OrderController.class) );
void submitOrder(User user, List<Product> products) {
logger.get().info("order started");
...
logger.get().info("order submitted");
}
}
In the above, logger
is no longer a stable value -- it is a stable supplier, meaning a supplier of the underlying data for a stable value. When a stable supplier is first created -- this is done with StableValue.ofSupplier
-- its stable value's underlying data is not yet initialized.
To access the underlying data, clients call logger.get
rather than logger.computeIfUnset
. Upon the first invocation of logger.get
, the lambda expression provided to ofSupplier
is evaluated, and its result is used to initialize the underlying data of the stable value, which is returned to the client. Subsequent invocations of logger.get
return the underlying data immediately.
The use of a stable supplier, rather than a stable value, improves maintainability. The declaration and initialization of logger
are now adjacent, resulting in more readable code, while the OrderController
class no longer has to document the invariant that every logger access must go through the getLogger
method, which now can be removed.
Furthermore, the JVM can perform constant-folding optimizations for code that accesses the underlying data of stable values through stable suppliers.
Aggregating stable values
So far, we have used a separate stable value for each piece of deferred immutable data we needed to model (e.g. a component's logger object). While this is useful, many applications work with collections whose elements are themselves modeled as deferred immutable data, sharing similar initialization logic.
Consider the case where an application might want to create not a single OrderController
but a pool of OrderController
objects. Different application requests can then be served by different OrderController
objects, effectively creating an efficient load-sharing scheme across all the OrderController objects in the pool. Furthermore, objects in the pool should not be initialized eagerly, but only when a new object is needed by the application. This can be achieved using a stable list:
class Application {
/* Old:
static final OrderController ORDERS = new OrderController(); */
/* New */
static final List<OrderController> ORDERS =
StableValue.ofList(POOL_SIZE, OrderController::new);
public static OrderController orders() {
long index = Thread.currentThread().threadId() % POOL_SIZE;
return ORDERS.get((int)index);
}
}
In the above, ORDERS
is no longer a stable value -- it is a stable list, meaning a list where each element is the underlying data for some stable value. When a stable list is first created -- via the StableValue.ofList
factory -- the stable list has a fixed size (e.g. POOL_SIZE
), and the underlying data of the stable values associated with the list elements is not yet initialized.
To access the underlying data, clients call ORDERS.get
rather than ORDERS.computeIfUnset
. Upon the first invocation of ORDERS.get
with a particular index, a new OrderController
object is created (using a lambda expression or, as above, a method reference) and then used to initialize the underlying data of the element's stable value, which is returned to the client. Subsequent invocations of ORDERS.get
with the same index return the element's underlying data immediately. Elements in a stable list are initialized independently, and only before they are needed. For example, if the application runs in a single thread, only one OrderController
will ever be initialized and added to ORDERS.
Stable lists retain many of the benefits of stable suppliers, as the lambda expression used to initialize the stable list elements is provided together with the definition of its field (e.g. ORDERS
). Moreover, the JVM can perform constant-folding optimizations for code that accesses the underlying data of stable values through stable lists.
Alternatives
In this section, we discuss common ways in which Java programs can express deferred immutability. Unfortunately, all the workarounds proposed here come with disadvantages which range from limited applicability, to increased startup cost, to loss of constant-folding optimizations.
Class-holder idiom
A common workaround is the so-called class-holder idiom. The class holder idiom ensures deferred immutability with at-most-once semantics, by leveraging the laziness in the JVM's class initialization process:
class OrderController {
public staic Logger getLogger() {
class Holder {
private static final Logger LOGGER = Logger.create(...);
}
return Holder.LOGGER;
}
}
While the class holder idiom allows for constant-folding optimizations, it is only applicable to static
fields. Moreover, if several variables are to be handled, there needs to be a separate holder class for each variable, which makes applications harder to read, slower to start up, and requires more memory for the class definitions.
Double-checked locking
Another alternative is to resort to the double-checked locking idiom. The basic idea behind double-checked locking is to provide a fast path for accessing a variable's value that has already been initialized; and then provide a slower path in the (assumed rare) case where the 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;
}
}
As logger
is a mutable field, no constant-folding optimization can be applied here. More importantly, for the double-checked idiom to work, the logger
field must be marked as volatile
. This guarantees that the field's value is read and updated consistently across multiple threads. Unfortunately, volatile
is not enough to guarantee correctness when dealing with arrays of deferred immutable values as shown in the next section.
Double-checked locking on arrays
Implementing a double-checked locking construct capable of supporting arrays of deferred immutable values is much more difficult, as there is no way to declare an array whose elements must be accessed with volatile
semantics. Instead, a client needs to handle 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 semantics 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]) {
// Plain read semantics suffice here as updates to an element
// always takes place under the same mutex object as for this read
v = loggers[index];
if (v == null) {
// Volatile semantics needed here to establish a
// happens-before relation with future volatile reads
LOGGERS_HANDLE.setVolatile(loggers, index,
v = Logger.create(... index ...));
}
}
}
return v;
}
}
The code above is even more convoluted and error-prone: we now need a separate synchronization object for each array element, and remember to specify the correct semantics (getVolatile
, setVolatile
) upon each access. Moreover, access to array elements is not efficient, as constant-folding optimizations cannot be applied here.
Concurrent map
Deferred immutability can also be achieved with thread-safe maps like ConcurrentHashMap
, uppdated with the Map.computeIfAbsent
method:
class OrderController {
private final Map<Class<?>, Logger> logger = new ConcurrentHashMap<>();;
public Logger getLogger() {
return logger.computeIfAbsent(OrderController.class, Logger::create);
}
}
As the JVM cannot trust the content of a map entry to never be updated again after it is first added to the map, no constant-folding optimization can be applied here. Moreover, Map.computeIfAbsent
is null-hostile: if the computing function returns null
no new entry will be added to the map, which makes this solution impractical in some cases.
Risks and assumptions
The constant-folding optimizations provided by stable values hinges on the JVM's ability to trust that instance final
fields can be updated only once. Currently, this only happens in a handful of cases -- e.g. when the final
field models a record component. Broadening the set of conditions under which an instance final
field is trusted to be updated only once might be the work of a future JEP.
- relates to
-
JDK-8258588 MD5 MessageDigest in java.util.UUID should be cached
- Open