Java developers have long lived with an awkward trade-off. If you want the clarity and optimization benefits of final, you must initialize eagerly, in a constructor or static initializer. If you want to defer expensive setup until first use, you usually fall back to mutable lazy-initialization patterns, nullable fields, or double-checked locking. Those techniques work, but they also make code harder to reason about and harder for the JVM to optimize.

JEP 526, delivered as a preview in Java 26, introduces Lazy Constants to close that gap. The feature began life in Java 25 as Stable Values, and the rename is revealing. The first preview looked more like a low-level mechanism. The second preview is shaped much more like a developer-facing abstraction. JEP 526 explicitly says the Java 26 API is a revised second preview of JEP 502, renamed from StableValue to LazyConstant, simplified by removing lower-level setter-style methods, and extended with lazy collection factories on List and Map.

Most introductions stop there and explain how to replace a null check with LazyConstant.of(...). That is useful, but it misses the more interesting story. Lazy Constants matter because they expose a form of deferred immutability that the JVM can still treat like a constant. The feature is about performance, yes, but not only startup performance. It is really about regaining JVM trust after moving initialization out of eager final fields. JEP 502 framed the original problem in exactly those terms: developers should not have to choose between flexible initialization and the peak performance associated with final fields and constant folding.

The Real Problem Is Not Laziness, It Is Trust

Java developers already know how to be lazy. They have been writing if (field == null) checks, holder classes, double-checked locking, and cache maps for years. The problem is not how to postpone initialization. The problem is how to postpone initialization without giving up immutability semantics and optimizer trust.

The JEP motivation starts with the limitations of final. Final fields are Java’s main tool for expressing immutability, but they must be initialized eagerly: in constructors for instance fields and in class initialization for static fields. The declaration order also constrains initialization order. That is fine for trivial constants, but it is clumsy for real systems where constant values may still take meaningful time to construct, such as loggers, parsers, repositories, or precomputed structures.

The classic workaround looks familiar:

CODE
class OrderController {
    private Logger logger = null;

    Logger getLogger() {
        if (logger == null) {
            logger = Logger.create(OrderController.class);
        }
        return logger;
    }
}

This pushes work later, but it creates new costs. Every access has to go through the getter. Concurrency becomes your responsibility. The invariant “this field changes at most once” exists only in the developer’s head. More importantly, the JVM cannot trust a normal mutable field the way it trusts a true constant. That is the gap JEP 526 is trying to fill with deferred immutability: initialized no earlier than needed, at most once, and then effectively constant thereafter.

From Stable Values in Java 25 to Lazy Constants in Java 26

Lazy Constants are not the first version of this idea. Java 25 previewed the API as Stable Values. The original API was more flexible and more imperative. It exposed methods such as orElseSet, setOrThrow, and trySet, and it also offered stable suppliers and stable lists. It was powerful, but it felt like an API shaped around the underlying mechanism rather than the main user story.

Java 26 narrows the focus. JEP 526 renames the type to LazyConstant, removes the low-level setter-oriented methods, keeps construction-time factory methods that take a computing function, moves lazy list and map factories into List and Map, and disallows null as a computed value. The result is a smaller and clearer API centered on one common pattern: define the computation at declaration time, then call get() when you need the value.

That leads to a much simpler idiom:

CODE
class OrderController {
    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 get() call guarantees that the constant is initialized before it returns, and that under concurrent access only one thread performs a successful initialization. If initialization fails, a later get() may retry. That makes the feature feel much more like a natural Java API instead of a concurrency utility with extra rules.

Why the Rename Matters

The most important sentence in the JEP history is not about syntax, but about naming. The old name, StableValue, suited a low-level API closer to the mechanism underneath. The new name, LazyConstant, emphasizes the high-level use case: one constant value, initialized on demand.

That rename also reveals what the feature is not. It is not a general-purpose lazy container. It is not an all-purpose cache slot. It is not a future replacement for every custom initialization pattern. The design says, very clearly: this is for a single value that should be computed late, at most once successfully, and then treated as immutable. That narrower definition is exactly what gives the runtime room to optimize.

Under the Hood: The Hidden Role of @Stable

This is where the feature becomes much more interesting for JVM engineers and performance-minded developers.

Lazy Constants are implemented in the Java libraries, not as a new language feature and not as a new bytecode instruction. JEP 526 explicitly says it is not a goal to add syntax for lazy fields and not a goal to alter the semantics of final. Yet the runtime story is not merely ordinary library code; it relies on existing JVM support for trusted stable state. JEP 502 explains that the content of a stable value is stored in a non-final field annotated with the JDK-internal @Stable annotation, and JEP 526 says the original StableValue name was closer to that underlying JVM mechanism.

@Stable tells the JVM that, although a field is technically not final, it will not change after its one successful update. If a lazy constant is reachable through a trusted path rooted in static final, the JVM can treat its content like a true constant and apply constant-folding optimizations. That is the feature’s real value proposition. It is not just laziness. It is laziness with constant-like semantics.

Before Lazy Constants, developers had to choose:

  • use final and get optimizer trust, but initialize eagerly;
  • use mutable lazy patterns and defer work, but give up optimizer trust.

Lazy Constants exist to recover both. They are Java’s developer-facing wrapper around a JVM idea that JDK internals have benefited from for a while.

Collections: where the second preview got smarter

A single constant is useful, but many real systems need pools, registries, or fixed sets of lazily created objects. Java 26 bakes that into the collections API through List.ofLazy(...) and Map.ofLazy(...). A lazy list stores each element behind its own lazy constant and initializes elements independently on first access. A lazy map does the same for a known key set.

That is a small API design choice with a big usability payoff. In Java 25, the collection helpers lived under StableValue. In Java 26, they live where developers will look for them: List and Map. This is another sign that the second preview is intentionally moving away from mechanism-first design toward everyday discoverability.

What Most Articles Miss

By now, most public articles cover the happy path: startup wins, constant folding, API simplification, and the rename. The more interesting part for an experienced audience is where Lazy Constants are NOT a perfect fit.

Blocking Behavior Can Turn get() Into a Contention Point

The usual examples make get() look innocent. You call it, the value appears, then future accesses are cheap. That is true when initialization is fast and reliable.

If multiple threads race to initialize the same lazy constant, only one computing thread runs the supplier while the others block until initialization completes. That is correct and predictable. But thread interruption does NOT cancel initialization. LazyConstant::get does not clear interruption state and does not throw InterruptedException. The API documentation also makes clear that there is no timeout or cancellation support for callers waiting on initialization.

Because the API provides no timeout and no cancellation, Lazy Constants are a poor abstraction for anything whose initialization path involves flaky networks, remote discovery, external services, or unpredictable I/O. If the supplier is not truly bounded and reliable, get() turns from a constant accessor into a potential contention point.

Retry-After-Failure Can Repeat Side Effects

Another subtle point is the behavior on failure. If the computing function throws, the throwable is propagated, the lazy constant remains uninitialized, and a later get() may try again.

That is a sensible design for transient errors, but it has an uncomfortable implication. If your supplier writes a file, registers a resource, logs a metric, updates counters, or performs any partially visible work before failing, the next call may do it again. Many developers assume Lazy Constants are initialized once, but the precise truth is initialized at most once successfully.

Consider this dangerous pattern:

CODE
private final LazyConstant<Connection> db = LazyConstant.of(() -> {
    Metrics.increment("db.connection.attempts"); // SIDE EFFECT
    return Database.connectOrThrow();            // MIGHT FAIL
});

If connectOrThrow() fails, the LazyConstant remains uninitialized. The next time a thread calls db.get(), the supplier runs again, and your db.connection.attempts metric increments a second time.

Memory Retention Is the Hidden Startup Trade-Off

The startup story around Lazy Constants is attractive because it focuses on work you do not pay up front. What gets less attention is the work you cannot undo later.

Once a lazy constant is initialized, its content can never be removed. The lazy constant strongly references its content for as long as the lazy constant itself remains reachable. The computing function is also strongly referenced at least until initialization completes successfully. The Java 26 API documentation warns explicitly that this can become an unintended memory leak.

This is a crucial trade-off: Lazy Constants are not a cache eviction mechanism, not a soft reference, and not a general resource lifecycle tool. They are a permanent one-way transition: empty, then initialized forever. If the constant happens to be a large object graph, an oversized parser table, or a rarely used but heavyweight subsystem, you are trading startup cost for long-term retention.

The Optimization Story Is Narrower Than It Sounds

Writers love to say, Store a LazyConstant in a final field and the JVM can optimize it like a constant. That is directionally true, but the real rule is narrower. The Java 26 API docs say constant folding is only possible if there is a direct reference from a static final field to a lazy constant, or if there is a chain from a static final field through one or more trusted fields.

That is not the same as all final-field uses get the same win.

Instance final fields are still weakened by reflective mutability, except in special cases, while static final fields remain much more reliably optimizable. JEP 502’s risks section calls this out directly. In practice, this means the optimizer payoff is strongest in static singleton-style patterns and more conditional elsewhere. They are not a magic performance pass you sprinkle on every instance field.

Furthermore, they are not a universal replacement for lower-level patterns. For example, you can store an array inside a lazy constant, but doing so does not improve the access performance of the array’s individual elements. For that, the new lazy collections, such as List.ofLazy(...), are the better abstraction.

Class Initialization Order Still Matters

It is tempting to see Lazy Constants as a way to escape Java’s static initialization headaches. They certainly help reduce eager work, and that alone can simplify startup profiles. But they do not suspend the rules of class initialization.

The Java 26 API documentation warns that use in static initializers can still interact with class initialization order, and cyclic initialization may still produce initialization errors. Delayed work is not the same thing as simpler dependency graphs. If your system already has class-init ordering problems, Lazy Constants may simply move the failure from startup to first use.

One More Practical Limit: Serialization

There is one more practical limitation worth noting. LazyConstant is not Serializable. That is not a problem for most internal runtime structures, but it does matter if you are tempted to embed lazy constants inside objects that participate in serialization-based frameworks, remote DTOs, or session replication.

So What Are Lazy Constants Actually Good For?

After all those warnings, it is worth being clear: Lazy Constants are still a very good idea.

They are excellent when you have expensive, immutable, on-demand state that should be initialized safely and only when first touched. Loggers, optional subsystems, parsers, repositories, pools with independently initialized entries, fixed registries, and component graphs that benefit from staggered warmup are all strong candidates. JEP 526 positions the feature precisely as a way to initialize application state incrementally, on demand, rather than monolithically, while guaranteeing at-most-once initialization in multi-threaded programs.

They are especially appealing in code where the old alternatives were ugly:

  • the class-holder idiom only works for static cases and can become verbose;
  • double-checked locking is error-prone and blocks constant folding;
  • volatile array schemes with VarHandle are difficult to read and easy to get wrong;
  • concurrent maps defer work but do not give the JVM the same trust guarantees.

Lazy Constants clean up that mess. They just do not make the trade-offs disappear.

How Lazy Constants Align with Java’s Computation-Shifting Goals

Lazy Constants are not formally part of Project Leyden, but they align closely with Leyden’s broader goal of improving startup, warmup, and footprint by shifting computation to a more useful point in a program’s lifecycle.

Seen that way, LazyConstant gives developers a standard way to defer initialization until first use while still preserving the JVM’s ability to treat the resulting value like a trusted constant when the usage pattern allows it. Unlike traditional lazy-init patterns that rely on mutable state and often weaken optimization, Lazy Constants aim to preserve both flexibility and optimizer trust.

The Real Takeaway

The most useful way to think about Lazy Constants is this:

  • They are not Java finally adding lazy fields.
  • They are not just a prettier Supplier.
  • They are not a universal replacement for every deferred initialization technique.

They are Java 26’s current answer to a much narrower and much more interesting question: How do we expose deferred immutability to user code without giving up JVM trust?

That is why Lazy Constants matter. And that is also why the interesting conversation is not “How do I replace a null check?” but “Which initialization problems deserve constant-like semantics, and which ones do not?”

That is the part most articles miss.

The Lazy Constants Cheat Sheet

  • Best fit: static final fields, singletons, parsers, optional subsystems, fixed registries, heavy object graphs that should initialize on first use.
  • Optimization rule: The JVM optimizes best when the lazy constant is reachable from a static final root. Instance-field cases are more conditional.
  • Concurrency: get() blocks competing threads during computation. No timeout, no cancellation, no InterruptedException.
  • Failure mode: At most once successfully. If the supplier throws, the next get() may try again. Avoid side effects before failure.
  • Memory: No eviction. Once initialized, the value is held strongly for as long as the LazyConstant is reachable.
  • Bad fit: Flaky I/O, remote service discovery, heavy initialization that must be cancelable, values that need eviction, and lifecycle-driven initialization where the supplier is not known at construction time.

This article is part of the JAVAPRO magazine issue:

From Coder To System Designer

Understand what it means to move from coding to designing systems in the age of AI.
Take a closer look at modern Java platforms, architectural thinking, and the responsibilities that come with shaping complex software systems.

Discover the edition