🧩 Section 2 · Question #5

What is double-checked locking

The core objective of DCL is to eliminate synchronization overhead on the hot read path once the resource has already been initialized, while ensuring thread-safe initialization...


🟢 Junior Level

Double-Checked Locking (DCL) is a concurrency design pattern used to optimize lazy initialization of expensive resources (most commonly Singletons).

The core objective of DCL is to eliminate synchronization overhead on the hot read path once the resource has already been initialized, while ensuring thread-safe initialization when multiple threads request the resource simultaneously.


Why Naive Approaches Fail

1. Synchronized Method — Excessive Contention

// ❌ HIGH OVERHEAD: Locks on EVERY single read invocation
public static synchronized DatabaseConnection getInstance() {
    if (instance == null) {
        instance = new DatabaseConnection();
    }
    return instance;
}

In high-throughput concurrent systems with millions of invocations, locking the class monitor on every read creates a severe bottleneck (lock contention), degrading throughput even though the object was initialized long ago.

2. Naive Lazy Initialization — Race Condition

// ❌ DANGEROUS: Race condition creates multiple instances
public static DatabaseConnection getInstance() {
    if (instance == null) { // Thread A and Thread B check simultaneously
        instance = new DatabaseConnection(); // Two distinct instances created!
    }
    return instance;
}

The Solution: Double-Checked Locking

The condition is evaluated twice:

  1. First Check (lock-free): If the instance is already initialized, return it immediately without acquiring a lock.
  2. Second Check (inside synchronized): If the instance appears to be null, acquire the monitor lock and check again. This protects against racing threads that waited on the lock while another thread initialized the instance.
public class DatabaseConnection {
    // ⚠️ The 'volatile' modifier is STRICTLY MANDATORY!
    private static volatile DatabaseConnection instance;

    private DatabaseConnection() {}

    public static DatabaseConnection getInstance() {
        // 1st Check: Fast path (No locking)
        if (instance == null) {
            synchronized (DatabaseConnection.class) {
                // 2nd Check: Thread-safe guard under lock
                if (instance == null) {
                    instance = new DatabaseConnection();
                }
            }
        }
        return instance; // Lock-free read on all subsequent calls
    }
}

🟡 Middle Level

Why Exactly Two Checks Are Necessary

Consider the execution flow between two threads racing to call getInstance():

Thread 1                                 Thread 2
   │                                        │
   ├─ 1st Check: instance == null           │
   │  (Evaluates true; advances to lock)   ├─ 1st Check: instance == null
   │                                        │  (Also evaluates true!)
   ├─ Acquires synchronized lock            │
   │                                        ├─ Attempts to enter synchronized
   ├─ 2nd Check: instance == null           │  (BLOCKED: waits for monitor release)
   │  (Evaluates true; instantiates object) │
   ├─ instance = new DatabaseConnection()   │
   ├─ Exits synchronized block ─────────────┼─ Acquires released monitor lock
   │                                        ├─ 2nd Check: instance == null
   │                                        │  (FALSE! Already initialized by Thread 1)
   │                                        ├─ Exits without creating a duplicate
  • If the 1st check is omitted: The code degrades into a synchronized block, inflicting monitor contention on every call.
  • If the 2nd check is omitted: Thread 2 will acquire the monitor after Thread 1 finishes and construct a second instance, violating the Singleton contract.

Why volatile Is Crucial: Instruction Reordering

Instantiating an object in Java (instance = new DatabaseConnection()) is not atomic. At the bytecode and CPU pipeline levels, it decomposes into three distinct steps:

1. allocate_memory(DatabaseConnection) -> Allocates raw heap memory for the object
2. invokespecial <init>                 -> Executes constructor (populates instance fields)
3. instance = address                   -> Writes object memory address to the reference variable

Out-of-Order Execution (The Danger)

To optimize instruction pipelines, modern CPU hardware and the JIT compiler are permitted to reorder independent instructions. The execution order can legally become 1 -> 3 -> 2:

Thread 1 (Creator)                              Thread 2 (Reader)
   │                                               │
   ├─ 1. Allocates heap memory                     │
   ├─ 3. Writes address to 'instance'              │
   │     (Reference is now non-null, but           ├─ Calls getInstance()
   │      constructor has NOT finished!)           ├─ 1st Check: instance != null (TRUE!)
   │                                               ├─ Returns uninitialized reference!
   │                                               ├─ Thread 2 calls instance.query()
   │                                               │  ❌ NPE or corrupted object state!
   ├─ 2. Constructor finishes (Too late!)          │

The role of volatile: Declaring instance as volatile prevents the compiler and hardware from reordering step 3 before step 2, guaranteeing that memory writes performed by the constructor are flushed and visible before the reference publication occurs.


Local Variable Optimization

In high-throughput code, reading a volatile field incurs cache synchronization overhead. By reading the volatile reference into a local CPU stack variable once, we optimize the fast path:

public class OptimizedDclSingleton {
    private static volatile OptimizedDclSingleton instance;

    public static OptimizedDclSingleton getInstance() {
        // Read volatile field exactly once into local stack variable
        OptimizedDclSingleton local = instance;
        if (local == null) {
            synchronized (OptimizedDclSingleton.class) {
                local = instance;
                if (local == null) {
                    instance = local = new OptimizedDclSingleton();
                }
            }
        }
        return local; // Returned from stack register
    }
}
  • Performance impact: In 99.99% of calls after initialization, instance is read only once instead of twice. In microbenchmarks, this reduces hot-path read latency by ~25–30%.

Comparison of Singleton Approaches

Approach Lazy Loading Thread-Safe Hot-Path Lock Overhead Implementation Complexity
Synchronized Method ✅ Yes ✅ Yes 🔴 High (Lock Contention) Minimal
DCL without volatile ✅ Yes ❌ BROKEN (Data Race) 🟢 None Flawed
DCL with volatile ✅ Yes ✅ Yes 🟢 Minimal (One volatile read) Moderate
Bill Pugh (Holder Idiom) ✅ Yes ✅ Yes 🟢 None (Classloader lock) Low
Enum Singleton ❌ No (Eager) ✅ Yes 🟢 None Minimal

🔴 Senior Level

Hardware Memory Models: x86 (TSO) vs ARM64 and JSR-133

Prior to JSR-133 (Java 5) in 2004, the Java Memory Model (JMM) had significant ambiguities. While volatile guaranteed visibility, it did not enforce ordering constraints between volatile accesses and normal variable reads/writes. Consequently, DCL was fundamentally broken prior to Java 5, even with volatile.

Why DCL Without volatile “Worked” on Intel Servers

  • x86-64 (Intel / AMD): Implements a Total Store Order (TSO) memory model. Hardware does not reorder StoreStore or LoadLoad operations. The hardware prevents memory reference publication before preceding stores are committed. Thus, broken DCL code could run for years on x86 servers without throwing errors.
  • ARM64 (Apple Silicon M-series, AWS Graviton, Ampere): Implements a Weak Memory Model. Both CPU cores and memory interconnects aggressively reorder reads and writes unless explicit memory barrier instructions (dmb, isb) are inserted. On ARM64 architectures, DCL without volatile produces immediate corrupted-state bugs and NullPointerExceptions under concurrent load.

Memory Barriers (Fences) Emitted by the JVM

When compiling a write to a volatile variable, the JVM inserts:

  • StoreStore barrier before the write (ensures all constructor field writes complete before reference publication).
  • StoreLoad barrier after the write (ensures the write is committed to cache before any subsequent read). When reading a volatile variable:
  • LoadLoad and LoadStore barriers prevent subsequent reads/writes from floating above the volatile read.

VarHandle Alternative (Java 9+)

Starting with Java 9, fine-grained Acquire/Release memory semantics can be leveraged via VarHandle. This provides the necessary ordering guarantees with lower overhead than the full sequential consistency (Sequential Consistency) enforced by volatile:

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

public class VarHandleDcl {
    private static final VarHandle INSTANCE_HANDLE;
    private static VarHandleDcl instance;

    static {
        try {
            INSTANCE_HANDLE = MethodHandles.lookup()
                .findStaticVarHandle(VarHandleDcl.class, "instance", VarHandleDcl.class);
        } catch (ReflectiveOperationException e) {
            throw new ExceptionInInitializerError(e);
        }
    }

    private VarHandleDcl() {}

    public static VarHandleDcl getInstance() {
        // One-way Acquire barrier (cheaper than full Seq-Cst volatile read on ARM)
        VarHandleDcl local = (VarHandleDcl) INSTANCE_HANDLE.getAcquire();
        if (local == null) {
            synchronized (VarHandleDcl.class) {
                local = (VarHandleDcl) INSTANCE_HANDLE.getAcquire();
                if (local == null) {
                    local = new VarHandleDcl();
                    // One-way Release barrier (cheaper than StoreLoad full fence)
                    INSTANCE_HANDLE.setRelease(local);
                }
            }
        }
        return local;
    }
}

DCL Beyond Singleton: Lazy Caching

DCL is not restricted to Singletons; it is an invaluable pattern for lazily computing and caching heavy immutable payloads:

public class HeavyReportCache {
    private volatile byte[] cachedPdf;

    public byte[] getReportPdf() {
        byte[] local = cachedPdf;
        if (local == null) {
            synchronized (this) {
                local = cachedPdf;
                if (local == null) {
                    cachedPdf = local = generateExpensivePdfReport();
                }
            }
        }
        return local;
    }

    private byte[] generateExpensivePdfReport() {
        // Resource-intensive PDF compilation
        return new byte[4096];
    }
}

Exception to the Rule: Why String.hashCode() Omits volatile

In java.lang.String, the hash code is cached without volatile and without synchronization:

// OpenJDK java.lang.String source snippet
public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

Why This Is Safe (Benign Data Race):

  1. Primitive int (32 bits): In the JVM specification, reading and writing 32-bit primitives is atomic. There is no risk of word-tearing.
  2. Deterministic & Idempotent: If multiple threads compute the hash concurrently, they all produce the exact same integer value. Overwriting hash with the identical value is completely harmless.
  3. Contrast with Object References: This pattern is strictly invalid for complex objects, because a racing thread could read a reference whose nested heap fields are half-initialized.

4 Tricky Questions

1. What happens if you omit volatile in DCL? Will the code compile, and under what conditions does it fail?

Answer: The code compiles cleanly and often passes unit tests on developer workstations (especially on x86 architectures due to Total Store Order). However, under concurrent load, CPU instruction reordering ($1 \to 3 \to 2$) allows a reader thread to observe instance != null before the constructor finishes executing. The reader thread accesses an uninitialized or partially populated object, causing random NullPointerExceptions or corrupted state. On weak memory architectures like ARM64 (AWS Graviton, Apple Silicon), this failure reproduces consistently.


2. Why is the local variable assignment local = instance used in optimized DCL?

Answer: This is the Local Variable Optimization. Reading a volatile variable bypasses the CPU L1/L2 cache register pipeline and forces memory barrier checks against cache coherence protocols (MESI). In the initialized state (the hot path), assigning local = instance ensures the volatile field is read exactly once rather than twice, yielding a ~25–30% performance boost in tight loops.


3. Why was DCL fundamentally broken in Java prior to JDK 5 (JSR-133), even when using volatile?

Answer: Prior to JSR-133 in Java 5, the Java Memory Model did not give volatile acquire/release ordering semantics relative to normal (non-volatile) field writes. While volatile ensured that reads and writes of the volatile variable itself went directly to main memory, it did not prevent ordinary writes (such as constructor field assignments) from being reordered around the volatile write. Therefore, the reference could still be published before constructor initialization completed.


4. Why can String.hashCode() cache its value without volatile or locks, while a Singleton cannot?

Answer: String.hashCode() relies on a benign data race. The cached value is a 32-bit primitive int, which guarantees atomic writes (no word-tearing) according to the JLS. Furthermore, the calculation is purely deterministic and idempotent: any thread computing the hash will produce the exact same value. In contrast, a Singleton is a composite object reference on the heap; without volatile, racing threads can observe a non-null reference while the fields of the referenced object in heap memory are still zero or uninitialized.


🎯 Interview Cheat Sheet

30-Second Elevator Pitch

“Double-Checked Locking (DCL) is an optimization idiom for thread-safe lazy initialization. It minimizes lock contention by performing an initial check without locking, only acquiring a monitor lock if the instance is null, and then performing a second check inside the synchronized block. The volatile modifier is strictly required because without it, compiler and CPU instruction reordering ($1 \to 3 \to 2$) can publish the object reference before the constructor completes, exposing a partially initialized object to concurrent threads.”


Key Takeaways

  • First Check: Fast lock-free path for 99.9% of callers.
  • Second Check: Guard against race conditions when multiple threads pass the first check simultaneously.
  • volatile: Imposes memory barriers (StoreStore, StoreLoad) to prevent publication before constructor completion.
  • Local Variable: local = instance cuts volatile read instructions on the hot path.
  • Bill Pugh Singleton: Often preferred over DCL because it offloads thread safety to JVM class loading (JLS §12.4.2) without explicit synchronization or volatile variables.

Red Flags & Anti-Patterns

  • 🚩 Omitting volatile in DCL: Fundamental failure to understand the Java Memory Model and hardware memory ordering.
  • 🚩 Claiming “It works on my machine without volatile”: Ignorance of x86 TSO masking concurrency bugs that surface on ARM64 architectures.
  • 🚩 Omitting the second check inside synchronized: Leads to duplicate instance creation when threads race past the first check.
  • 🚩 Handcrafting DCL Singletons in Spring applications: Singletons should be managed by the Spring IoC container (@Component, @Service) rather than handwritten static singletons.