Skip to main content

Lazy Constants in JDK 27 (JEP 531): Replacing Double-Checked Locking

Java’s LazyConstant (JEP 531, third preview in JDK 27) against the holder idiom and double-checked locking: the API run on JDK 25, 26 and 27, the failure rule that changed between 26 and 27, List, Map and Set.ofLazy, a JMH benchmark, and why it is not yet for shipped code.

Some values are expensive to build and only sometimes needed: a parsed configuration file, a compiled regular expression, a connection pool, a large lookup table. Building them at start-up wastes time when nobody asks for them, and building them on the first request means two threads can arrive at once and both try. Java has had answers to this for a long time, and all of them are slightly awkward. JEP 531, Lazy Constants, is the third preview of a new one: a value you describe once, that the JVM computes on first use, exactly once, and afterwards treats as if it were a constant. This article starts from the three classic idioms and what each one actually does, then shows the new API with real output, what happens when the computation fails (a rule that changed between JDK 26 and 27), the new lazy collections, a benchmark of what reading the value costs, how to migrate from the JDK 25 preview, and the honest answer to whether to use any of it yet. It is a preview feature, and that word matters more than any other in the article. Every claim comes from a real compile and run, and each code block links to its file in the companion repository.
Versions. Tested on JDK 27+35 (Temurin, GA 15 September 2026) for the API; on JDK 26.0.2.1 and JDK 25.0.4.1 (Temurin) where the article compares releases; and on JDK 21.0.10 (Ubuntu OpenJDK) for the plain-Java baseline. LazyConstant is a preview API in 26 and 27, so every program that uses it needs --enable-preview at compile time (with --release set to the JDK you compile on) and again at run time. The benchmark uses JMH 1.37, downloaded from Maven Central and sha1-checked, on a 2-CPU virtual machine; treat its numbers as a shape, not a result. openjdk.org/jeps returned HTTP 403 to the tooling used for this article, so the behaviour below comes from running the JDKs, not from the JEP text.

Build it when you first need it: the three classic answers

Start with the problem in its smallest form. You have a class, Expensive, that takes a while to construct. You want exactly one instance, you want it built only if someone asks for it, and you want that to be safe when several threads ask at once. There are three long-standing ways to get some or all of that. The first is to build it eagerly in a static final field of the class that uses it. That is the simplest and it gives you thread safety for free, but the value is built when the class loads, whether or not it is ever used. The second is the holder idiom: put the value in a small nested class and read it through that class. The JVM initialises a class the first time it is touched and guarantees it does so once, so the value is built on first use with no locking in your code. The third is double-checked locking: keep the value in a volatile field, check it, take a lock only if it is still empty, and check again inside the lock.
// The three classic ways to hold a value that is expensive to build. Plain Java, no preview features.
public class OldWays {
    static class Expensive {
        final String how;
        Expensive(String how) { this.how = how; System.out.println("  [init] building Expensive via " + how); }
    }

    // 1. Eager: built when OldWays is initialised, which is before main runs, whether or not anyone uses it.
    static final Expensive EAGER = new Expensive("eager static final");

    // 2. Holder idiom: the JVM initialises Holder the first time it is touched, and does so exactly once.
    static class Holder { static final Expensive VALUE = new Expensive("holder idiom"); }
    static Expensive holder() { return Holder.VALUE; }

    // 3. Double-checked locking: correct only because the field is volatile.
    private static volatile Expensive dcl;
    static Expensive doubleChecked() {
        Expensive local = dcl;
        if (local == null) {
            synchronized (OldWays.class) {
                local = dcl;
                if (local == null) dcl = local = new Expensive("double-checked locking");
            }
        }
        return local;
    }

    public static void main(String[] args) {
        System.out.println("main starts (the eager value already exists)");
        System.out.println("first holder() call:");   holder();
        System.out.println("second holder() call:");  holder();
        System.out.println("first doubleChecked() call:");  doubleChecked();
        System.out.println("second doubleChecked() call:"); doubleChecked();
    }
}
Source: OldWays.java. The constructor of Expensive prints a line, so the output shows exactly when each value was built.
$ javac src/OldWays.java && java OldWays     (25.0.4.1+1-LTS)
  [init] building Expensive via eager static final
main starts (the eager value already exists)
first holder() call:
  [init] building Expensive via holder idiom
second holder() call:
first doubleChecked() call:
  [init] building Expensive via double-checked locking
second doubleChecked() call:
Output: 01-old-ways.txt, which also holds the JDK 21 and 27 runs (identical). The eager value is built before main prints anything. The holder and the double-checked value are each built on the first call and not on the second.
main starts Eager static finalHolder idiomDouble-checked locking built (class load) already there when anyone asks built at first call returned from then on built at first call returned from then on Only the eager value is built before anyone asks. Red is the cost you pay whether or not you use it.
The picture is the transcript in one line per idiom: the eager value sits left of the dashed line, before main starts, and the other two sit to its right, at the moment of first use. That difference is the whole reason to want laziness.
The awkward parts are what the new API removes. The holder idiom needs a separate nested class for every value and only works for static values. Double-checked locking works for instance fields too, but it is only correct because the field is volatile; drop that one word and the code still compiles, and by the rules of the memory model it can publish a half-built object to another thread (I reasoned that from the specification and did not try to reproduce the failure). Both idioms also hide the intent (“compute this once, later”) inside mechanics.
Going deeper: why each old idiom is correct

The holder idiom is correct because of the way the JVM initialises a class: it takes a per-class lock, runs the static initialisers once, and every thread that touches the class afterwards sees the result. The rules are in JLS 12.4.2 (Detailed Initialization Procedure). Double-checked locking is correct in modern Java only because the field is volatile: JLS 8.3.1.4 and the memory model in JLS 17.4 are what guarantee that a thread that reads a non-null field also sees a fully built object. Neither of these is something I re-derived here; the transcript above shows the behaviour on 21, 25 and 27, and the specification is where the guarantee comes from.

What both idioms cost at run time is measured later in this article, next to the new API, with the same benchmark.

Going deeper on this section

LazyConstant: the smallest thing that works

A LazyConstant<T> is a small holder object. You create it with LazyConstant.of(supplier), where the supplier is the code that builds the value, and you read it with get(). The supplier does not run when you create the holder. It runs the first time anyone calls get(), its result is remembered, and every later get() returns the same object without running it again.
import java.util.ArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;

// LazyConstant (JEP 531, third preview in JDK 27). Compile and run with --enable-preview.
public class Basics {
    static final AtomicInteger calls = new AtomicInteger();

    // The supplier runs at most once, on the first get(), no matter how many threads ask.
    static final LazyConstant<String> CONFIG = LazyConstant.of(() -> {
        calls.incrementAndGet();
        return "loaded";
    });

    public static void main(String[] args) throws Exception {
        System.out.println("supplier calls before get: " + calls.get());
        System.out.println("get() returns: " + CONFIG.get());
        System.out.println("get() again:   " + CONFIG.get());
        System.out.println("supplier calls after two gets: " + calls.get());

        AtomicInteger slowCalls = new AtomicInteger();
        LazyConstant<Integer> slow = LazyConstant.of(() -> {
            slowCalls.incrementAndGet();
            try { Thread.sleep(50); } catch (InterruptedException e) { throw new RuntimeException(e); }
            return 42;
        });
        var pool = Executors.newFixedThreadPool(16);
        var futures = new ArrayList<Future<Integer>>();
        for (int i = 0; i < 16; i++) futures.add(pool.submit(slow::get));
        for (var f : futures) f.get();
        pool.shutdown();
        System.out.println("16 threads asked at once, supplier calls: " + slowCalls.get());
    }
}
Source: Basics.java. The counter records how often the supplier ran. The second half asks the same question of a slow supplier from sixteen threads at once.
$ javac --enable-preview --release 27 src/Basics.java && java --enable-preview Basics     (27+35)
supplier calls before get: 0
get() returns: loaded
get() again:   loaded
supplier calls after two gets: 1
16 threads asked at once, supplier calls: 1
Output: 02-basics.txt. Before get() the supplier has run zero times; after two calls it has run once; and with sixteen threads racing on a supplier that sleeps for 50 milliseconds, it still ran exactly once. None of the other fifteen calls ran it again. That is the guarantee double-checked locking gives you when you write it correctly, in a single call.
unset[computing function=…] first get(): ok first get(): throws loaded[loaded] failed[failed with=…] get() returns the value,supplier never runs again get() throwsNoSuchElementException
A constant is in exactly one of three states, and it only ever moves forward. The failed branch is new in 27 and is the subject of the next section; the top branch is the one you will use nine times in ten. The bracketed text in the diagram is what toString() actually prints in each state, which is handy when you are debugging:
// What toString() shows in each state. The text after "@hash" is internal, so match on the bracketed part only.
public class ToStringStates {
    public static void main(String[] args) {
        LazyConstant<String> ok = LazyConstant.of(() -> "loaded");
        System.out.println("before get: " + shape(ok.toString()));
        ok.get();
        System.out.println("after get:  " + shape(ok.toString()));

        LazyConstant<String> bad = LazyConstant.of(() -> { throw new IllegalStateException("boom"); });
        try { bad.get(); } catch (RuntimeException e) { /* first failure */ }
        System.out.println("after failed get: " + shape(bad.toString()));
    }
    static String shape(String s) { return s.substring(s.indexOf('[')).replaceAll("\\$\\$Lambda/0x[0-9a-f]+@[0-9a-f]+", "\\$\\$Lambda"); }
}
$ javac --enable-preview --release 27 src/ToStringStates.java && java --enable-preview ToStringStates     (27+35)
before get: [computing function=ToStringStates$$Lambda]
after get:  [loaded]
after failed get: [failed with=java.lang.IllegalStateException]
Source: ToStringStates.java, output in 02-basics.txt. The part before the bracket is an internal class name and an address, so the program prints only what is inside the brackets.
The supplier gets no arguments and runs once, at the first get(). Anything it needs must be captured from the surrounding code. Keep it free of side effects you would mind happening on an arbitrary thread at an arbitrary time, and remember that it runs once: it is the wrong tool for a value that must be refreshed.
Going deeper: replacing a holder class and a volatile field in an ordinary class

Here is the shape you will most often write: one shared value in a static final field, and one per-instance value that is only sometimes needed, in a final instance field. These are the two jobs that used to need a holder class and a volatile field with a synchronised block:

import java.util.regex.Pattern;

// Where the holder idiom and double-checked locking used to live: a costly resource inside an ordinary class.
public class Service {
    record Config(String url, int retries) {
        static Config load() { System.out.println("  [load] reading configuration"); return new Config("https://example.test", 3); }
    }

    // A shared, expensive-to-build value: a static final LazyConstant is the replacement for the holder class.
    private static final LazyConstant<Config> CONFIG = LazyConstant.of(Config::load);

    // A per-instance value that is only sometimes needed: the replacement for a volatile field plus double-checked locking.
    private final LazyConstant<Pattern> emailPattern = LazyConstant.of(() -> {
        System.out.println("  [compile] building the e-mail pattern");
        return Pattern.compile("[^@\\s]+@[^@\\s]+\\.[a-z]{2,}");
    });

    boolean isEmail(String s) { return emailPattern.get().matcher(s).matches(); }
    static String url() { return CONFIG.get().url(); }

    public static void main(String[] args) {
        Service s = new Service();
        System.out.println("service created");
        System.out.println("url: " + url());
        System.out.println("url again: " + url());
        System.out.println("isEmail([email protected]): " + s.isEmail("[email protected]"));
        System.out.println("isEmail(nope): " + s.isEmail("nope"));
    }
}

Source: Service.java.

$ javac --enable-preview --release 27 src/Service.java && java --enable-preview Service     (27+35)
service created
  [load] reading configuration
url: https://example.test
url again: https://example.test
  [compile] building the e-mail pattern
isEmail([email protected]): true
isEmail(nope): false

Output: 02-basics.txt. Creating the service builds nothing. The configuration is loaded on the first url() call and not again, and the pattern is compiled on the first isEmail call and not again. A caution that the benchmark section returns to: the two fields do not behave the same way for the JIT compiler, and only the static final one gets the full benefit.

Going deeper on this section

When the supplier fails: a rule that changed between JDK 26 and 27

What should happen if the supplier throws? There are two reasonable designs. One says a failure is temporary: let the exception reach the caller and leave the constant unset, so the next get() tries again. The other says a constant either has a value or it does not, and a failed computation is a permanent fact: remember the failure, and never run the supplier a second time. JDK 26 does the first; JDK 27 does the second. The same source file, compiled on each, shows it.
import java.util.concurrent.atomic.AtomicInteger;

// What happens when the supplier throws, returns null or asks for its own value.
// The source compiles on JDK 26 and 27; the behaviour is different (see output/03).
public class Failures {
    // Lambda class names carry an address that changes on every run; keep the transcript stable.
    static String s(Object o) { return String.valueOf(o).replaceAll("\\$\\$Lambda/0x[0-9a-f]+@[0-9a-f]+", "\\$\\$Lambda"); }

    public static void main(String[] args) {
        AtomicInteger calls = new AtomicInteger();
        LazyConstant<String> bad = LazyConstant.of(() -> { calls.incrementAndGet(); throw new IllegalStateException("boom"); });
        for (int i = 0; i < 3; i++) {
            try { bad.get(); }
            catch (Throwable t) { System.out.println("get #" + (i + 1) + ": " + s(t) + "  | cause=" + s(t.getCause()) + "  | supplier calls=" + calls); }
        }

        try { LazyConstant.of(() -> (String) null).get(); }
        catch (Throwable t) { System.out.println("null result: " + s(t) + "  | cause=" + s(t.getCause())); }

        @SuppressWarnings("unchecked") LazyConstant<String>[] self = new LazyConstant[1];
        self[0] = LazyConstant.of(() -> self[0].get());
        try { self[0].get(); }
        catch (Throwable t) { System.out.println("recursion: " + s(t) + "  | cause=" + s(t.getCause())); }
    }
}
Source: Failures.java. It calls a failing constant three times, then tries a supplier that returns null, then one that asks for its own value.
$ javac --enable-preview --release 26 src/Failures.java && java --enable-preview Failures     (26.0.2.1+1)
get #1: java.lang.IllegalStateException: boom  | cause=null  | supplier calls=1
get #2: java.lang.IllegalStateException: boom  | cause=null  | supplier calls=2
get #3: java.lang.IllegalStateException: boom  | cause=null  | supplier calls=3
null result: java.lang.NullPointerException  | cause=null
recursion: java.lang.IllegalStateException: Recursive invocation of a LazyConstant's computing function: Failures$$Lambda  | cause=null
$ javac --enable-preview --release 27 src/Failures.java && java --enable-preview Failures     (27+35)
get #1: java.util.NoSuchElementException: Unable to access the constant because java.lang.IllegalStateException was thrown at initial computation  | cause=java.lang.IllegalStateException: boom  | supplier calls=1
get #2: java.util.NoSuchElementException: Unable to access the constant because java.lang.IllegalStateException was thrown at initial computation  | cause=null  | supplier calls=1
get #3: java.util.NoSuchElementException: Unable to access the constant because java.lang.IllegalStateException was thrown at initial computation  | cause=null  | supplier calls=1
null result: java.util.NoSuchElementException: Unable to access the constant because java.lang.NullPointerException was thrown at initial computation  | cause=java.lang.NullPointerException
recursion: java.util.NoSuchElementException: Unable to access the constant because java.lang.IllegalStateException was thrown at initial computation  | cause=java.lang.IllegalStateException: Recursive invocation of a LazyConstant's computing function: Failures$$Lambda
Output: 03-failures.txt. On 26 every get() throws the original IllegalStateException and the supplier runs again each time (calls 1, 2, 3). On 27 the first get() throws a NoSuchElementException whose cause is the original exception, and the second and third throw the same NoSuchElementException with no cause, while the supplier stays at one call. A null result and a recursive call are both treated as failures on 27 and reported the same way, with the underlying exception as the cause of the first report.
JDK 26: failure is temporaryJDK 27: failure is permanent get #1: IllegalStateExceptionsupplier calls = 1 get #2: IllegalStateExceptionsupplier calls = 2 get #3: IllegalStateExceptionsupplier calls = 3 get #1: NoSuchElementException (cause: ISE)supplier calls = 1 get #2: NoSuchElementException (no cause)supplier calls = 1 get #3: NoSuchElementException (no cause)supplier calls = 1 Same source file, same three calls, two different contracts.
The columns are lifted straight from the transcript. The left column can retry a flaky computation for free, and can also hammer a broken dependency on every call; the right column cannot retry at all, and its later failures do not even carry the original stack trace.
Do not use a LazyConstant for anything that can fail transiently. A database that is briefly unreachable, a file that is not there yet, a network call: on 27, one bad moment during the first get() poisons the constant for the life of the JVM. Use it for values whose computation is deterministic (parsing, compiling, building from constants), and catch and log the first failure where you can see it, because the later ones will not tell you what went wrong.
Going deeper: what I did not test on failures

I did not test an Error (as opposed to an exception) thrown from the supplier, an InterruptedException during a blocked get(), or what a thread waiting on a slow supplier sees when that supplier fails; the transcript covers a thrown IllegalStateException, a null return and direct recursion, and nothing else. A retry wrapper (catch the NoSuchElementException, build a new LazyConstant and swap it in) is possible in principle but needs a non-final field, which loses the optimisation described in the benchmark section; I did not build one.

Going deeper on this section

A whole array of lazy values: List, Map and Set

Often the expensive thing is not one value but a family: one entry per shard, per locale, per key. On JDK 26 the lazy factories for List and Map live on those interfaces themselves (on 25 they were on StableValue), and 27 adds one for Set. Each element is its own lazy constant, computed on first access and remembered, so a large table costs nothing until you look at the slots you need.
import java.util.List;
import java.util.Map;
import java.util.Set;

// List.ofLazy, Map.ofLazy (JDK 26 and 27) and Set.ofLazy (new in JDK 27): each element is computed on first access.
public class LazyCollections {
    public static void main(String[] args) {
        List<String> list = List.ofLazy(3, i -> { System.out.println("  computing list element " + i); return "v" + i; });
        System.out.println("list created, size " + list.size() + " (nothing computed yet)");
        System.out.println("list.get(1) = " + list.get(1));
        System.out.println("list.get(1) again = " + list.get(1));

        Map<String, Integer> map = Map.ofLazy(Set.of("a", "bb", "ccc"), k -> { System.out.println("  computing map value for " + k); return k.length(); });
        System.out.println("map created, size " + map.size() + " (nothing computed yet)");
        System.out.println("map.get(\"bb\") = " + map.get("bb"));
        System.out.println("map.get(\"bb\") again = " + map.get("bb"));

        Set<Integer> set = Set.ofLazy(Set.of(1, 2, 3), n -> { System.out.println("  testing " + n); return n > 1; });
        System.out.println("set created");
        System.out.println("set.contains(2) = " + set.contains(2));
        System.out.println("set.contains(1) = " + set.contains(1));
        System.out.println("set.contains(9) = " + set.contains(9));
        System.out.println("set.size() = " + set.size());
    }
}
Source: LazyCollections.java.
$ javac --enable-preview --release 27 src/LazyCollections.java && java --enable-preview LazyCollections     (27+35)
list created, size 3 (nothing computed yet)
  computing list element 1
list.get(1) = v1
list.get(1) again = v1
map created, size 3 (nothing computed yet)
  computing map value for bb
map.get("bb") = 2
map.get("bb") again = 2
set created
  testing 2
set.contains(2) = true
  testing 1
set.contains(1) = false
set.contains(9) = false
  testing 3
set.size() = 2
Output: 04-collections.txt. The list of three has computed nothing after creation and size(); asking for element 1 computes only element 1, and asking again does not compute it again. The map behaves the same way, keyed by the set you gave it. The set is different, and the difference is instructive: contains(2) tests only the element 2, contains(1) tests only 1, contains(9) tests nothing (9 was never a candidate), and size() has to test the one remaining candidate, 3, because a set cannot know its size without knowing which elements pass.
After List.ofLazy(3, …) 0: unset 1: unset 2: unset After list.get(1) 0: unset 1: “v1” 2: unset Only the slot you touch is ever computed, and only once.
The two rows are the first two lines of the list section of the transcript: nothing after creation, one slot after one get(1). This is the property that makes a big lookup table cheap to declare.
Set.ofLazy is 27 only. The same file compiled on JDK 26 fails with cannot find symbol: method ofLazy for the set line; List.ofLazy and Map.ofLazy are there on both. Code that mixes them will therefore not move backwards from 27 to 26.
$ javac --enable-preview --release 26 src/LazyCollections.java     (26.0.2.1+1)
src/LazyCollections.java:18: error: cannot find symbol
        Set<Integer> set = Set.ofLazy(Set.of(1, 2, 3), n -> { System.out.println("  testing " + n); return n > 1; });
                              ^
  symbol:   method ofLazy(Set<Integer>,(n)->{ Sys[...] 1; })
  location: interface Set
1 error
exit=1
Output: 04-collections.txt, the 26 compile.

Going deeper on this section

Why it can be fast: the JIT can treat the value as a constant

The reason this is an API of the JDK, and not a library you could write yourself in ten lines, is that the JVM understands it. A static final field that refers to a LazyConstant can be treated by the JIT compiler as a true constant once the value is set: the check disappears and the value is folded into the compiled code. That is the design intent as I understand it; I could not read the JEP text, so I test it below only by measuring. Whether it is kept for your code depends entirely on where the LazyConstant lives, so this section measures it. The benchmark reads an already-built value six ways: the eager static final as a reference, the holder idiom, double-checked locking, and a LazyConstant in a static final field, in a non-final static field, and in a final instance field. Each variant returns the value’s k plus a mutable field, so the compiler cannot delete the method but can fold a constant k.
package bench;

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

/**
 * Cost of READING an already-initialised lazily-built value, six ways. Every variant returns k + x, where x is an
 * ordinary mutable field, so the JIT cannot fold the whole method away, but can fold a constant k.
 */
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class AccessBench {
    record Config(int k) {}

    // 1. Eager static final: the reference point, always initialised, always foldable.
    static final Config EAGER = new Config(7);

    // 2. Holder idiom.
    static class Holder { static final Config VALUE = new Config(7); }

    // 3. Double-checked locking with a volatile field.
    static volatile Config dclField;
    static Config dcl() {
        Config local = dclField;
        if (local == null) {
            synchronized (AccessBench.class) {
                local = dclField;
                if (local == null) dclField = local = new Config(7);
            }
        }
        return local;
    }

    // 4. LazyConstant in a static final field: the case the JEP is designed for.
    static final LazyConstant<Config> LAZY_STATIC = LazyConstant.of(() -> new Config(7));

    // 5. LazyConstant in a static field that is NOT final.
    static LazyConstant<Config> lazyNonFinalStatic = LazyConstant.of(() -> new Config(7));

    // 6. LazyConstant in an ordinary final instance field.
    final LazyConstant<Config> lazyInstance = LazyConstant.of(() -> new Config(7));

    int x = 3;

    @Benchmark public int eagerStaticFinal()   { return EAGER.k() + x; }
    @Benchmark public int holderIdiom()        { return Holder.VALUE.k() + x; }
    @Benchmark public int doubleCheckedLocking() { return dcl().k() + x; }
    @Benchmark public int lazyConstantStaticFinal() { return LAZY_STATIC.get().k() + x; }
    @Benchmark public int lazyConstantNonFinalStatic() { return lazyNonFinalStatic.get().k() + x; }
    @Benchmark public int lazyConstantInstanceField() { return lazyInstance.get().k() + x; }
}
Source: AccessBench.java, run by run-jmh.sh.
$ java --enable-preview org.openjdk.jmh.Main -f 3 -wi 3 -w 1 -i 5 -r 1     (27+35, JMH 1.37)
machine: 2 CPUs, Intel(R) Xeon(R) Processor @ 2.80GHz
Benchmark                               Mode  Cnt  Score   Error  Units
AccessBench.doubleCheckedLocking        avgt   15  0.964 ± 0.028  ns/op
AccessBench.eagerStaticFinal            avgt   15  0.648 ± 0.018  ns/op
AccessBench.holderIdiom                 avgt   15  0.639 ± 0.024  ns/op
AccessBench.lazyConstantInstanceField   avgt   15  1.465 ± 0.065  ns/op
AccessBench.lazyConstantNonFinalStatic  avgt   15  1.494 ± 0.064  ns/op
AccessBench.lazyConstantStaticFinal     avgt   15  0.636 ± 0.019  ns/op
Output: 07-jmh.txt. Three forks, five measured iterations each, on a 2-CPU virtual machine. Lower is better.
eager static final0.648 holder idiom0.639 LazyConstant, static final0.636 double-checked locking0.964 LazyConstant, instance field1.465 LazyConstant, non-final static1.494 Nanoseconds per read, from the transcript above. Green: as cheap as a plain static final. Amber: double-checked locking. Red: LazyConstant outside a static final.
The chart redraws the table. Three things stand out. The LazyConstant in a static final field costs the same as the eager static final and the holder idiom (0.64 ns, indistinguishable within the error bars), so for that placement you get laziness at no read cost. Double-checked locking is about 50 percent slower on this machine (0.96 ns); the likely cause is its volatile read on every access, though I did not profile it. And a LazyConstant in an instance field or a non-final static field is slower than double-checked locking (about 1.5 ns).
Put the LazyConstant in a static final field, or expect to pay. The speed comes from the field being a constant to the JIT compiler. In an instance field or a non-final static it is an ordinary object reference, and the read cannot be folded away. Observed here, not assumed: the instance-field variant was slower than the volatile field it is meant to replace. Whether that gap matters is a question about how often you read the value; at 1.5 ns it rarely will, but it is not free. I attribute the fast case to constant folding because that is what the feature is designed for and the numbers fit; I did not read the generated assembly.
Going deeper: how the benchmark was built, and what it does not measure

The jars come from fetch.sh, which downloads JMH 1.37 and its two runtime dependencies from Maven Central and checks each against the published sha1; they are not committed. The benchmark is compiled on JDK 27 with --enable-preview --release 27 and the JMH annotation processor, and run with -jvmArgs --enable-preview. JMH printed a note that this JVM supports compiler blackholes and that they are in use; comparisons within one run on one JVM are still fair, comparisons across JVMs are not.

What it does not measure: the one-time cost of the first get() (the supplier itself dominates that), contention while several threads race to initialise, memory overhead per constant, and behaviour on other CPUs or other JDKs. The machine has two CPUs and is shared, so the absolute numbers will differ on yours; the ordering of the six variants is the finding, not the digits.

Going deeper on this section

Migrating from StableValue, and what “preview” costs your build

This feature has already changed shape twice. In JDK 25 it was StableValue (JEP 502), with a supplier factory, an orElseSet method and list and map factories on StableValue itself. It was renamed LazyConstant in 26, and 27 removed two methods and added Set.ofLazy. The transcript is the clearest way to see all three, so what follows is built from javap output (all in 05-api-across-releases.txt) rather than from memory.
$ javap java.lang.StableValue     (25.0.4.1+1-LTS)
Compiled from "StableValue.java"
public interface java.lang.StableValue<T> {
  public abstract boolean trySet(T);
  public abstract T orElse(T);
  public abstract T orElseThrow();
  public abstract boolean isSet();
  public abstract T orElseSet(java.util.function.Supplier<? extends T>);
  public abstract void setOrThrow(T);
  public abstract boolean equals(java.lang.Object);
  public abstract int hashCode();
  public static <T> java.lang.StableValue<T> of();
  public static <T> java.lang.StableValue<T> of(T);
  public static <T> java.util.function.Supplier<T> supplier(java.util.function.Supplier<? extends T>);
  public static <R> java.util.function.IntFunction<R> intFunction(int, java.util.function.IntFunction<? extends R>);
  public static <T, R> java.util.function.Function<T, R> function(java.util.Set<? extends T>, java.util.function.Function<? super T, ? extends R>);
  public static <E> java.util.List<E> list(int, java.util.function.IntFunction<? extends E>);
  public static <K, V> java.util.Map<K, V> map(java.util.Set<K>, java.util.function.Function<? super K, ? extends V>);
}
The same listing on JDK 26 (05-api-across-releases.txt):
$ javap java.lang.LazyConstant     (26.0.2.1+1)
Compiled from "LazyConstant.java"
public interface java.lang.LazyConstant<T> extends java.util.function.Supplier<T> {
  public abstract T orElse(T);
  public abstract T get();
  public abstract boolean isInitialized();
  public abstract boolean equals(java.lang.Object);
  public abstract int hashCode();
  public abstract java.lang.String toString();
  public static <T> java.lang.LazyConstant<T> of(java.util.function.Supplier<? extends T>);
}
And on JDK 27 (again 05-api-across-releases.txt):
$ javap java.lang.LazyConstant     (27+35)
Compiled from "LazyConstant.java"
public interface java.lang.LazyConstant<T> extends java.util.function.Supplier<T> {
  public abstract T get();
  public abstract boolean equals(java.lang.Object);
  public abstract int hashCode();
  public abstract java.lang.String toString();
  public static <T> java.lang.LazyConstant<T> of(java.util.function.Supplier<? extends T>);
}
Reading them: JDK 25 has fifteen public methods on StableValue (counting equals and hashCode); JDK 26 has LazyConstant with orElse, get, isInitialized and an of factory; JDK 27 keeps only get and of (plus equals, hashCode and toString). The small API is the point: there is one way to read the value.
JDK 25 StableValuesupplier(…)orElseSet(…)StableValue.list / map JDK 26 LazyConstantof, getorElse, isInitializedList.ofLazy, Map.ofLazyfailed get: retried JDK 27 LazyConstantof, get+ Set.ofLazyfailed get: permanent Each release compiled and run in the transcripts; nothing on this timeline is from memory.
The three boxes are the three javap listings above, plus the failure behaviour from the previous section, condensed. Reading left to right is the migration path: rename, then drop two methods, then accept the new failure rule. The old code does not survive the move, and the compiler says so plainly. This is the JDK 25 program, which runs on 25:
import java.util.List;
import java.util.function.Supplier;

// The JDK 25 preview API (JEP 502, Stable Values). It does not exist on 26 or 27: see broken/StillStable.java.
public class StableValue25 {
    static final Supplier<String> CONFIG = StableValue.supplier(() -> { System.out.println("  [load] configuration"); return "loaded"; });

    static final StableValue<String> LAZY = StableValue.of();
    static String lazy() { return LAZY.orElseSet(() -> { System.out.println("  [compute] orElseSet"); return "computed"; }); }

    static final List<String> LIST = StableValue.list(3, i -> { System.out.println("  [element] " + i); return "v" + i; });

    public static void main(String[] args) {
        System.out.println("CONFIG.get(): " + CONFIG.get());
        System.out.println("CONFIG.get(): " + CONFIG.get());
        System.out.println("lazy(): " + lazy());
        System.out.println("lazy(): " + lazy());
        System.out.println("LIST.get(2): " + LIST.get(2));
    }
}
$ javac --enable-preview --release 25 src/StableValue25.java && java --enable-preview StableValue25     (25.0.4.1+1-LTS)
  [load] configuration
CONFIG.get(): loaded
CONFIG.get(): loaded
  [compute] orElseSet
lazy(): computed
lazy(): computed
  [element] 2
LIST.get(2): v2
Source: StableValue25.java. And this is the same kind of code on 27, where the class is simply gone:
public class StillStable {
    // JDK 25 preview code, unchanged: StableValue was replaced by LazyConstant.
    static final StableValue<String> CONFIG = StableValue.of();
}
$ javac --enable-preview --release 27 broken/StillStable.java     (27+35)
broken/StillStable.java:3: error: cannot find symbol
    static final StableValue<String> CONFIG = StableValue.of();
                 ^
  symbol:   class StableValue
  location: class StillStable
broken/StillStable.java:3: error: cannot find symbol
    static final StableValue<String> CONFIG = StableValue.of();
                                              ^
  symbol:   variable StableValue
  location: class StillStable
2 errors
exit=1
Source: StillStable.java. The 26 transcript shows the same two errors (the rename happened in 26), and code that used the two methods 27 removed compiles on 26 and not on 27:
public class RemovedApi {
    public static void main(String[] args) {
        LazyConstant<String> c = LazyConstant.of(() -> "x");
        System.out.println(c.isInitialized());   // exists on JDK 26, removed in 27
        System.out.println(c.orElse("fallback")); // exists on JDK 26, removed in 27
    }
}
$ javac --enable-preview --release 27 broken/RemovedApi.java     (27+35)
broken/RemovedApi.java:4: error: cannot find symbol
        System.out.println(c.isInitialized());   // exists on JDK 26, removed in 27
                            ^
  symbol:   method isInitialized()
  location: variable c of type LazyConstant<String>
broken/RemovedApi.java:5: error: cannot find symbol
        System.out.println(c.orElse("fallback")); // exists on JDK 26, removed in 27
                            ^
  symbol:   method orElse(String)
  location: variable c of type LazyConstant<String>
2 errors
exit=1
Source: RemovedApi.java, output in 05-api-across-releases.txt. On 26 the same file compiles with exit=0. The practical migration for orElse and isInitialized is to stop asking: call get() and let the supplier run. The second cost of a preview API is the build itself. Without the flag the compiler refuses, and with it the class files you produce are marked so that they run only on the exact JDK that built them. The four transcripts below come from 06-preview-flags.txt:
$ javac broken/NoPreviewFlag.java     (27+35)
broken/NoPreviewFlag.java:3: error: LazyConstant is a preview API and is disabled by default.
    static final LazyConstant<String> CONFIG = LazyConstant.of(() -> "x");
                 ^
  (use --enable-preview to enable preview APIs)
broken/NoPreviewFlag.java:3: error: LazyConstant is a preview API and is disabled by default.
    static final LazyConstant<String> CONFIG = LazyConstant.of(() -> "x");
                                               ^
  (use --enable-preview to enable preview APIs)
2 errors
exit=1
The class file that --enable-preview produces (06-preview-flags.txt), and what happens when it is run:
$ javap -v Basics | grep 'major\|minor'     (27+35, compiled with --enable-preview --release 27)
  minor version: 65535
  major version: 71
$ java Basics     (27+35, without --enable-preview)
Error: LinkageError occurred while loading main class Basics
	java.lang.UnsupportedClassVersionError: Preview features are not enabled for Basics (class file version 71.65535). Try running with '--enable-preview'
$ java --enable-preview Basics     (26.0.2.1+1, class file built by 27)
Error: LinkageError occurred while loading main class Basics
	java.lang.UnsupportedClassVersionError: Basics has been compiled by a more recent version of the Java Runtime (class file version 71.65535), this version of the Java Runtime only recognizes class file versions up to 70.0
Sources: NoPreviewFlag.java and Basics.java, output in 06-preview-flags.txt. The class file carries minor version 65535, which is how the JVM knows it is preview; it will not start without --enable-preview, and a JDK 26 runtime, even with the flag, refuses a class file built by 27.
A library cannot ship code that uses a preview API. Every consumer would have to run on exactly the JDK you compiled with, with the flag on. That is fine for a demo, a benchmark or an internal application pinned to one JDK, and wrong for anything published. The same trap for pattern matching on primitive types is shown in the pattern matching article and in its 08-preview-class-files.txt.
Going deeper: the JDK 25 program, line by line

The 25 API had three ways to get a value that map onto the new one. StableValue.supplier(f) returned a Supplier that computes on first get(), which is what LazyConstant.of(f) is now. StableValue.of() plus orElseSet(f) was the manual version: an empty slot you fill on first use, which is what a field-plus-get() wrapper around a LazyConstant replaces. StableValue.list(n, f) is List.ofLazy(n, f). The StableValue25 transcript above shows each of them running on 25 with the same “computed once” behaviour. I did not migrate a larger codebase, so I have no experience of what the rename does to a real project’s build.

Going deeper on this section

Should you use it yet?

Not in anything you ship; yes in an experiment you control. The API has been renamed once, trimmed once and had its failure rule reversed once in three releases, and it is still preview, so the next release may change it again. If you need lazy initialisation today, the holder idiom for static values and double-checked locking with a volatile field for instance values are correct, well understood and, in this benchmark, no slower than the new API wherever the new API is not sitting in a static final field. When it is finalised, the case for it is real: one obvious way to write “compute once, later”, no volatile to forget, and reads that cost the same as a plain static final. Try it now in a pinned-JDK prototype, keep the supplier deterministic, and put the constant in a static final field. Everything in the failure-semantics section is a reason not to assume the current behaviour will still be the behaviour when it is final.

Further reading

No Comments yet!

Leave a Reply

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