skip to content

Explain thread-safe lazy initialization in Java: why is double-checked locking broken without volatile, and how do you fix it?

level: seniorimportance: must knowfreq 70%

answer

  1. naive lazy = race -> two instances
  2. DCL: check, lock, check again
  3. new X() is allocate/construct/assign - reorderable
  4. volatile = happens-before, no half-built object
  5. holder idiom avoids volatile entirely

basics

~20 s

If two threads create a lazy singleton at the same time you can get two instances. Double-checked locking checks the field, locks only if it is null, then checks again. Without the volatile keyword on the field, another thread can see a half-built object, so you must mark the field volatile.

solid answer

~50 s

Naive lazy init (`if (instance == null) instance = new X();`) is a race: two threads can both see null and both construct. The fix is double-checked locking (DCL): check the field, and only if it is null acquire a lock, then check again inside the lock before creating. The subtle bug is that `instance = new X()` is not atomic - the JVM may publish the reference before the constructor's writes are visible, so another thread reading without the lock could see a non-null but partially constructed object. Marking the field `volatile` closes this: a volatile write has release semantics and a volatile read has acquire semantics, establishing a happens-before edge so a reader either sees null or a fully constructed object. So correct DCL needs `volatile`. In practice I prefer the initialization-on-demand holder idiom, which gives the same lazy + thread-safe behavior with no locking and no volatile, leaning on the JVM's guaranteed-safe class initialization.

code

java · 24 lines
java
public final class Singleton {
    // volatile is REQUIRED for correct double-checked locking
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                 // 1st check (no lock)
            synchronized (Singleton.class) {
                if (instance == null) {         // 2nd check (locked)
                    instance = new Singleton(); // publish via volatile write
                }
            }
        }
        return instance;
    }
}

// Usually preferred - lazy, thread-safe, no volatile/synchronized:
final class Better {
    private Better() {}
    private static class Holder { static final Better INSTANCE = new Better(); }
    public static Better getInstance() { return Holder.INSTANCE; }
}

go deeper

for a junior

Recognizes that two threads can create two instances if lazy init is unguarded, and that synchronized can prevent it.

for a middle

Can write double-checked locking and knows the field must be volatile, even if the deep why is fuzzy; knows the holder idiom exists.

for a senior

Explains the unsafe-publication/reordering bug precisely, why volatile fixes it via happens-before, and why the holder idiom is usually preferable to DCL.

for a principal

Ties this to the Java Memory Model's acquire/release semantics, the pre/post-Java-5 distinction, and guides when DCL is even worth it versus holder/enum or simply DI-managed single instances.

## The goal: create the instance once, lazily, safely We want to build the single instance only on first use (lazy), but never build two even if many threads call at once (thread-safe). ### Why the naive version is broken ```java if (instance == null) { // (A) instance = new Singleton(); // (B) } return instance; ``` Thread T1 runs (A), sees `null`, and is paused before (B). Thread T2 also runs (A), sees `null`, and creates an instance. T1 resumes and creates **another**. Now there are two - the singleton guarantee is gone. This is a **data race** (two threads access shared state, at least one writing, with no ordering between them). ### Fix attempt: synchronize the whole method ```java public static synchronized Singleton getInstance() { if (instance == null) instance = new Singleton(); return instance; } ``` Correct, but **every** call now acquires a lock even long after the instance exists - needless contention on a hot path. ### Double-checked locking (DCL) The idea: only lock when the instance might still be null. ```java private static Singleton instance; // (still buggy - see below) public static Singleton getInstance() { if (instance == null) { // 1st check (no lock) synchronized (Singleton.class) { if (instance == null) { // 2nd check (locked) instance = new Singleton(); } } } return instance; } ``` Once `instance` is set, the first check passes and we skip the lock - fast. So why is this still wrong? ### The unsafe-publication bug `instance = new Singleton()` is **not a single step**. Roughly it is: (a) allocate memory, (b) run the constructor to initialize the object's fields, (c) assign the reference to `instance`. The Java Memory Model permits the compiler/CPU to **reorder** (b) and (c) - the reference can become non-null **before** the constructor's field writes are visible to other threads. Now another thread runs the first check (outside the lock), sees `instance != null`, skips the synchronized block, and returns a reference to an object whose fields are still default/garbage. This is an **unsafe publication** - a reference escaped before construction finished. The bug is intermittent, CPU/JIT-dependent, and brutal to debug. ### The fix: volatile ```java private static volatile Singleton instance; ``` `volatile` does two things relevant here: - A **volatile write** (assigning `instance`) has **release** semantics: all writes the thread made before it (the constructor's field writes) are flushed and ordered before the volatile write becomes visible. - A **volatile read** (the first check) has **acquire** semantics: it sees those prior writes. Together they create a **happens-before** edge: any thread that reads a non-null `instance` is guaranteed to see a **fully constructed** object. A reader now sees either `null` or a complete object - never a half-built one. `volatile` also forbids the reorder that caused the bug. (Note: before Java 5 the memory model was too weak for even volatile DCL to work; on Java 5+ it is correct.) ### The cleaner alternative: holder idiom ```java private static class Holder { static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } ``` The JVM guarantees **class initialization runs exactly once and is thread-safe** (it holds an internal lock during init). `Holder` is not loaded until `getInstance()` first reads `Holder.INSTANCE`, so creation is lazy. No `volatile`, no `synchronized`, no DCL subtleties - just the language guarantee. For most cases this is the idiom to reach for; DCL is mainly relevant when you must lazily initialize a *field* of an existing object (not a fresh class) and want to understand the memory model. ### Summary - Naive lazy init: data race -> multiple instances. - Synchronized method: correct but slow on every call. - DCL without volatile: broken by reordering / unsafe publication. - DCL with volatile: correct on Java 5+. - Holder idiom: simplest correct lazy singleton; preferred.

  • Why does the holder idiom not need volatile or synchronized?
    Because the JVM specification guarantees a class is initialized exactly once and that initialization is thread-safe (the JVM holds an init lock). The static final INSTANCE is set during the holder class's initialization, so all threads safely see the fully constructed object without any explicit synchronization.
  • What specifically does the volatile write guarantee in DCL?
    It establishes a happens-before relationship: the constructor's writes to the new object's fields happen-before the volatile write of the reference, and a thread that reads the non-null reference (volatile read) is guaranteed to see those completed writes - so it never observes a partially constructed instance, and the publishing reorder is forbidden.

saying these in an interview costs you the question

  • Claiming DCL works without volatile - it does not; reordering can publish a partially constructed object.
  • Thinking volatile makes the operation atomic - it provides visibility/ordering, not atomicity of a compound update.
  • Saying synchronized on the whole getInstance() is wrong - it is correct, just slower than needed.
  • Asserting DCL was always safe - pre-Java-5 the memory model was too weak even with volatile.

context