DEV Community

jamilxt
jamilxt

Posted on

Java Finally Killed Double-Checked Locking. The Code Proves It.

Somewhere in the codebase you work on right now, there is almost certainly a field like this:

private volatile ExpensiveClient client;

public ExpensiveClient getClient() {
    ExpensiveClient c = client;
    if (c == null) {
        synchronized (this) {
            c = client;
            if (c == null) {
                client = c = ExpensiveClient.create();
            }
        }
    }
    return c;
}
Enter fullscreen mode Exit fullscreen mode

The double-checked locking idiom. You need the value to exist before the first call, you need it created at most once, and you need it safe under concurrent access. The pattern works, but look at what it costs you. A volatile read on every access. A subtle invariant that all access must go through this one method. And a guarantee the JVM will never fully trust: because the field is mutable, the JIT compiler has to assume its content can change at any moment, so it cannot optimize reads the way it optimizes reads of a final field.

Java has had two answers to this for twenty years, and both are compromises. Make the field final and eat the eager initialization cost. Or make it mutable and lose both thread-safety guarantees and constant-folding. There was never a third option.

As of September 15, 2026, there is. JDK 27 shipped JEP 531, Lazy Constants, its third preview. The API gives you deferred initialization with true final-field semantics, and it does it in one line.

One disclosure before the code. This is a preview API, so everything here needs --enable-preview, and no one should ship it to production today. What this piece does instead is execute every single snippet on the real JDK 27 GA build (27+35) and paste the actual output. Nothing below is hand-written prediction. Where the behavior is surprising, the article says so.

The one-line version

java.lang.LazyConstant wraps a value and takes a computing function, usually a lambda. The lambda does not run at creation time. It runs once, on the first .get(), whenever that happens:

private final LazyConstant<ExpensiveClient> CLIENT =
        LazyConstant.of(ExpensiveClient::create);

public ExpensiveClient getClient() {
    return CLIENT.get();
}
Enter fullscreen mode Exit fullscreen mode

That is the whole migration. The volatile keyword, the null check, the synchronized block, the local variable dance: all gone. Per the JEP, the lambda is evaluated at most once, even when .get() is invoked concurrently from many threads. And once initialized, the constant is unmodifiable, so the JVM can finally trust it.

To see the difference with your own eyes, here is the eager version first. A static final service that main never touches:

public class EagerDemo {

    static final long START = System.currentTimeMillis();

    static final HeavyService SERVICE = new HeavyService();

    static class HeavyService {
        HeavyService() {
            System.out.println("[init] HeavyService created at +"
                + (System.currentTimeMillis() - START) + " ms (class init)");
        }
    }

    public static void main(String[] args) throws Exception {
        System.out.println("[main] entered at +"
            + (System.currentTimeMillis() - START) + " ms");
        System.out.println("[main] main body never touches SERVICE in this run");
    }
}
Enter fullscreen mode Exit fullscreen mode

Real output from JDK 27 GA:

[init] HeavyService created at +1 ms (class init)
[main] entered at +18 ms
[main] main body never touches SERVICE in this run
Enter fullscreen mode Exit fullscreen mode

The object is constructed during class initialization, before main even starts. In an application with hundreds of statically wired components, that is your startup tax: every component builds its logger, its config, its HTTP client, whether or not anything uses them in a given run.

Now the same shape with LazyConstant:

import java.lang.LazyConstant;

public class LazyDemo {

    static final long START = System.currentTimeMillis();

    static final LazyConstant<HeavyService> SERVICE =
            LazyConstant.of(HeavyService::new);

    public static void main(String[] args) throws Exception {
        System.out.println("[main] entered at +"
            + (System.currentTimeMillis() - START) + " ms");

        Thread.sleep(500);   // prove nothing was built during class init

        System.out.println("[main] about to call SERVICE.get() at +"
            + (System.currentTimeMillis() - START) + " ms");

        HeavyService s = SERVICE.get();
        HeavyService s2 = SERVICE.get();
        System.out.println("[main] same instance? " + (s == s2));
    }
}
Enter fullscreen mode Exit fullscreen mode

Real output:

[main] entered at +19 ms
[main] about to call SERVICE.get() at +531 ms
[init] HeavyService created at +531 ms
[main] same instance? true
Enter fullscreen mode Exit fullscreen mode

The construction moved from class-load time to the exact moment of first use, half a second later. And the second .get() returned the same object. That is the "deferred immutability" the JEP talks about: final-like guarantees, mutable-like timing.

The part that matters: does it actually race?

"At most once, even under concurrency" is the load-bearing promise. If the lambda can run twice under contention, the whole API is broken, because real computing functions have side effects: they open connections, write files, allocate pools.

So here is the ugliest possible test. Sixteen threads, all released at once by a countdown latch, all calling .get() on the same uninitialized constant. The constructor sleeps 50 milliseconds to widen the race window as much as possible:

import java.lang.LazyConstant;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public class LazyRace {

    static final AtomicInteger CONSTRUCTIONS = new AtomicInteger();

    static class Expensive {
        Expensive() {
            try { Thread.sleep(50); } catch (InterruptedException e) {}
            CONSTRUCTIONS.incrementAndGet();
        }
    }

    static final LazyConstant<Expensive> SHARED = LazyConstant.of(Expensive::new);

    static final int THREADS = 16;

    public static void main(String[] args) throws Exception {
        var ready = new CountDownLatch(THREADS);
        var go = new CountDownLatch(1);
        Thread[] threads = new Thread[THREADS];

        for (int i = 0; i < THREADS; i++) {
            threads[i] = new Thread(() -> {
                ready.countDown();
                try { go.await(); } catch (InterruptedException e) { return; }
                SHARED.get();
            });
            threads[i].start();
        }

        ready.await();
        go.countDown();
        for (Thread t : threads) t.join();

        System.out.println("threads that called get(): " + THREADS);
        System.out.println("times the constructor ran:  " + CONSTRUCTIONS.get());
    }
}
Enter fullscreen mode Exit fullscreen mode

Real output, three separate runs:

threads that called get(): 16
times the constructor ran:  1
Enter fullscreen mode Exit fullscreen mode

Sixteen threads hit an uninitialized constant simultaneously while the constructor was sleeping. One construction. Every time. Per the JEP, this is not an implementation detail: the computing function is guaranteed to be evaluated exactly once even under concurrent access, and losing threads get the winner's value.

Lazy collections: the quiet upgrade hiding in this JEP

The single-value LazyConstant is only half the release. JDK 27 also lands lazy versions of the three core collections, and one of them is brand new in this preview.

  • List.ofLazy(size, intFunction) builds a fixed-size list where each element is its own lazy constant, initialized independently on first access.
  • Map.ofLazy(keySet, function) builds a map with fixed keys and on-demand values.
  • Set.ofLazy(...) is new in JDK 27. It tracks membership per element, computed on demand. Previous previews had only List and Map, and this round completed the set.

Why you would want a lazy list: pooling. The JEP's own example is a pool of request-scoped controllers, one per thread, where you do not want to build all four upfront but also do not want a factory call on the hot path. Here is a trimmed version of the executed test:

List<HeavyService> pool = List.ofLazy(4, i -> new HeavyService());

pool.get(2);   // builds ONLY slot 2
pool.get(2);   // no rebuild
pool.get(0);   // builds ONLY slot 0
Enter fullscreen mode Exit fullscreen mode

Real output, trimmed to the construction lines:

[init] pool slot 2 created at +568 ms
[init] pool slot 0 created at +586 ms
[main] pool size 4, slots touched: 2 of 4 built
Enter fullscreen mode Exit fullscreen mode

A four-slot pool, two slots used, exactly two objects constructed. And per the JEP, each element is computed at most once per index even when threads collide on the same slot.

Why the JVM cares more than your code does

Here is the part that separates this from the ConcurrentHashMap.computeIfAbsent memoizer trick you have probably used:

private final Map<Class<?>, Logger> loggers = new ConcurrentHashMap<>();

public Logger logger() {
    return loggers.computeIfAbsent(getClass(), Logger::create);
}
Enter fullscreen mode Exit fullscreen mode

Here is the same 16-thread race pointed at computeIfAbsent instead. To be fair, it also ran the mapping function exactly once. The at-most-once property was never the problem. The problem is what the JVM is allowed to assume afterward. A map entry can be updated at any time, so every read is a real map lookup and the JIT has to treat the result as changeable. A LazyConstant stored in a final field is different: once initialized, it is unmodifiable, and the JVM can apply constant folding, the same optimization it applies to final fields.

Under the hood, per the JEP, the content lives in a field annotated with the JDK-internal @Stable annotation, the same mechanism low-level JDK code uses. That is why the comparison looks like this:

  • final field: updated exactly once, in the constructor or static initializer, eligible for constant folding, no flexibility.
  • LazyConstant: updated zero or one times, in its computing function, eligible for constant folding after initialization, fully flexible timing.
  • plain mutable field: updated any number of times, anywhere, never constant-folded.

Lazy constants sit exactly in the gap between the first two rows. That gap is where the double-checked locking idiom, the initialization-on-demand holder idiom, and the computeIfAbsent memoizer all live today, and all three are workarounds for it.

The fine print, before you get excited

This is a preview, and the fine print is real:

  • You need --enable-preview at both compile time and runtime. javac --release 27 --enable-preview and java --enable-preview. This is the third preview (JEP 502 in JDK 25, JEP 526 in JDK 26, JEP 531 in JDK 27), and the API has already been renamed once, from StableValue to LazyConstant. Expect the possibility of more change before finalization.
  • null is banned. A computing function that returns null throws. This was tightened in the JDK 26 round to align with List.of and ScopedValue.
  • It must live in a final field to get the constant-folding benefit. The JEP is explicit: constant folding requires the field holding the constant to be final.
  • static final is the sweet spot. The JEP notes that core reflection can still mutate instance final fields today, which limits folding for instance-level constants until JEP 500 completes its "final means final" work. Static final fields are already protected, so application-wide components see the full benefit now.
  • Methods like isInitialized and orElse were removed in this round deliberately, because they invited patterns the designers did not want. This API wants you to declare the computing function up front and never probe the constant's state.

Who should pay attention now

A preview release is a signal, not a to-do list. But this one is worth tracking closely:

  • If you maintain a library with lazy singletons, connection pools, or per-key caches, start a branch and try replacing the hottest one with LazyConstant. You will find the API friction points before your users do.
  • If you run startup-sensitive workloads (serverless, scale-to-zero, CLIs), the eager-to-lazy migration on your static wiring is the most direct win. The JEP's stated goal is exactly this: initialize application state on demand instead of monolithically.
  • If you teach or review Java concurrency, start mentioning this in code review conversations now. When it finalizes, likely in a near-term release, double-checked locking will read like what it is: a twenty-year workaround for a missing language feature.

The JDK team delivered this across three previews with visible responsiveness: renamed the API, removed the methods that invited misuse, and completed the collection trio in this round. That trajectory suggests finalization is not far away.

I write about Java, the JVM, and the tools around them every week. Subscribe, it is free.

Have you tried Lazy Constants on JDK 25, 26, or 27? Did the constant-folding promise hold up in your benchmarks? Tell me in the comments.

Sources

All code outputs in this article are from OpenJDK 27 GA, build 27+35-2325, Linux x64.

Top comments (0)