JEP draft: Lazy Constants (Second Preview)

AuthorPer Minborg & Maurizio Cimadamore
OwnerPer-Ake Minborg
TypeFeature
ScopeSE
StatusSubmitted
Componentcore-libs / java.lang
Discussioncore dash libs dash dev at openjdk dot org
EffortS
DurationS
Relates toJEP 502: Stable Values (Preview)
Reviewed byBrian Goetz
Created2025/06/18 08:40
Updated2025/09/25 16:49
Issue8359894

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:

Goals

Non-goals

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:

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 with java --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 with jshell --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.