Loading learning content...
In concurrent programming, few design challenges are as deceptively simple yet profoundly treacherous as lazy initialization. The concept appears straightforward: delay the creation of an expensive resource until first use, then cache it for subsequent requests. In single-threaded code, this is trivial. In multi-threaded code, it becomes a minefield of subtle bugs that can elude detection for months or years.
The Double-Checked Locking pattern emerged as a seemingly elegant solution to this problem—a way to achieve thread-safe lazy initialization with minimal synchronization overhead. Yet this pattern became infamous for its broken implementations, leading luminaries like Brian Goetz to label certain versions as "fundamentally and irreparably broken" in some programming languages.
Understanding why lazy initialization is problematic in concurrent contexts—and why early solutions failed—is essential before we can appreciate the safe implementation strategies that exist today.
By the end of this page, you will understand why lazy initialization creates race conditions in concurrent environments, why naive locking solutions are insufficient, and what performance concerns drive engineers toward optimized patterns like Double-Checked Locking.
Lazy initialization is a design pattern where the creation of an object is deferred until it is first needed. This contrasts with eager initialization, where the object is created immediately at program startup or class loading.
The motivation for lazy initialization typically falls into one of these categories:
1. Performance Optimization Some resources are expensive to create—establishing database connections, loading configuration files from disk, parsing large XML documents, or initializing complex subsystems. If these resources might not be used in every program execution path, creating them eagerly wastes time and memory.
2. Resource Conservation Objects that consume significant memory (large caches, image buffers, machine learning models) should only be instantiated when genuinely needed. A program that initializes ten optional modules eagerly consumes resources for all ten, even if only two are used.
3. Dependency Resolution Some objects depend on runtime information unavailable at startup. A configuration-dependent service might need to wait until configuration is loaded before initialization.
4. Avoiding Circular Dependencies Lazy initialization can break circular dependency chains by deferring creation until the dependency graph is fully established.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
/** * Single-Threaded Lazy Initialization * Works perfectly in single-threaded contexts */class ExpensiveResource { private static instance: ExpensiveResource | null = null; // Private constructor prevents direct instantiation private constructor() { // Simulate expensive initialization console.log("Creating expensive resource..."); this.loadConfigFromDisk(); this.establishDatabaseConnections(); this.warmUpCaches(); } private loadConfigFromDisk(): void { // Simulate 500ms disk I/O } private establishDatabaseConnections(): void { // Simulate 1000ms connection setup } private warmUpCaches(): void { // Simulate 2000ms cache population } /** * Lazy getter - creates instance on first call * In single-threaded code, this is completely safe */ public static getInstance(): ExpensiveResource { if (ExpensiveResource.instance === null) { // First call: create the instance ExpensiveResource.instance = new ExpensiveResource(); } // Subsequent calls: return cached instance return ExpensiveResource.instance; } public doWork(): void { console.log("Performing work with expensive resource"); }} // Usageconst resource = ExpensiveResource.getInstance(); // First call: initializesconst sameResource = ExpensiveResource.getInstance(); // Returns cachedconsole.log(resource === sameResource); // trueIn single-threaded code, the lazy initialization pattern shown above is completely correct. The null check and assignment happen sequentially with no possibility of interference. The complexity emerges only when multiple threads may execute this code simultaneously.
When multiple threads execute the lazy initialization code concurrently, a race condition emerges. The "check-then-act" sequence that works perfectly in single-threaded code becomes fundamentally broken.
Consider what happens when two threads—let's call them Thread A and Thread B—simultaneously call getInstance() when the instance hasn't been created yet:
| Time | Thread A | Thread B | instance State |
|---|---|---|---|
| T1 | Checks: instance == null? → true | (not yet executed) | null |
| T2 | (context switch) | Checks: instance == null? → true | null |
| T3 | (waiting) | Creates new ExpensiveResource() | null → initializing |
| T4 | (waiting) | Assigns to instance | Instance #1 |
| T5 | Creates new ExpensiveResource() | (finished) | Instance #1 → initializing |
| T6 | Assigns to instance | (finished) | Instance #2 (overwrites #1!) |
| T7 | Returns Instance #2 | Returns Instance #1 | Instance #2 |
The consequences of this race condition are severe:
1. Multiple Instance Creation The resource is created twice (or more), wasting the expensive initialization cost we sought to avoid. If initialization takes 3 seconds, we're now spending 6 seconds (or more) on parallel, redundant creations.
2. Singleton Guarantee Violated If we intended to create a singleton (exactly one instance), we've failed. Different threads may hold references to different instances, leading to subtle inconsistencies.
3. State Corruption If the "singleton" maintains mutable state, different threads operating on different instances will have divergent state. Changes made through Instance #1 won't be visible to code using Instance #2.
4. Resource Leaks If the resource allocates external resources (database connections, file handles, network sockets), each redundant instance allocates its own, leading to resource exhaustion.
5. Initialization Side Effects If initialization has side effects (registering with a global service, writing to a log, incrementing a counter), these execute multiple times incorrectly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
/** * DANGEROUS: Race condition in lazy initialization * This code is broken when called from multiple threads */class DatabaseConnectionPool { private static instance: DatabaseConnectionPool | null = null; private static creationCount = 0; private connections: Connection[] = []; private instanceId: number; private constructor() { DatabaseConnectionPool.creationCount++; this.instanceId = DatabaseConnectionPool.creationCount; console.log(`Creating connection pool #${this.instanceId}...`); // Expensive: create 10 database connections for (let i = 0; i < 10; i++) { this.connections.push(this.createConnection()); } console.log(`Pool #${this.instanceId} ready with ${this.connections.length} connections`); } private createConnection(): Connection { // Simulate connection establishment (1 second each) return new Connection(); } /** * BROKEN: Race condition between check and assignment * * Problem: The gap between "if (instance === null)" and * "instance = new DatabaseConnectionPool()" allows multiple * threads to pass the check simultaneously */ public static getInstance(): DatabaseConnectionPool { // DANGER ZONE: This check-then-act is NOT atomic if (DatabaseConnectionPool.instance === null) { // Multiple threads can reach here simultaneously! DatabaseConnectionPool.instance = new DatabaseConnectionPool(); } return DatabaseConnectionPool.instance; } public getConnection(): Connection { return this.connections[Math.floor(Math.random() * this.connections.length)]; } public getInstanceId(): number { return this.instanceId; }} // Simulated concurrent access (pseudocode - actual threading varies by runtime)async function demonstrateRaceCondition() { const promises = []; // Launch 10 "threads" simultaneously for (let i = 0; i < 10; i++) { promises.push( (async () => { const pool = DatabaseConnectionPool.getInstance(); console.log(`Thread ${i} got pool #${pool.getInstanceId()}`); return pool; })() ); } const pools = await Promise.all(promises); // Check: are all pools the same instance? const uniquePools = new Set(pools.map(p => p.getInstanceId())); console.log(`Unique pools created: ${uniquePools.size}`); // EXPECTED (if safe): 1 // ACTUAL (with race condition): could be 2, 3, or more!}The pattern of checking a condition and then acting on it is fundamentally unsafe in concurrent code unless the entire sequence is atomic. Between the check and the act, another thread can change the condition, invalidating the decision.
A natural question emerges: if lazy initialization creates race conditions, why not simply initialize eagerly? Create the resource at class loading time, and there's no race condition at all.
This is often the correct answer. Eager initialization is simpler, safer, and should be preferred when possible. However, there are legitimate cases where it's impractical:
Eager initialization should be your default choice. Only move to lazy initialization when you have measured evidence that eager initialization creates a problem. Premature optimization—especially in concurrency—creates bugs.
The obvious solution to the race condition is synchronization: ensure only one thread can execute the initialization code at a time. This is correct, but it introduces a significant performance problem.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
/** * Thread-safe but inefficient lazy initialization * Correct, but pays synchronization cost on every access */class ConfigurationService { private static instance: ConfigurationService | null = null; private static mutex = new Mutex(); // Hypothetical mutex private config: Map<string, string>; private constructor() { console.log("Loading configuration from remote service..."); // Simulate expensive network call this.config = this.fetchConfigFromRemote(); } private fetchConfigFromRemote(): Map<string, string> { // Simulate 2-second network delay return new Map([ ["database.url", "postgres://localhost:5432/app"], ["cache.size", "1000"], ["log.level", "DEBUG"], ]); } /** * CORRECT but SLOW: Synchronize the entire method * * Every call to getInstance() must acquire the lock, even when * the instance has already been created. This is wasteful. */ public static async getInstance(): Promise<ConfigurationService> { // PERFORMANCE PROBLEM: Lock acquired on EVERY call await ConfigurationService.mutex.acquire(); try { if (ConfigurationService.instance === null) { ConfigurationService.instance = new ConfigurationService(); } return ConfigurationService.instance; } finally { ConfigurationService.mutex.release(); } } public get(key: string): string | undefined { return this.config.get(key); }} /** * Performance Analysis: * * First call: * - Acquire lock: ~1μs * - Check null: ~1ns * - Create instance: ~2000ms * - Release lock: ~1μs * - Total: ~2000ms (acceptable) * * Subsequent calls (the problem): * - Acquire lock: ~1μs * - Check null: ~1ns * - Return instance: ~1ns * - Release lock: ~1μs * - Total: ~2μs (should be ~1ns!) * * In a hot path called 1 million times/second: * - With lock: 1,000,000 × 2μs = 2 seconds of lock contention * - Without lock: 1,000,000 × 1ns = 1 millisecond * * That's a 2000x slowdown! */The performance problem is clear: we need synchronization only during the first call (when the instance is created). Every subsequent call—potentially millions of them—pays the synchronization overhead even though the instance already exists and is immutable.
This is the fundamental tension that led to the invention of Double-Checked Locking:
The Goal: Synchronize only when necessary (during creation), then bypass synchronization entirely for subsequent reads.
The Challenge: Doing this correctly requires understanding subtle memory model semantics that aren't obvious from the code structure alone.
To understand why engineers sought an optimization like Double-Checked Locking, we need to quantify the performance impact of different approaches.
| Approach | First Access | Subsequent Access | Contention Under Load | Correctness |
|---|---|---|---|---|
| Eager Initialization | Paid at startup | ~1 nanosecond | None | ✓ Safe |
| Unsynchronized Lazy | ~N milliseconds | ~1 nanosecond | Race condition! | ✗ Broken |
| Synchronized Lazy | ~N milliseconds + lock | ~1-10 microseconds | High (serialized) | ✓ Safe |
| Double-Checked Locking | ~N milliseconds + lock | ~1 nanosecond | Low (lock-free reads) | Depends on impl |
Real-World Impact Numbers
Consider a service that accesses a configuration singleton 100,000 times per second (not unrealistic for a busy application server):
| Approach | Time per Access | Total Time/Second | CPU Cycles Wasted |
|---|---|---|---|
| Unsynchronized read | 1 ns | 100 μs | None (but broken!) |
| Synchronized read | 2 μs | 200 ms | 600 million |
| DCL (properly implemented) | ~2 ns | 200 μs | ~200 thousand |
The synchronized approach wastes 20% of a CPU core just on lock acquisition. In high-throughput systems, this is the difference between needing 5 servers and needing 6.
Lock Contention Amplifies the Problem
The microseconds-per-access figure assumes uncontended lock acquisition. Under contention (multiple threads trying to acquire the lock simultaneously), performance degrades dramatically:
On a 16-core server handling concurrent requests, naive synchronization can become a devastating bottleneck.
These numbers might tempt you to always use Double-Checked Locking. Resist this temptation! Most applications don't access singletons 100,000 times per second. For most use cases, the simple synchronized approach is perfectly adequate and far safer.
We've established the problem that Double-Checked Locking attempts to solve:
What's Next
The next page introduces the Double-Checked Locking pattern itself — the structure that attempts to synchronize only during initialization while allowing lock-free reads afterward. We'll see both why the pattern is attractive and why naive implementations are dangerously broken.
You now understand the problem space that motivates Double-Checked Locking: the tension between thread-safe lazy initialization and efficient subsequent access. Next, we'll explore the DCL pattern structure and why its correct implementation requires deep understanding of memory models.