Loading learning content...
Double-Checked Locking (DCL) is a software design pattern used to reduce the overhead of acquiring a lock by first testing the locking criterion (the "lock hint") without actually acquiring the lock. Only if the check indicates that locking is required does the actual locking logic proceed.
The pattern gained notoriety in the 1990s and early 2000s as a seemingly elegant optimization—and then as a prime example of how concurrent programming can go horribly wrong when developers don't understand memory models. The story of DCL is a cautionary tale about the gap between code that looks correct and code that is correct under concurrent execution.
In this page, we'll examine the pattern's structure, understand why it appears to work, and discover the subtle but critical flaw that makes naive implementations broken.
By the end of this page, you will understand the structure of Double-Checked Locking, why it appears to solve the performance problem, and crucially, why naive implementations without proper memory barriers are fundamentally unsafe.
The Double-Checked Locking pattern involves two checks of the same condition (typically whether a singleton instance is null), with a lock acquisition between them:
First Check (Outer): Test without acquiring the lock. If the instance already exists, return it immediately—no lock overhead.
Lock Acquisition: If the first check fails (instance is null), acquire the lock to ensure exclusive access.
Second Check (Inner): Test again inside the synchronized block. Another thread may have created the instance while we were waiting for the lock.
Initialize: If still null after the second check, create the instance.
The name comes from the two checks of the same condition: once before locking, once after.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
/** * Double-Checked Locking - Naive Implementation * * WARNING: This implementation is BROKEN in most languages/runtimes! * Shown here only to illustrate the pattern structure. */class Singleton { private static instance: Singleton | null = null; private static mutex = new Mutex(); private constructor() { // Expensive initialization this.initializeResources(); } private initializeResources(): void { // Setup database connections, load config, etc. } /** * The Double-Checked Locking Pattern * * Structure: * 1. First check (unsynchronized) - fast path for common case * 2. Synchronize (only if first check fails) * 3. Second check (synchronized) - guard against race * 4. Initialize (only if both checks fail) */ public static async getInstance(): Promise<Singleton> { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // FIRST CHECK: No lock - fast path // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ if (Singleton.instance === null) { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // LOCK: Only acquire if instance might be null // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ await Singleton.mutex.acquire(); try { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // SECOND CHECK: Verify still null after lock // (Another thread may have initialized while we waited) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ if (Singleton.instance === null) { // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // INITIALIZE: Create the instance // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ Singleton.instance = new Singleton(); } } finally { Singleton.mutex.release(); } } // Return the (supposedly) initialized instance return Singleton.instance; }}The code above is DANGEROUS and BROKEN. Despite looking correct, it can return an incompletely initialized object to threads performing the first check. We will explain exactly why in the following sections.
The Double-Checked Locking pattern is seductive because it addresses both concerns elegantly:
Correctness Argument (Seemingly Valid)
"The second check is inside the synchronized block. Even if two threads pass the first check simultaneously, only one can be inside the synchronized block at a time. Whichever thread enters first will create the instance and set the variable. When the second thread enters, it will see the instance is no longer null and skip creation. Perfect!"
Performance Argument (Valid)
"After the first successful initialization, the instance variable is non-null. All subsequent calls check instance == null, find it false, and return immediately without ever acquiring the lock. We get lock-free reads for the common case."
Let's trace through the execution timeline:
| Time | Thread A | Thread B | instance Value |
|---|---|---|---|
| T1 | First check: null → true | (waiting) | null |
| T2 | Acquires lock | (waiting) | null |
| T3 | Second check: null → true | First check: null → true | null |
| T4 | Creates instance | Blocks on lock | null → Object |
| T5 | Releases lock | Acquires lock | Object |
| T6 | (done) | Second check: null → false | Object |
| T7 | (done) | Returns existing instance | Object |
The timeline above looks perfect. Thread A creates the instance, Thread B waits, and eventually both use the same instance. What could possibly go wrong?
The hidden assumption: This analysis assumes that when Thread B reads instance after acquiring the lock, it sees the value that Thread A wrote. It also assumes that the instance Thread B receives is fully initialized.
Both assumptions are incorrect without additional synchronization mechanisms.
The naive DCL implementation is broken due to two fundamental issues that arise from how modern CPUs and compilers optimize code:
Issue 1: Instruction Reordering
Compilers and CPUs routinely reorder instructions for performance, as long as the reordering doesn't change the behavior of single-threaded code. Consider what happens during object construction:
12345678910111213141516171819
// What you write:instance = new Singleton(); // What this expands to at the low level:// Step 1: Allocate memory for Singleton object// Step 2: Initialize fields to default values// Step 3: Run constructor (assign fields to proper values)// Step 4: Assign the memory address to 'instance' // What the compiler/CPU might REORDER to:// Step 1: Allocate memory for Singleton object// Step 2: Assign the memory address to 'instance' ← MOVED UP!// Step 3: Initialize fields to default values// Step 4: Run constructor (assign fields to proper values) // The reordering is valid for single-threaded execution:// The final result is the same—'instance' points to a fully// constructed object. But between steps 2 and 4, 'instance'// points to a PARTIALLY CONSTRUCTED object!This reordering is called publication without proper synchronization. The reference to the object is "published" (made visible to other threads) before the object is fully constructed.
Issue 2: Memory Visibility
In modern multiprocessor systems, each CPU core has its own cache. When Thread A writes to instance, that write may sit in Thread A's CPU cache and not be visible to Thread B running on a different core.
The Java Memory Model (JMM), C++ memory model, and similar specifications explicitly allow this behavior. Without explicit memory barriers or synchronization, there is no guarantee that writes made by one thread are visible to another thread in any particular order—or at all.
| Time | Thread A (Core 1) | Thread B (Core 2) | Core 1 Cache | Core 2 Cache | Main Memory |
|---|---|---|---|---|---|
| T1 | Passes first check (null) | — | instance=null | instance=null | instance=null |
| T2 | Acquires lock | — | instance=null | instance=null | instance=null |
| T3 | Allocates Singleton | — | instance=null | instance=null | instance=null |
| T4 | Assigns address to instance | — | instance=0xABC (partial!) | instance=null | instance=null (stale) |
| T5 | Starts initializing fields | Reads instance | instance=0xABC | instance=null or 0xABC? | instance=0xABC (written) |
| T6 | (still initializing) | Sees non-null! Returns it | instance=0xABC | instance=0xABC | instance=0xABC |
| T7 | (still initializing) | Uses PARTIALLY INITIALIZED object | — | Crash or corruption | — |
| T8 | Finishes initialization | (too late, damage done) | instance=0xABC (complete) | — | instance=0xABC (complete) |
Thread B can observe a non-null instance that is NOT fully constructed. Using this partially initialized object can cause NullPointerExceptions (accessing fields that aren't set yet), incorrect computation (using default zero/null values instead of proper initialization), or security vulnerabilities (bypassing security checks in constructors).
Why The Lock Doesn't Help
You might think: "But Thread A holds the lock during construction, and the lock provides synchronization guarantees!"
The lock does provide guarantees—but only for threads that also acquire the lock. Thread B, performing the first (unsynchronized) check, doesn't acquire any lock. It's reading instance outside of any synchronization. The lock that Thread A holds provides no visibility guarantees to Thread B.
This is the fundamental insight: DCL requires the first check to be unsynchronized for performance, but that unsynchronized read is exactly what allows Thread B to see the partially constructed object.
To fully understand why DCL breaks, let's look at what happens inside the new Singleton() expression and how instruction reordering affects it:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
/** * Let's examine what happens during object construction * and how reordering creates the bug */ class ConfigService { private configData: Map<string, string>; private initialized: boolean; private connectionPool: ConnectionPool; constructor() { // Step A: Initialize configData this.configData = new Map(); this.configData.set("apiUrl", "https://api.example.com"); // Step B: Set up connection pool this.connectionPool = new ConnectionPool(10); // Step C: Mark as initialized this.initialized = true; } getApiUrl(): string | undefined { if (!this.initialized) { throw new Error("Not initialized!"); } return this.configData.get("apiUrl"); }} // The expression "instance = new ConfigService()" involves://// INTENDED ORDER (what we expect):// ┌────────────────────────────────────────────────────────┐// │ 1. Allocate memory (configData=null, initialized=false,│// │ connectionPool=null) │// │ 2. Execute constructor body: │// │ 2a. configData = new Map() │// │ 2b. configData.set("apiUrl", ...) │// │ 2c. connectionPool = new ConnectionPool(10) │// │ 2d. initialized = true │// │ 3. Assign completed object address to 'instance' │// └────────────────────────────────────────────────────────┘//// POSSIBLE REORDERED EXECUTION (the bug):// ┌────────────────────────────────────────────────────────┐// │ 1. Allocate memory (configData=null, initialized=false,│// │ connectionPool=null) │// │ 2. Assign memory address to 'instance' ← EARLY! │// │ (instance now points to UNINITIALIZED object) │// │ 3. Execute constructor body: │// │ 3a. configData = new Map() │// │ 3b. configData.set("apiUrl", ...) │// │ 3c. connectionPool = new ConnectionPool(10) │// │ 3d. initialized = true │// └────────────────────────────────────────────────────────┘//// Between steps 2 and 3d, any thread checking "instance != null"// sees TRUE, but the object fields are still null/false!The Concrete Failure Scenario
Imagine Thread B performs the first null check right after Thread A executes step 2 (the reordered assignment) but before step 3:
12345678910111213141516171819202122232425262728293031
// Thread A is in the middle of construction...// (has allocated memory and assigned to 'instance', but// constructor body hasn't executed yet) // Thread B comes along:function threadB_execution() { const config = ConfigService.getInstance(); // ^ First check: instance != null → TRUE (sees the address) // ^ Returns immediately without acquiring lock // Thread B now has a reference to a partially constructed object // where: // - configData = null // - connectionPool = null // - initialized = false const url = config.getApiUrl(); // ^ Throws "Not initialized!" or, worse... // If the check wasn't there: const url2 = config.configData.get("apiUrl"); // ^ NullPointerException: configData is null! // If configData was set but connectionPool wasn't: const pool = config.connectionPool; pool.getConnection(); // NullPointerException!} // Key insight: The object EXISTS (has a memory address) but its// FIELDS aren't initialized. The reference is valid; the object// state is not.This bug typically manifests only under heavy load, on multi-core systems, and is often non-deterministic. You might run the code 1000 times without seeing the bug, then have it appear in production under high concurrency. The bug is also architecture-dependent—x86 CPUs rarely reorder in ways that trigger it, while ARM CPUs do so more frequently.
The DCL pattern became notorious in the early 2000s when researchers demonstrated that it was broken in Java and C++. Here are some landmark examples:
volatile keyword, but years of broken code were already deployed.123456789101112131415161718192021222324252627282930313233
/** * This code appeared in countless Java books and articles * between 1995-2004. It is BROKEN. * * From "Design Patterns: Elements of Reusable Object-Oriented * Software" (1994) - adapted to Java */public class Singleton { private static Singleton instance = null; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); // BROKEN! } } } return instance; }} // Why it's broken:// 1. 'instance' is not volatile// 2. The JVM is free to reorder the constructor operations// 3. Thread B can see a non-null 'instance' that points// to an incompletely constructed object//// This bug affected production systems at:// - Sun Microsystems (ironic, given they created Java)// - IBM WebSphere// - Numerous open-source projects// - Enterprise applications worldwideThe DCL saga taught the programming community that concurrent code cannot be reasoned about using intuition alone. You must understand the memory model of your language/platform to write correct concurrent code. Even experts got this wrong for years.
One of the most dangerous aspects of the DCL bug is that it rarely manifests during development and testing. Understanding why helps appreciate why concurrency bugs require special attention:
Never rely on testing alone to find concurrency bugs. Use static analysis tools, follow proven patterns, and when in doubt, use the simpler (fully synchronized) approach. The performance difference rarely matters, but the correctness difference always does.
We've now fully dissected the Double-Checked Locking pattern and understood why naive implementations fail:
What's Next
The next page dives deep into memory models—the formal specifications that define what reorderings are allowed and what guarantees synchronization provides. Understanding memory models is essential for implementing DCL correctly.
You now understand the structure of Double-Checked Locking and, critically, why the naive implementation is fundamentally broken. The pattern requires very specific memory model guarantees that we'll explore in the next page.