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:
- First Check (lock-free): If the instance is already initialized, return it immediately without acquiring a lock.
- Second Check (inside
synchronized): If the instance appears to benull, 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,
instanceis 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
StoreStoreorLoadLoadoperations. 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 withoutvolatileproduces immediate corrupted-state bugs andNullPointerExceptionsunder concurrent load.
Memory Barriers (Fences) Emitted by the JVM
When compiling a write to a volatile variable, the JVM inserts:
StoreStorebarrier before the write (ensures all constructor field writes complete before reference publication).StoreLoadbarrier after the write (ensures the write is committed to cache before any subsequent read). When reading avolatilevariable:LoadLoadandLoadStorebarriers 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):
- Primitive
int(32 bits): In the JVM specification, reading and writing 32-bit primitives is atomic. There is no risk of word-tearing. - Deterministic & Idempotent: If multiple threads compute the hash concurrently, they all produce the exact same integer value. Overwriting
hashwith the identical value is completely harmless. - 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. Thevolatilemodifier 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 = instancecuts 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
volatilein 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.
Related Topics
- How to implement thread-safe Singleton — Comprehensive analysis of all Singleton implementations
- What is Singleton — Fundamental principles and reflection/serialization attacks
- What are problems with Singleton — Global state, tight coupling, and testing challenges
- What pattern categories exist — Creational, structural, and behavioral patterns
- What anti-patterns do you know — Common software architecture pitfalls