Before you start: this is a preview

JEP 531 is the third preview of lazy constants in JDK 27. The API has changed in every release so far. Do not ship it.

You need a JDK 27 build. Check the toolchain first:

java --version
javac --version

Preview must be enabled at compile time and run time:

javac --enable-preview --release 27 LazyConstantsDemo.java
java --enable-preview LazyConstantsDemo

For scratch experiments, jshell --enable-preview works the same way. This post teaches the JDK 27 shape only: java.lang.LazyConstant, LazyConstant.of(...), get(), and the ofLazy factories on List, Map, and Set. If an older tutorial talks about StableValue, orElseSet, or isInitialized(), it is describing a superseded preview.

The problem: final is fast, mutable is flexible

A final field must be assigned in a constructor or static initializer. That is exactly what the JVM wants — a value it can trust never changes, so it can fold reads of it into a constant. It is also exactly what your startup time does not want, because private final RateTable rates = RateTable.load() parses a large file on every construction, whether or not the request ever needs it.

Drop final and a plain if (rates == null) gets you the timing you wanted plus a race: two threads can both see null and both call load(). The classic fix is double-checked locking. It works, and it is the reason this JEP exists.

class Checkout {
    private volatile RateTable rates;

    RateTable rates() {
        RateTable local = rates;
        if (local == null) {
            synchronized (this) {
                local = rates;
                if (local == null) {
                    rates = local = RateTable.load();
                }
            }
        }
        return local;
    }
}

Count what you must get right: the field has to be volatile, the local copy avoids a second racy read, the null check happens twice, and every caller has to go through rates() instead of touching the field. Get any of it wrong and the bug is a visibility bug — it will not reproduce on your laptop. Worse, the field is mutable, so the JIT cannot constant-fold it: you paid in complexity and still lost the optimization final gave you for free.

The initialization-on-demand holder idiom keeps the optimization by borrowing lazy class initialization:

class Checkout {
    private static class Holder {
        static final RateTable RATES = RateTable.load();
    }

    static RateTable rates() {
        return Holder.RATES;
    }
}

That is correct and fast, but it only works for static fields, and every lazy value needs its own holder class. Ten lazy values means ten classes to load, name, and read past.

The mental model

A lazy constant is a holder object that you create eagerly and that computes its content at most once, on first read. You hand LazyConstant.of(...) the computing function up front; nobody can set the content from outside, because there is no setter in this API. The only operation is get(), and it guarantees the constant is initialized by the time it returns.

class Checkout {
    private final LazyConstant<RateTable> rates = LazyConstant.of(RateTable::load);

    RateTable rates() {
        return rates.get();
    }
}

That is the whole migration from the double-checked block above: no volatile, no synchronized, no null checks, and no way for a caller to observe a half-built value. Three properties do the work. At most once: if eight threads race on get(), one runs the computing function and the rest block until it finishes, so side effects like opening a file happen once. Unmodifiable after init: content can never change, so it is safely published and safe to elide. Foldable: the content sits in a field the JVM is told will only ever be written once.

Lazy constants sit between final and mutable: initialization is deferred, immutability is not. The table from JEP 531 is the clearest one-screen summary of the design:

Field kindTimes writtenValue computed inConstant folding?
final field1Constructor or static initializerYes
LazyConstant0 or 1Computing functionYes, after initialization
Non-final field0 to manyAnywhereNo

Where the API stands: StableValue became LazyConstant

The name and shape changed twice, so search results are a minefield:

ReleaseJEPType nameWhat changed
Java 25JEP 502StableValueFirst preview. Low-level orElseSet, setOrThrow, trySet.
Java 26JEP 526LazyConstantRenamed. Low-level setters removed, List.ofLazy / Map.ofLazy added, null content disallowed.
Java 27JEP 531LazyConstantThird preview. isInitialized() and orElse() removed, Set.ofLazy added.

If a snippet calls setOrThrow, trySet, orElseSet, isInitialized(), or orElse(), it will not compile on JDK 27. Those methods were deliberately dropped because they let callers use the holder as a general-purpose mutable box instead of a constant.

Lab: your first lazy constant

LazyConstant lives in java.lang, so there is nothing to import. Put this in LazyConstantsDemo.java:

public class LazyConstantsDemo {

    record Config(String region, int maxRetries) {}

    static final LazyConstant<Config> CONFIG = LazyConstant.of(LazyConstantsDemo::loadConfig);

    static Config loadConfig() {
        System.out.println("  [computing config]");
        return new Config("eu-west-1", 3);
    }

    public static void main(String[] args) {
        System.out.println("app started");
        System.out.println("region  = " + CONFIG.get().region());
        System.out.println("retries = " + CONFIG.get().maxRetries());
    }
}

Compile and run it with the two preview commands above. The output shows the two things that matter — the computing function runs after main begins, and it runs once for two get() calls:

app started
  [computing config]
region  = eu-west-1
retries = 3

Move loadConfig() into a static final Config field instead and the [computing config] line moves above app started. That difference is the entire point of the feature.

Lab: prove the at-most-once guarantee

The single-threaded case is easy to believe. Race it with eight threads in a second file, LazyRaceDemo.java, to see the guarantee double-checked locking was protecting.

import java.util.concurrent.Executors;

static final LazyConstant<String> TOKEN = LazyConstant.of(LazyRaceDemo::mintToken);

static String mintToken() {
    System.out.println("  [minting on " + Thread.currentThread().getName() + "]");
    return "tok-42";
}

public static void main(String[] args) {
    try (var pool = Executors.newFixedThreadPool(8)) {
        for (int i = 0; i < 8; i++) {
            pool.submit(() -> System.out.println(
                    Thread.currentThread().getName() + " read " + TOKEN.get()));
        }
    }
}

Read order varies between runs, but the [minting ...] line appears exactly once. The winning thread runs the computing function on its own stack; the losers block until it completes, then return the same content.

Note: interruption does not cancel initialization. If the computing function blocks forever, every waiter blocks forever — get() has no timeout and throws no InterruptedException.

Compose lazy constants into a dependency graph

A lazy constant can read another lazy constant, which lets a whole subsystem initialize itself in dependency order on first real use.

static final LazyConstant<Config> CONFIG = LazyConstant.of(Config::load);

static final LazyConstant<DataSource> DATA_SOURCE =
        LazyConstant.of(() -> DataSource.from(CONFIG.get()));

static final LazyConstant<OrderRepository> ORDERS =
        LazyConstant.of(() -> new OrderRepository(DATA_SOURCE.get()));

static OrderRepository orders() {
    return ORDERS.get();
}

Calling orders() builds the repository, which builds the data source, which loads the config — each exactly once. Nothing is built if the process never touches orders. LazyConstant<T> also extends Supplier<T>, so it drops straight into any API that already takes a supplier.

A computing function must not read its own lazy constant. A self-recursive computation cannot terminate, so get() throws NoSuchElementException with an IllegalStateException cause instead of deadlocking or overflowing the stack.

List.ofLazy: a pool without eager construction

List.ofLazy(size, computingFunction) gives you a fixed-size list where every element is its own lazy constant, initialized independently on first get(index).

private static final int POOL_SIZE = 3;

static final List<OrderController> CONTROLLERS =
        List.ofLazy(POOL_SIZE, _ -> new OrderController());

static OrderController controller() {
    int index = (int) (Thread.currentThread().threadId() % POOL_SIZE);
    return CONTROLLERS.get(index);
}

The computing function is an IntFunction, so it receives the index. This example ignores it with an unnamed variable; a sharded cache would use it to build a different value per slot. Size is fixed at creation, the list is unmodifiable, subList() and reversed() views stay lazy, and a single-threaded run creates one OrderController instead of three.

Note: equals(), hashCode(), and toString() on a lazy list force initialization of the elements they touch — logging one at DEBUG level will quietly build your entire pool.

Map.ofLazy: memoize a known key set

Map.ofLazy(keys, computingFunction) takes the complete key set up front and computes each value on first lookup.

static final Map<Integer, Double> TERM_RATES =
        Map.ofLazy(Set.of(1, 3, 12, 24), LazyConstantsDemo::fetchRate);

static double fetchRate(int termMonths) {
    System.out.println("  [fetching rate for " + termMonths + "m]");
    return 0.031 + termMonths * 0.0004;
}

Each key’s value is computed at most once, even under concurrent lookups, and initialized values are foldable — read two keys and only those two [fetching rate ...] lines appear. But this is not a general cache. The key set is closed: TERM_RATES.get(36) returns null without calling the computing function, because 36 was never a key. Use Map.ofLazy when the domain is enumerable — supported currencies, tenant IDs, feature groups — and ConcurrentHashMap.computeIfAbsent when keys arrive at run time.

Set.ofLazy: compute membership once

New in JDK 27, Set.ofLazy(elementCandidates, predicate) stores the membership status of each candidate in a lazy constant. It is the natural fit for feature flags, where deciding whether a flag is on may mean reading a file or hitting a database.

enum Option { VERBOSE, DRY_RUN, STRICT }

static final Set<Option> OPTIONS =
        Set.ofLazy(EnumSet.allOf(Option.class), LazyConstantsDemo::isEnabled);

static boolean isEnabled(Option option) {
    // parse the command line, read a config file, query a database
    return option == Option.DRY_RUN;
}

static void process() {
    if (OPTIONS.contains(Option.DRY_RUN)) {
        return; // skip the real work
    }
}

The predicate runs at most once per candidate. Once contains(DRY_RUN) has been answered and the set is reached from a static final field, the JIT can fold the condition and delete the dead branch — a flag check that costs nothing after warmup. A candidate outside the set returns false without invoking the predicate.

static final is not optional for folding

The API works in any field. The optimization has a stricter rule: the JVM only trusts the content when there is a direct reference from a static final field to the lazy constant, or a chain from a static final field through trusted fields — other static final fields, record components, or final fields of hidden classes.

// Foldable: reached directly from a static final field.
static final LazyConstant<RateTable> RATES = LazyConstant.of(RateTable::load);

// Correct and thread-safe, but not foldable today.
private final LazyConstant<RateTable> instanceRates = LazyConstant.of(RateTable::load);

The reason is reflection. Core reflection can still overwrite most instance final fields, so the JVM cannot assume they are stable; it cannot do that to static final fields, which is why folding across them is routine. Instance fields still get correct at-most-once initialization — they just do not get the constant, so put hot-path lazy constants in static final fields and keep instance fields for per-object deferred initialization.

Failure is sticky

If the computing function throws an unchecked exception, the constant does not initialize — it moves to an error state, permanently.

import java.util.NoSuchElementException;

static final LazyConstant<String> SECRET =
        LazyConstant.of(() -> { throw new IllegalStateException("vault unreachable"); });

static void probeSecret() {
    for (int attempt = 1; attempt <= 2; attempt++) {
        try {
            SECRET.get();
        } catch (NoSuchElementException e) {
            System.out.println("attempt " + attempt + " cause: " + e.getCause());
        }
    }
}

The first get() wraps your exception; the second reports the same failure with no cause and never calls the function again:

attempt 1 cause: java.lang.IllegalStateException: vault unreachable
attempt 2 cause: null

Two more cases land in the same error state. Returning null throws NoSuchElementException with a NullPointerException cause — a lazy constant can never hold null, so wrap genuinely optional content in Optional. Self-recursive computation throws with an IllegalStateException cause.

There is no retry. That is the right default for immutable configuration and the wrong tool for a flaky network call you expect to succeed on the second attempt. Put retry logic inside the computing function when the value can legitimately fail.

Gotchas

  • Content is never released. A lazy constant strongly references its content for as long as the constant is reachable. A static final one holding something large is a leak you cannot clear.
  • Arrays gain nothing. An array inside a lazy constant does not make element access foldable; use a lazy list, nested as deep as you need.
  • equals is identity. Two lazy constants with equal content are not equal. On LazyConstant itself, equals, hashCode, and toString never trigger initialization — unlike the lazy collections, where they do. The type is also not Serializable, and locking on one is unsafe because the implementation may synchronize on itself.
  • Preview everywhere. Compiler, launcher, tests, IDE, and build tool all need matching JDK 27 preview settings.

Cheat sheet

JDK 27 / JEP 531: third preview — not production-stable
Compile: javac --enable-preview --release 27 Demo.java
Run:     java --enable-preview Demo   |   Shell: jshell --enable-preview

java.lang.LazyConstant<T>             // sealed, extends Supplier<T>
LazyConstant.of(Supplier<T>)          // computing function fixed at creation
lazy.get()                            // compute once, then same content forever

List.ofLazy(int size, IntFunction<E>)       // per-index lazy elements
Map.ofLazy(Set<K> keys, Function<K,V>)      // closed key set, per-key lazy values
Set.ofLazy(Set<E> candidates, Predicate<E>) // lazy membership (new in 27)

Folding needs: static final field -> lazy constant (or a chain of trusted fields)
null content:     NoSuchElementException (cause NullPointerException)
Function throws:  permanent error state, NoSuchElementException on every get()
Recursion:        NoSuchElementException (cause IllegalStateException)
No setters, no timeout, no cancellation, no interruption of initialization
Renamed from StableValue (JDK 25); gone: orElseSet, setOrThrow, trySet, isInitialized(), orElse()

Do:

  • Replace double-checked locking and holder classes with LazyConstant.of(...).
  • Put hot-path lazy constants in static final fields so folding applies.
  • Compose lazy constants so a subsystem initializes in dependency order on first use.
  • Reach for List/Map/Set.ofLazy when the index range, key set, or candidate set is known up front, and handle any retry inside the computing function.

Don’t:

  • Copy examples that use StableValue, orElseSet, setOrThrow, or isInitialized().
  • Treat Map.ofLazy as an open-ended cache; unknown keys return null.
  • Return null from a computing function, or expect a failure to be retryable.
  • Log or toString() a lazy collection you did not mean to fully initialize.
  • Ship JDK 27 preview class files as production artifacts.

Wrap-up

Lazy constants close the gap between final and mutable fields. You keep the deferred initialization the mutable version gave you, plus the at-most-once guarantee and the constant folding final gave you — without a volatile field, a synchronized block, and two null checks per lazy value.

The JDK 27 surface is deliberately small: create with LazyConstant.of(...), read with get(), and use List.ofLazy, Map.ofLazy, or Set.ofLazy when the whole index range, key set, or candidate set is known in advance. Failure is permanent, null is rejected, and folding requires a static final reference. Try it on a service that loads configuration or builds a client at startup, since that is where the win shows up first — then leave it behind --enable-preview until JEP 531 finalizes, because three previews in three releases is exactly the signal it looks like.