skip to content

Why is double-checked locking broken in Java without a volatile field? Explain the unsafe-publication / reordering failure concretely.

level: seniorimportance: must knowfreq 80%

answer

  1. new = allocate, construct, publish — reorderable
  2. Publish-before-construct ⇒ non-null but half-built
  3. No lock on fast path ⇒ no happens-before
  4. Unsafe publication of a partially constructed object
  5. volatile forbids the reorder + adds happens-before

basics

~20 s

Without volatile, the line instance = new T() can be reordered so the reference is set before the object's fields finish initializing. Another thread doing the lock-free first check can then see a non-null reference pointing at a half-built object and use it.

solid answer

~50 s

Double-checked locking checks `instance == null` without a lock, and only synchronizes if it looks null. The flaw is that `instance = new Singleton()` is not atomic: it allocates memory, runs the constructor, and publishes the reference, and the Java Memory Model permits the compiler or CPU to reorder the publish before the constructor finishes. A second thread taking the lock-free fast path can then observe a non-null reference whose object is still partially constructed — fields at default values, invariants not yet established. This is an unsafe publication: there is no happens-before relationship between the writing thread's construction and the reading thread's read, so the reader is not guaranteed to see the constructor's writes. Declaring the field `volatile` fixes it: a volatile write happens-before a subsequent volatile read of the same field, which forbids the harmful reordering and makes the fully constructed object visible to readers on the fast path.

code

java · 20 lines
java
public final class Singleton {
    // CORRECT DCL: the field MUST be volatile
    private static volatile Singleton instance;

    private Singleton() { /* expensive construction */ }

    public static Singleton getInstance() {
        Singleton result = instance;          // read volatile once
        if (result == null) {                 // fast path, no lock
            synchronized (Singleton.class) {
                result = instance;
                if (result == null) {         // re-check under lock
                    result = new Singleton();
                    instance = result;        // volatile write publishes safely
                }
            }
        }
        return result;
    }
}

go deeper

for a junior

Can state that DCL needs the field to be volatile, even if the deep reason is fuzzy.

for a middle

Explains that new isn't atomic and a reference can be published before construction finishes, so another thread sees a half-built object.

for a senior

Articulates the JMM happens-before gap on the lock-free read path and exactly how volatile's write/read semantics close it; notes the Java 5 / JSR-133 boundary.

for a principal

Reasons about reordering at the compiler/JIT/CPU level, the cost/benefit of DCL vs the holder idiom vs an enum singleton, and can teach why this is a poster child for relying on proven idioms over hand-rolled lock-free code.

## What double-checked locking (DCL) is DCL tries to get the best of both worlds: skip the lock on the common path where the singleton already exists, but lock when it might still need creating. ```java private static Singleton instance; // BROKEN: not volatile public static Singleton getInstance() { if (instance == null) { // (A) first check, NO lock synchronized (Singleton.class) { if (instance == null) { // (B) second check, with lock instance = new Singleton(); // (C) construct + publish } } } return instance; } ``` The two checks are why it's called *double-checked*: (A) avoids locking once the instance exists; (B) re-checks under the lock so only one thread actually constructs. ## Why `instance = new Singleton()` is not one step That single line is actually several machine operations: 1. **Allocate** raw memory for the object. 2. **Run the constructor** — write the object's fields, establish its invariants. 3. **Publish** — store the address of that memory into the `instance` reference. The key fact: there is no rule that says step 2 must complete before step 3. The compiler, the JIT, and the CPU are allowed to **reorder** instructions as long as a *single thread* can't tell the difference. From the writing thread's own point of view, whether the reference is published before or after the field writes makes no observable difference — so the reorder `1 → 3 → 2` (publish, then finish constructing) is legal. ## The Java Memory Model and happens-before The **Java Memory Model (JMM)** is the spec that defines when a write made by one thread is guaranteed to be *visible* to a read in another thread. Its central concept is **happens-before**: if action X happens-before action Y, then X's effects are visible to Y. Crucially, *without* a synchronizing action between two threads, **there is no happens-before edge**, and one thread is under no guarantee to see another thread's writes — or to see them in program order. In DCL, the writing thread does steps 1–3 *inside* the lock, but the reading thread on the fast path (A) reads `instance` *outside* any lock. So there is no happens-before relationship between the constructor's field writes and the reader's use of those fields. ## The concrete failure (unsafe publication) Put the reorder and the missing happens-before together: - Thread W enters the synchronized block, allocates, and (due to reordering) **publishes the reference before the constructor finishes**. `instance` is now non-null but the object's fields are still at default values (e.g. `0`, `null`, `false`). - Thread R runs check (A) without a lock, sees `instance != null`, skips the whole synchronized block, and returns the reference. - Thread R now uses a **partially constructed object** — reading fields that haven't been initialized yet. This is called **unsafe publication**: making an object reachable by another thread without the proper memory-visibility guarantees, so the other thread can observe it in a broken, incompletely-initialized state. The bug is rare, hardware- and JIT-dependent, and nearly impossible to reproduce reliably — which makes it especially dangerous. ## Why `volatile` fixes it Declaring `private static volatile Singleton instance;` does two things under the JMM: 1. A **volatile write** (publishing the reference in step 3) is guaranteed to happen-*after* everything that preceded it in program order, including the constructor's field writes — the harmful reorder is forbidden, and the write is flushed so other threads can see it. 2. A **volatile read** (check A) establishes a happens-before edge with that volatile write: if the reader sees the non-null reference, it is *guaranteed* to also see all the writes that happened before the volatile write — i.e. the fully constructed object. So with `volatile`, a reader that observes a non-null `instance` is guaranteed to observe a completely built object. Note this only became reliable with **Java 5** (JSR-133), which strengthened `volatile`'s semantics; before Java 5 even the volatile version of DCL was not guaranteed correct. ## Takeaways - DCL without `volatile` is broken: reordering + missing happens-before lets a reader on the lock-free path see a non-null but partially-constructed object. - The single fix is to make the field `volatile`. - Many teams prefer to sidestep the whole subtlety with the **initialization-on-demand holder idiom**, which leans on the JVM's class-initialization guarantee instead of hand-written memory barriers.

  • Does the bug create two instances?
    No. Exactly one instance is created (the second check under the lock guarantees that). The bug is visibility: a reader on the lock-free path can see that single instance's reference as non-null while its constructor hasn't finished, so it observes default/garbage field values.
  • Why does reading `instance` into a local variable `result` help?
    It avoids re-reading the volatile field multiple times — a minor performance optimization (one volatile read on the hot path instead of two). It does not affect correctness; the local just caches the single volatile read.
  • Was DCL ever fixable before Java 5?
    Not reliably. Pre-JSR-133, volatile did not establish the necessary happens-before for the referenced object's contents, so even the volatile version was technically broken. Java 5's revised memory model is what made volatile DCL correct.

saying these in an interview costs you the question

  • Claiming DCL is fine without volatile because of the second check inside the lock
  • Thinking the bug is that two instances get created (no — it's one non-null but partially built instance)
  • Saying synchronized inside the block already gives the reader visibility (the reader on the fast path never enters the block)
  • Forgetting that volatile DCL only became reliable in Java 5 / JSR-133

context