Double-Checked Locking Pattern
Solution: Double-Checked Locking Pattern
Read aloud is unavailable in this browser.
The Double-Checked Locking Pattern
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.
Pattern Structure
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.
Why It Appears to Work
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.
- •Initialization (rare path): Acquires lock, performs checks, creates instance. O(1) cost, happens once.
- •Subsequent access (common path): Reads instance, checks null (false), returns immediately. No lock acquisition, no synchronization overhead.
- •Contention during initialization: Threads racing during first access block on the lock, but only one creates the instance.
- •Post-initialization performance: Equivalent to unsynchronized access—optimal for read-heavy workloads.
The Broken Pattern: What Goes Wrong
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.
Visualizing the Problem
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.
Famous Broken DCL Examples
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:
- •1995-2002: Widespread Broken Implementations — DCL was taught as a valid optimization technique. Books, articles, and even official documentation presented it without proper warnings.
- •2001: "The 'Double-Checked Locking is Broken' Declaration" — Brian Goetz and others published definitive proof that naive DCL couldn't work in Java, detailing the memory model issues.
- •2000: Scott Meyers and Andrei Alexandrescu — Published "C++ and the Perils of Double-Checked Locking" showing the pattern was equally broken in C++.
- •2004: JSR-133 (Java Memory Model revision) — Java 5.0 fixed the memory model and made DCL possible with the
volatilekeyword, but years of broken code were already deployed. - •2011: C++11 Memory Model — C++11 finally provided the tools (std::atomic, memory_order) to implement DCL correctly in C++.
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.
Why Normal Testing Doesn't Find This
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:
- •Single-Core Developer Machines — Most developer machines run single-core virtualized environments. Without multiple physical cores, the cache coherency issues don't manifest.
- •Low Contention in Tests — Unit tests rarely create meaningful thread contention. The window for the race condition (microseconds) isn't hit when tests run sequentially.
- •x86 Strong Memory Model — Intel/AMD x86 CPUs have a relatively strong memory model that rarely reorders stores. The bug is more likely on ARM, POWER, or SPARC architectures.
- •JIT Optimization Timing — The JVM's Just-In-Time compiler may not aggressively optimize code until it's "hot." Tests don't run long enough to trigger aggressive optimization.
- •Debug Builds — Development often uses debug builds with optimization disabled. The reordering that causes the bug occurs during optimized compilation.
- •Heisenbugs — Adding debugging (print statements, breakpoints) often introduces synchronization that masks the bug. Observing the bug changes the timing enough to hide it.
- •Unit tests with mock objects
- •Integration tests on single-core CI runners
- •Load tests under moderate concurrency
- •Tests on x86 architecture only
- •Manual testing by developers
- •Static analysis tools (FindBugs, SpotBugs)
- •Race condition detectors (ThreadSanitizer)
- •Testing on ARM/POWER architectures
- •Production traffic from many concurrent users
- •Code review by concurrency experts
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.
Summary: The DCL Pattern and Its Flaw
We've now fully dissected the Double-Checked Locking pattern and understood why naive implementations fail:
- •DCL uses two null checks — one unsynchronized (for performance) and one synchronized (for correctness under contention).
- •The pattern's goal is lock-free reads — after initialization, subsequent accesses bypass the lock entirely.
- •Naive implementations are broken because compilers/CPUs may reorder the constructor and reference assignment.
- •The first (unsynchronized) check is the problem — it can observe a non-null reference to a partially constructed object.
- •Testing rarely catches this bug — it manifests under high load, on multi-core systems, often only in production.
- •This was a widespread problem — broken DCL appeared in books, documentation, and production code for over a decade.
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.