Singleton: Double-Check Locking

Master the industry-standard way to implement thread-safe Singleton. Learn about the performance pitfalls of synchronization and the beauty of double-check locking.

April 22, 20246 min read3 / 4

In our previous discussion on Singleton in Single-Threaded Environments, we saw why languages like JavaScript are safe by default. However, for multi-threaded languages like Java, C++, and Rust, we face a serious challenge: Race Conditions.

Today, we resolve that conflict by implementing the industry-standard solution: Double-Check Locking.

The Problem: Why not just use synchronized?

When we realized that Lazy Initialization fails in a multi-threaded environment, the first solution that comes to mind is using Locks. In Java, the easiest way to do this is by adding the synchronized keyword to our getInstance() method.

Java
public static synchronized Database getInstance() { if (instance == null) { instance = new Database(); } return instance; }

The Performance Trap

While this works perfectly in terms of thread safety, it is a performance disaster.

Think about the lifecycle of your application. The "risky" period where two threads might accidentally create two objects only exists at the very beginning, the first time the object is created. Once the instance is initialized, every subsequent call to getInstance() is just a read operation.

By making the entire method synchronized, we force every single thread in our system to wait in a queue just to read a variable that hasn't changed in hours. In a high-scale system, this creates a massive bottleneck.

Attempt 1: The "Inside-Out" Lock (Incorrect)

A common attempt to fix this is to move the lock inside the if block, thinking we only need to lock when the instance is null.

Java
public static Database getInstance() { if (instance == null) { // We only lock when we think we need to create synchronized(Database.class) { instance = new Database(); } } return instance; }

Why this fails: Imagine two threads, James and Denver, both call getInstance() at the same time.

  1. Both threads see instance == null and enter the if block.
  2. James acquires the lock, creates the instance, and exits.
  3. Denver, who was waiting for the lock, now enters the synchronized block and creates a second instance.

Visualizing the Race Condition

Here is how you would write a test to "catch" this failure. If you run this code against a non-double-checked Singleton, you'll see two different memory addresses!

Java
public class SingletonTest { public static void main(String[] args) { // We simulate two concurrent requests Thread james = new Thread(() -> { Database db = Database.getInstance(); System.out.println("James's DB: " + db.hashCode()); }); Thread denver = new Thread(() -> { Database db = Database.getInstance(); System.out.println("Denver's DB: " + db.hashCode()); }); james.start(); denver.start(); } }

The Output (Fail):

Plain text
James's DB: 123456 Denver's DB: 789012 <-- Different Hashcode! Singleton Broken.

We are back to square one!

Attempt 2: Double-Check Locking (The Gold Standard)

To fix the issue above, we need to check the instance twice: once before acquiring the lock (to save performance) and once after acquiring the lock (to ensure no one else created it while we were waiting).

Java
public class Database { private static Database instance = null; private Database() { System.out.println("Initializing Database Connection Pool..."); } public static Database getInstance() { // 1st Check: No lock! High performance. if (instance == null) { // 2nd Step: Acquire lock synchronized(Database.class) { // 3rd Step: Second Check inside the lock if (instance == null) { instance = new Database(); } } } return instance; } }

Why this is the "Best of Both Worlds"

  1. Speed: Once the object is created, the first if (instance == null) check fails, and threads return immediately without ever touching a lock.
  2. Thread Safety: The second check inside the lock handles the race condition where multiple threads might have entered the first if block simultaneously.

[!IMPORTANT] In Java, you should also mark the instance variable as volatile to prevent "instruction reordering" by the CPU, which can occasionally lead to returning a partially initialized object.

Bonus: The Senior Engineer's Choice (Bill Pugh Singleton)

In a Google LLD interview, if you want to truly impress, you should mention the Initialization-on-demand holder idiom (also known as the Bill Pugh Singleton).

It uses a static inner class to achieve lazy initialization without using synchronized or volatile at all. It relies on the JVM's guarantee that a class is only loaded when it is first used.

Java
public class Database { private Database() {} // The inner class is only loaded when someone calls getInstance() private static class Holder { private static final Database INSTANCE = new Database(); } public static Database getInstance() { return Holder.INSTANCE; } }

Why it's brilliant: It is 100% thread-safe, 100% lazy, and has zero synchronization overhead. It's the most efficient way to implement a Singleton in Java without using Enums.


Real-World Validation: The Dagger Framework

This isn't just a theoretical concept. Dagger, a dependency injection framework written by Google and used in millions of Android apps, uses this exact pattern internally to manage singletons efficiently.

Even top engineering teams at companies like Zomato have used Double-Check Locking to fix hard-to-track race conditions that were causing crashes in their high-traffic mobile apps.

Can Singleton still be broken?

You might think we've built an impenetrable fortress, but there are still "secret tunnels" into our class:

  1. Reflection: Java's Reflection API allows code to "force open" private constructors and create new instances.
  2. Serialization: If your class is saved to a file and then loaded back (de-serialized), it can create a brand new object.

In the next part, we will explore how Singleton can still be broken using advanced techniques like Reflection and Serialization, and find the "ultimate" solution.

Practice what you just read.

Lab: Triggering the Race ConditionQuiz: Double-Check Locking
2 exercises