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.
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.
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.
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.
- Both threads see
instance == nulland enter theifblock. - James acquires the lock, creates the instance, and exits.
- 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!
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):
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).
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"
- Speed: Once the object is created, the first
if (instance == null)check fails, and threads return immediately without ever touching a lock. - Thread Safety: The second check inside the lock handles the race condition where multiple threads might have entered the first
ifblock simultaneously.
[!IMPORTANT] In Java, you should also mark the instance variable as
volatileto 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.
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:
- Reflection: Java's Reflection API allows code to "force open" private constructors and create new instances.
- 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.
Keep reading