Core Java

Lazy Constants (JEP 531): JIT’s Best Friend You Haven’t Met

How ofLazy() unlocks constant-folding without static final, why it finally replaces double-checked locking, and what the numbers look like in practice.

Every Java developer knows the tension. You want a value to be immutable so the JIT can inline it, skip null checks, and apply constant-folding — but you also want it to initialize lazily, not at class-load time. The two goals have always seemed to pull in opposite directions: static final gives you JIT trust at the cost of eager initialization, and mutable fields give you lazy initialization at the cost of JIT trust. For years the standard answer was double-checked locking, Holder idioms, or a private synchronized method — all workable, all boilerplate-heavy, and none of them visible to the optimizer.

That is exactly the gap that JEP 531: Lazy Constants (Third Preview) is designed to close. The feature, which first appeared as Stable Values in Java 25 (JEP 502), was significantly redesigned and renamed in Java 26 (JEP 526), and is now targeting JDK 27 in its third preview form. Even though it is still a preview API, the direction is clear — and understanding it now means you will be ready the moment it graduates.

Preview status notice: JEP 531 is a preview API targeting JDK 27. All examples require --enable-preview at compile time. The API surface can still change before finalization.

The Problem with final and Lazy Initialization

Let’s be precise about what “constant-folding” actually means here, because it is the heart of why this feature matters. When the JIT compiles a method that reads a static final field, it can embed the value directly into the generated machine code — no memory load required. Furthermore, it can propagate that value through branches and eliminate dead code. This is a significant optimization, especially for configuration objects, loggers, connection pools, and service locators that get read millions of times per second.

The catch, as the JEP itself explains, is that final fields must be assigned during the object’s constructor or a static initializer block. This means they are initialized when the class loads, not when the value is first needed. On the one hand, that forces every dependency to be created at startup, regardless of whether it will actually be used. On the other hand, if you drop the final keyword to allow lazy assignment later, the JVM can no longer trust that the value is stable — and it quietly gives up on constant-folding entirely.

In other words, the JVM’s problem is one of trust: it needs a guarantee that a field’s value will never change once it is set, but the language only provides that guarantee at the syntactic level of final declarations, not at the semantic level of “written exactly once, some time after construction.”

The Double-Checked Locking Band-Aid

The most common workaround — double-checked locking — solves thread safety but does nothing for the JIT. Consider the classic pattern:

// Classic double-checked locking
public class ServiceRegistry {
    private static volatile Logger logger;

    public static Logger getLogger() {
        if (logger == null) {
            synchronized (ServiceRegistry.class) {
                if (logger == null) {
                    logger = LoggerFactory.getLogger(ServiceRegistry.class);
                }
            }
        }
        return logger;
    }
}

This compiles and runs correctly on Java 5+ thanks to the volatile keyword’s happens-before guarantee. However, it forces the JIT to treat logger as potentially mutable on every read — meaning no constant-folding, no null-check elision, and a volatile memory barrier on the hot path even after initialization. The first time is expensive; the subsequent times are just slightly more expensive than they need to be. Multiply that by thousands of calls per second and it adds up.

Enter LazyConstant and ofLazy()

JEP 531 introduces a new API centered on the LazyConstant<T> class. Think of it as a container that can hold exactly one value of type T. Once that value is written, it is immutable for the lifetime of the container — the JVM gets the stability guarantee it needs, even though the assignment happens lazily.

Internally, the implementation uses the JDK-internal @Stable annotation — the same annotation that the JDK itself has used for years on arrays in StringMethodType, and other core classes — together with Unsafe memory barriers to ensure correct visibility across threads. No changes to bytecode, the class file format, or the JVM specification were required; it is pure Java library code, which is part of why it could ship as a preview feature without a deeper platform commitment.

The API surface is intentionally small. You create a lazy constant by calling the static factory and providing a computing function. You read it by calling .get():

// Java 26/27 with --enable-preview
import java.lang.invoke.LazyConstant;
import org.slf4j.LoggerFactory;
import org.slf4j.Logger;

public class ServiceRegistry {
    // The LazyConstant itself is final — the JIT can trust the container
    private static final LazyConstant<Logger> LOGGER =
        LazyConstant.of(() -> LoggerFactory.getLogger(ServiceRegistry.class));

    public static Logger getLogger() {
        return LOGGER.get(); // initialized at most once, thread-safe
    }
}

There is no null check written by the developer, no synchronized block, and no volatile keyword. The platform handles all of that. More importantly, because LOGGER itself is static final, the JIT can constant-fold the container — and once the container’s value has been set, the JIT can constant-fold through the container to the value itself.

You hold the LazyConstant in a static final field. The JIT trusts the container, and through the @Stable annotation the JIT eventually trusts the value inside it too — giving you both laziness and full constant-folding.

From Stable Values to Lazy Constants: A Quick Evolution

If you have been following this feature across JDK versions, the renaming and API changes can be confusing. The table below summarizes what changed and why, so you can map older blog posts to the current state of the feature.

JDKJEPNameKey changes
25502Stable Values (Preview)Initial preview. Low-level API: setOrThrowtrySetorElseSet exposed.
26526Lazy Constants (2nd Preview)Renamed. Removed low-level methods. Factory methods for lazy List and Map moved into java.util.List / java.util.Map. Removed isInitialized() and orElse().
27531Lazy Constants (3rd Preview)Targeting JDK 27. Minor further refinements. API stabilizing toward final form.

The shift from JEP 502 to JEP 526 was more than a renaming exercise. The original Stable Values API exposed low-level operations that made it feel like a synchronization primitive, which is not the intent. Lazy Constants instead present a high-level abstraction: you describe how to compute a value, and the platform takes care of when, concurrency, and optimization. As a result, the API is both easier to use correctly and harder to misuse.

Lazy Lists and Lazy Maps: Scaling the Pattern

Beyond a single constant, the feature generalizes naturally to collections. The factory methods List.ofLazy(int, IntFunction) and Map.ofLazy(Set, Function) create collections where each element is itself a lazy constant — initialized on first access, backed by @Stable for JIT optimization, and fully thread-safe.

A practical example is a connection pool where each slot should only be allocated if it is actually needed by the application. With a lazy list, this becomes straightforward:

// Lazy pool of database connections — each slot initialized on demand
import java.util.List;
import com.example.db.DatabaseConnection;

public class ConnectionPool {
    private static final int POOL_SIZE = 10;

    // Each element is its own lazy constant
    private static final List<DatabaseConnection> POOL =
        List.ofLazy(POOL_SIZE, i -> new DatabaseConnection("jdbc:postgresql://host/db", i));

    public static DatabaseConnection acquire(int slot) {
        return POOL.get(slot); // initialized exactly once per slot, thread-safe
    }
}

Similarly, a lazy map is useful for locale-to-resource-bundle resolution, where you want keys known at construction time but values loaded only when a given locale is actually requested:

// Lazy map: locale keys known upfront, bundles loaded on first access
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;

public class I18nRegistry {
    private static final Set<Locale> SUPPORTED =
        Set.of(Locale.ENGLISH, Locale.FRENCH, Locale.GERMAN);

    private static final Map<Locale, ResourceBundle> BUNDLES =
        Map.ofLazy(SUPPORTED, locale ->
            ResourceBundle.getBundle("messages", locale));

    public static ResourceBundle bundleFor(Locale locale) {
        return BUNDLES.get(locale);
    }
}

Both of these patterns were achievable before, but they required either eager initialization (wasting startup time) or custom lazy initialization code (adding complexity and losing JIT benefits). Lazy collections combine all three desirable properties at once: deferred initialization, thread safety, and JIT-visible stability.

Steady-State Access Latency — Simulated Comparison (ns/op)

Illustrative steady-state throughput figures representative of patterns documented in OpenJDK performance analysis. “After JIT warm-up” scenario with 10⁶ iterations. Lower is better.

What the Numbers Actually Look Like

Benchmarking lazy initialization patterns requires care. The interesting comparison is not cold startup — where all lazy approaches win equally over eager. The more important question is what the hot path looks like after the JIT has warmed up and the value has been initialized. That is where constant-folding makes the difference.

The table below summarizes representative steady-state access costs for the four main patterns. These figures reflect the characteristics documented in OpenJDK performance analysis and community JMH investigations into the feature, and should be treated as order-of-magnitude estimates rather than exact measurements — the precise numbers vary by JVM version, hardware, and surrounding call graph.

PatternThread-safe?Constant-foldable?Relative hot-path costBoilerplate
static finalYesYesBaseline (≈ 0.3–0.5 ns)Eager init required
Double-checked lockingYesNo2–5× baselineSignificant
Holder class idiomYesPartial1.5–3× baselineExtra class per constant
LazyConstant.of()YesYes (after warm-up)≈ baseline after JITMinimal

The key takeaway from these figures is that LazyConstant eventually performs as well as a plain static final on the hot path — something that double-checked locking fundamentally cannot do, because the volatile read prevents the JIT from constant-folding the value away. The holder class idiom gets closer, but only for per-class singletons, not for instance-level or collection-element constants.

Startup Time Impact — Eager vs Lazy Initialization Styles

Relative startup overhead for an application initializing 50 heavyweight components. Lazy patterns defer cost to first use. Lower is better.

Thread Safety Without the Ceremony

One of the subtler benefits of LazyConstant is how it handles concurrency. The specification guarantees that the computing function is called at most once, even under concurrent access from many threads. If two threads simultaneously call .get() on an uninitialized constant, one will run the function and the other will block — briefly — and then receive the same computed value. There is no possibility of two different values being published to different threads.

This guarantee is enforced through memory barriers inside the implementation, built on top of Unsafe. Consequently, even though the field holding the value inside the container is not technically volatile or final at the language level, the correct happens-before relationship is established at initialization time. After that point, the @Stable annotation tells the JIT that the value is frozen, which is precisely when constant-folding kicks in.

Notably, the computing function must be pure — that is, it should not rely on or produce side effects that depend on being called exactly once. If the function throws an exception, the LazyConstant is not considered initialized: the next call to .get() will try again. This is a sensible design choice that matches what developers expect from a deferred initialization API.

When Should You Reach for Lazy Constants?

Not every field is a good candidate. Lazy Constants are most valuable when three conditions overlap: the initialization is non-trivial (in cost or in dependencies), the value may not be needed on every application run, and the hot path is performance-sensitive after initialization. The table below maps common scenarios to the right approach.

ScenarioRecommended approachReason
Cheap, always-needed constant (e.g. a regex pattern)static finalNo benefit from deferral; final is simpler
Expensive singleton loaded at startup unconditionallystatic final or DI frameworkLazy deferral adds complexity with no startup benefit
Logger, codec, cache initialized only if feature is usedLazyConstant.of()Deferred init + JIT constant-fold on hot path
Pool of objects, one per slot/threadList.ofLazy()Per-element lazy init, each foldable by JIT
Lookup table by known key set (e.g. locale → bundle)Map.ofLazy()Keys fixed at construction, values deferred per key
Instance field initialized after constructionLazyConstant.of()Cannot use final for post-construction init

Migrating from Double-Checked Locking

If you already have double-checked locking in your codebase, migration to LazyConstant is mechanically straightforward once JDK 27 is available in your project. The pattern is consistent: replace the mutable volatile field and its accessor with a static final LazyConstant and a one-line .get() call.

The before-and-after for a typical service class looks like this:

// BEFORE: double-checked locking
public class MetricsService {
    private static volatile MetricsRegistry registry;

    public static MetricsRegistry getRegistry() {
        if (registry == null) {
            synchronized (MetricsService.class) {
                if (registry == null) {
                    registry = new MetricsRegistry(); // expensive setup
                }
            }
        }
        return registry;
    }
}

// AFTER: LazyConstant (Java 27 preview, --enable-preview)
import java.lang.invoke.LazyConstant;

public class MetricsService {
    private static final LazyConstant<MetricsRegistry> REGISTRY =
        LazyConstant.of(MetricsRegistry::new);

    public static MetricsRegistry getRegistry() {
        return REGISTRY.get();
    }
}

The lines of code drop noticeably, but more importantly, you get something the double-checked version never provided: JIT-visible stability. After the JIT warms up and the registry is initialized, calls to getRegistry() can be folded to a direct reference — no volatile read, no null check, no branch.

Migration tip: Keep one accessor method per lazy constant rather than exposing the LazyConstant object directly. This preserves encapsulation and gives you a clean interface to update if the API changes between preview rounds.

What This Means for Frameworks and Libraries

The most significant long-term impact of Lazy Constants may be felt not in application code, but in frameworks. Libraries like Spring FrameworkQuarkus, and Micronaut already invest considerable engineering effort in lazy bean initialization and startup-time optimization. With Lazy Constants, they gain an optimization pathway that is visible to the JIT in a way that custom lazy init patterns simply are not.

Furthermore, the JEP explicitly notes that one of its goals is to enable user code to benefit from constant-folding optimizations that were previously available only to JDK-internal code. The @Stable annotation has long been a private weapon used in the JDK’s own String handling, method dispatch tables, and intrinsic APIs. Lazy Constants democratize that mechanism behind a clean, public API.

This also matters for virtual threads. In high-concurrency scenarios with many lightweight threads reading the same service locators or configuration objects, the difference between a volatile read per access and a constant-folded reference can translate into meaningful throughput improvements at scale — especially when combined with the virtual threads introduced in Java 21.

What We Learned

JEP 531 Lazy Constants represent a quiet but important shift in how Java handles the tension between immutability and deferred initialization. We saw that the core problem was one of JIT trust: final fields allow constant-folding but force eager initialization, while mutable fields allow lazy initialization but forfeit JIT optimization entirely. Double-checked locking solves thread safety but leaves a volatile barrier on the hot path that the JIT cannot optimize away.

The LazyConstant.of() API resolves this by holding the container in a static final field and using the JDK-internal @Stable annotation to signal stability to the JIT once the value is set. The result is a pattern that is lazy at initialization time, thread-safe by design, and constant-foldable at steady state — matching the hot-path performance of a plain static final. The lazy collection variants (List.ofLazy() and Map.ofLazy()) extend the same guarantees to per-element initialization in pools and lookup tables. Still in third preview targeting JDK 27, this feature is well worth watching — and worth starting to migrate to as soon as your project moves to a modern JDK.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button