A singleton ensures that only one instance of a class exists throughout the entire application lifetime. On the surface, that sounds simple to implement. In a multithreaded environment — which every Android app is — getting it wrong is surprisingly easy, and the consequences (duplicate database connections, corrupted shared state) can be hard to reproduce and debug.
This post walks through exactly where naive implementations break and why double-checked locking is the correct fix.
What Singleton Means
A singleton class produces exactly one object. Every caller that asks for an instance gets back the same object, no matter how many times or from how many threads it is requested.
In Android, classes like DatabaseHelper, shared preference managers, and network clients are natural singletons — creating multiple instances wastes resources and can cause data consistency issues.
Attempt 1 — The Broken Naive Version
public class DatabaseHelper {
private static DatabaseHelper instance = null;
private DatabaseHelper() {}
public static DatabaseHelper getInstance() {
if (instance == null) {
instance = new DatabaseHelper(); // ❌ race condition here
}
return instance;
}
}
This works perfectly in a single-threaded environment. In a multithreaded one, it breaks.
The race condition:
Two threads — call them Thread A and Thread B — both call getInstance() for the very first time at the same moment. Since instance is still null, both threads pass the if (instance == null) check simultaneously. Both then proceed to execute new DatabaseHelper(). Two objects are created. The singleton contract is broken.
This is not a hypothetical edge case. On a modern multi-core device, two threads entering this method at the same time is entirely normal.
Attempt 2 — Synchronized, But Still Broken
public static DatabaseHelper getInstance() {
if (instance == null) {
synchronized (DatabaseHelper.class) {
instance = new DatabaseHelper(); // still broken
}
}
return instance;
}
Adding synchronized makes the block act like a gate — only one thread can be inside it at a time. But the problem is not fully solved.
Here is the sequence:
- Thread A checks
instance == null→ true. Enters thesynchronizedblock. - Thread B checks
instance == null→ also true (before A has created anything). Thread B queues at the gate. - Thread A creates the instance and exits the block.
- Thread B now enters the block — and creates a second instance because there is no check inside the block.
The outer null check and the inner creation step are not atomic. Thread B slipped past the outer check before Thread A finished creating the object.
Attempt 3 — Double-Checked Locking (Correct)
public class DatabaseHelper {
private static volatile DatabaseHelper instance = null;
private DatabaseHelper() {}
public static DatabaseHelper getInstance() {
if (instance == null) { // first check
synchronized (DatabaseHelper.class) {
if (instance == null) { // second check
instance = new DatabaseHelper();
}
}
}
return instance;
}
}
Now follow the same scenario:
- Both Thread A and Thread B see
instance == nulland reachsynchronized. - The gate allows only one through — say Thread A enters first. Thread B waits.
- Thread A passes the inner
if (instance == null)check (still null), creates the object, and exits. - Thread B enters the
synchronizedblock. It hits the inner null check — instance is now set. It skips creation and exits.
Only one object is ever created. Every subsequent call skips the synchronized block entirely because the outer null check returns false, keeping performance fast.
Why Two Null Checks?
This is the part that trips people up in interviews.
| Check | Purpose |
|---|---|
Outer if (instance == null) | Performance — once the instance exists, skip synchronized on every future call. Without it, every single call would acquire a lock, which is expensive. |
Inner if (instance == null) | Correctness — guards the window where two threads both passed the outer check before any instance was created. |
Remove the outer check: correct but slow (every call locks). Remove the inner check: fast but broken (two threads can still create two objects). Both together: correct and fast.
The volatile Keyword
Notice private static volatile DatabaseHelper instance. The volatile keyword is essential here.
Without it, the JVM or CPU is allowed to reorder the instructions that make up new DatabaseHelper(). In certain JVM implementations, a partially-constructed object could be written to instance before its constructor finishes running. Thread B might then read a non-null instance that is not yet safe to use.
volatile prevents this reordering. It guarantees that the write to instance is fully visible to all threads only after the object is completely constructed.
Double-checked locking without volatile is not actually thread-safe on the Java Memory Model, even though it looks correct at first glance.
Why Dagger Removes This Burden
Writing and reasoning about double-checked locking correctly is non-trivial — two null checks, a synchronized block, and a volatile field just to create one singleton. Now imagine doing this for every shared object in a large project.
Dagger handles all of this with a single annotation:
@Provides
@Singleton
fun provideDatabase(): DatabaseHelper {
return DatabaseHelper()
}
Dagger generates thread-safe singleton code at compile time. You express the intent (@Singleton) and Dagger writes the boilerplate. Understanding the underlying pattern — as covered above — is what lets you trust that annotation and debug it when something goes wrong.