JEP draft: Lazy Constants (Second Preview)
Author | Per Minborg & Maurizio Cimadamore |
Owner | Per-Ake Minborg |
Type | Feature |
Scope | SE |
Status | Submitted |
Component | core-libs / java.lang |
Discussion | core dash libs dash dev at openjdk dot org |
Effort | S |
Duration | S |
Relates to | JEP 502: Stable Values (Preview) |
Reviewed by | Brian Goetz |
Created | 2025/06/18 08:40 |
Updated | 2025/09/25 16:49 |
Issue | 8359894 |
Summary
Introduce an API for lazy constants, which are objects that hold immutable data. Lazy constants are treated as true constants by the JVM, enabling the same performance optimizations that are enabled by declaring a field final
. Compared to final
fields, however, lazy constants offer greater flexibility as to the timing of their initialization. This is a preview API.
History
This API first previewed in JDK 25 via JEP 502: Stable Values. Based on experience and feedback, we here propose to revise and re-preview the API in JDK 26. Specifically, we propose to:
-
Re-orient the API to focus on high-level use cases, removing the low-level methods
orElseSet
,setOrThrow
andtrySet
, leaving only factory methods that take value-computing functions. -
Rename the API from
StableValue
toLazyConstant
. The former was appropriate for a low-level API that was close to the underlying JVM mechanism upon which it is implemented; the latter better captures the primary intended high-level use case, namely that of a single constant value that is initialized lazily. -
Enhance discoverability by moving the factory methods for lazy lists and maps into the
java.util.List
andjava.util.Map
interfaces, respectively. -
Further simplify the API by removing the
function
andintFunction
factory methods, which provided only marginal benefits over lazy lists and maps. -
Disallow
null
as a computed value in order to improve performance and better align lazy constants with constructs such as unmodifiable collections andScopedValue
.
Goals
-
Enable application state to be initialized incrementally, on demand, rather than monolithically, thereby reducing application startup time.
-
Decouple the creation of lazy constants from their initialization, without significant performance penalties.
-
Guarantee that lazy constants are initialized at most once, even in multi-threaded programs.
-
Enable user code to benefit from constant-folding optimizations previously applicable only to JDK-internal code.
Non-goals
-
It is not a goal to enhance the Java programming language with a means to declare lazy fields.
-
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 in only 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 lazy constant is an object, of type java.lang.LazyConstant
, that holds a single data value, its content. A lazy constant must be initialized some time before its content is first retrieved. This is done using a computing function, typically a lambda expression or a method reference, provided at construction. After a lazy constant is initialized, it is thereafter immutable. A lazy constant is a way to achieve deferred immutability.
Here is the OrderController
class, rewritten to use a lazy constant for its logger:
class OrderController {
// OLD:
// private Logger logger = null;
// NEW:
private final LazyConstant<Logger> logger
= LazyConstant.of(() -> Logger.create(OrderController.class));
void submitOrder(User user, List<Product> products) {
logger.get().info("order started");
...
logger.get().info("order submitted");
}
}
The logger
field holds a lazy constant, created with the static factory method LazyConstant.of(...)
. Initially, the lazy constant is uninitialized, i.e., it holds no content.
The submitOrder
method calls logger.get()
to retrieve a logger. If the lazy constant was already initialized, then the get
method returns the content. If the lazy constant is uninitialized, then the get
method initializes its content with the value returned by invoking the lambda expression provided at construction, causing the lazy constant to become initialized; the method then returns that value. The get
method thus guarantees that a lazy constant is initialized before it returns.
Even though the lazy constant, once initialized, is immutable, we are not forced to initialize its content in a constructor or, for a static
value, in a class initializer. Rather, we initialize it on demand. Furthermore, the get
method guarantees that the provided lambda expression is evaluated only once, even when logger.get()
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 Lazy Constants API, you must enable preview features:
-
Compile your program with
javac --release 26 --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 lazy constants
Lazy constants 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 |
LazyConstant |
[0, 1] | Anywhere | Yes, after initialization | Yes, by winner |
Non-final field |
[0, ∞) | Anywhere | No | Yes |
The flexibility of lazy constants enables us to re-imagine the initialization of entire applications. In particular, we can compose lazy constants from other lazy constants. Just as we used a lazy constant to store the logger in the OrderController
component, we can also use a lazy constant 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 LazyConstant<OrderController> ORDERS = LazyConstant.of(OrderController::new);
static final LazyConstant<ProductRepository> PRODUCTS = LazyConstant.of(ProductRepository::new);
static final LazyConstant<UserService> USERS = LazyConstant.of(UserService::new);
public static OrderController orders() {
return ORDERS.get();
}
public static ProductRepository products() {
return PRODUCTS.get();
}
public static UserService users() {
return USERS.get();
}
}
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 computing function and the get
method of the corresponding lazy constant. Each component, moreover, initializes its sub-components, such as its logger, on demand in the same way.
There is, furthermore, mechanical sympathy between lazy constants and the Java runtime. Under the hood, the content of a lazy constant is stored in a non-final
field annotated with the JDK-internal @Stable
annotation. This annotation is a common feature of low-level JDK code. It asserts that, even though the field is non-final
, the field’s value will not change after the field’s initial and only update. This allows the JVM to treat the content of a lazy constant as a true constant, provided that the field which refers to the lazy constant is final
. Thus the JVM can apply constant-folding optimizations to code that accesses immutable data through multiple levels of lazy constants, e.g., Application.orders().getLogger()
.
Consequently, developers no longer have to choose between flexible initialization and peak performance.
Aggregating lazy constants
Many applications use 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 lazy list:
class Application {
// OLD:
// static final OrderController ORDERS = new OrderController();
// NEW:
static final List<OrderController> ORDERS
= List.ofLazy(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 lazy constant but, rather, a lazy list, i.e., a List
in which each element is stored in a lazy constant. When a lazy list is created, via List.ofLazy(...)
, its size is fixed, in this case to POOL_SIZE
. The lazy constants in which the list elements are stored are not yet initialized.
To obtain an OrderController
, the orders
methods calls ORDERS.get(...)
, passing it an index computed from the current thread's numeric identifier, rather than ORDERS.get()
. The first invocation of ORDERS.get(...)
with a particular index invokes the computing function provided at construction, in this case a lambda that ignores the index and invokes the OrderController
constructor. It uses the resulting OrderController
object to initialize the content of the indexed element's underlying lazy constant, and then returns that object. Subsequent invocations of ORDERS.get(...)
with the same index return the element's content immediately.
The elements of a lazy 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
.
Lazy lists retain many of the benefits of lazy constants. The computing function used to initialize the list's elements is evaluated only once per element, even if the lazy list is accessed concurrently. The JVM can, as usual, apply constant-folding optimizations to code that accesses the content of lazy constants through lazy lists.
Alternatively, we could solve the same problem in a different way with a lazy map, i.e., a Map
whose keys are known at construction and whose values are stored in lazy constants, initialized on demand by a computing function that is also provided at construction:
class Application {
// NEW:
static final Map<String, OrderController> ORDERS
= Map.ofLazy(Set.of("Customers", "Internal", "Testing"),
_ -> new OrderController());
public static OrderController orders() {
String groupName = Thread.currentThread().getName();
return ORDERS.get(groupName);
}
}
In this example, OrderController
instances are associated with the name of a thread — "Customers"
, "Internal"
, and "Testing"
— rather than integer indexes computed from thread identifiers. Lazy maps allow for more expressive access idioms than lazy lists, but otherwise have all the same benefits.
Future Work
Lazy constants cover the common, high-level use cases for lazy initialization. In the future we might consider providing stable access semantics directly, at a lower level, for reference, array, and primitive fields. This would address, for example, use cases where the computing function associated with a lazy constant is not known at construction.
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 not to be updated after it is first added to the map, so constant-folding optimizations cannot be applied here.
Risks and assumptions
The JVM can apply 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 lazy constants.
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 lazy constants, maps, or lists in static
final
fields will have good performance.