The Singleton pattern is a foundational creational design pattern that guarantees a class has only one instance and provides a single, global point of access to it. Think of it as the master key to a specific resource in your application—there’s only one, and everyone who needs it uses the same one.
This pattern is essential when managing resources that are expensive to create or should be shared across the entire application to ensure consistency.
mindmap
root((Singleton Pattern))
Definition
::icon(fa fa-lock)
One Class, One Instance
Global Access Point
Why Use It?
Memory Optimization
::icon(fa fa-memory)
Avoids redundant object creation
Maintains Shared State
::icon(fa fa-sync)
Ensures data consistency
Implementation
Private Constructor
Static Instance
Static `getInstance()` Method
Key Challenge
::icon(fa fa-exclamation-triangle)
Thread Safety
Race Conditions
Solutions
Eager Initialization
Double-Checked Locking
Enum Singleton
Why Do We Need a Singleton?
There are two primary motivations for using the Singleton pattern:
-
Optimizing Memory and Resources: Imagine a class that manages your application’s configuration settings. These settings are loaded once and rarely change. If every part of your application created its own configuration object, you’d waste memory on redundant, identical objects. A Singleton ensures a single configuration object is created and shared, saving resources. The same logic applies to database connection pools, loggers, and notification services.
-
Maintaining a Consistent Shared State: This is the most critical use case. When multiple parts of an application need to interact with a single, mutable resource, you need to ensure they are all working with the same data. Without a shared state, you can end up with catastrophic data corruption.
The Problem: A Race Condition in a Banking App
Let’s illustrate the danger of not having a shared state with a simple banking application. We have a BankService that manages an account balance.
Here’s our initial, non-Singleton BankService:
// BankService.java - Initial Version
public class BankService {
private double balance = 10000.0;
public void withdraw(double amount) {
System.out.println("Withdrawal request for: " + amount);
if (balance >= amount) {
// Simulate network latency
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance -= amount;
System.out.println("Withdrawal successful. New balance: " + balance);
} else {
System.out.println("Withdrawal failed. Insufficient funds.");
}
}
public void deposit(double amount) {
System.out.println("Deposit request for: " + amount);
// Simulate network latency
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance += amount;
System.out.println("Deposit successful. New balance: " + balance);
}
}
Now, let’s simulate two users trying to access the same account at the exact same time. One attempts to withdraw $7,000, and the other tries to deposit $5,000.
// Main.java
public class Main {
public static void main(String[] args) {
// Thread 1: Withdraw
Thread t1 = new Thread(() -> {
BankService bankService1 = new BankService();
bankService1.withdraw(7000);
});
// Thread 2: Deposit
Thread t2 = new Thread(() -> {
BankService bankService2 = new BankService();
bankService2.deposit(5000);
});
t1.start();
t2.start();
}
}
The Disastrous Output:
Withdrawal request for: 7000
Deposit request for: 5000
Withdrawal successful. New balance: 3000.0
Deposit successful. New balance: 15000.0
The final balance should be 10000 - 7000 + 5000 = 8000. But we got 15000 from the deposit operation!
Here’s what happened:
sequenceDiagram
participant T1 as Thread 1 (Withdraw)
participant T2 as Thread 2 (Deposit)
participant BS1 as BankService Instance 1
participant BS2 as BankService Instance 2
T1->>BS1: new BankService() (balance=10000)
T2->>BS2: new BankService() (balance=10000)
par
T1->>BS1: withdraw(7000)
Note over BS1: Reads balance (10000)
and
T2->>BS2: deposit(5000)
Note over BS2: Reads balance (10000)
end
T1->>BS1: balance = 10000 - 7000 (balance=3000)
T2->>BS2: balance = 10000 + 5000 (balance=15000)
Each thread created its own BankService instance. They weren’t sharing the balance; they were modifying their own separate copies.
A Flawed Fix: Synchronizing Methods on Separate Instances
A common first thought is to synchronize the methods. Let’s add the synchronized keyword.
--- a/BankService.java
+++ b/BankService.java
@@ -3,7 +3,7 @@
public class BankService {
private double balance = 10000.0;
- public void withdraw(double amount) {
+ public synchronized void withdraw(double amount) {
System.out.println("Withdrawal request for: " + amount);
if (balance >= amount) {
// Simulate network latency
@@ -15,7 +15,7 @@
}
}
- public void deposit(double amount) {
+ public synchronized void deposit(double amount) {
System.out.println("Deposit request for: " + amount);
// Simulate network latency
try { Thread.sleep(100); } catch (InterruptedException e) {}
If we run the simulation again, we get the exact same incorrect result.
[!WARNING] The
synchronizedkeyword locks on the object instance (this). Since each thread still has its own separate instance ofBankService, they are locking different objects. There is no contention, and the race condition persists.
The Real Solution: Implementing a Thread-Safe Singleton
To fix this, we must ensure both threads use the exact same instance of BankService.
Here are the three steps to create a Singleton:
- Make the constructor
private: This prevents anyone from creating a new instance with thenewkeyword. - Create a
private staticvariable: This will hold the single instance of the class. - Create a
public staticaccess method (e.g.,getInstance()): This method is responsible for creating the instance (if it doesn’t exist) and returning it.
The biggest challenge is making this getInstance() method thread-safe. A naive implementation can still fail under load.
sequenceDiagram
participant T1 as Thread 1
participant T2 as Thread 2
participant Singleton as Singleton.class
T1->>Singleton: getInstance()
T2->>Singleton: getInstance()
par
T1->>Singleton: if (instance == null) -> true
Note over T1: Pauses before creating instance
and
T2->>Singleton: if (instance == null) -> true
end
T1->>Singleton: instance = new Singleton()
T2->>Singleton: instance = new Singleton() // Oops! Second instance created!
The Double-Checked Locking Solution
To solve this, we use a technique called double-checked locking. It’s efficient because it only acquires a lock the first time, when the instance is null.
Here is the final, thread-safe BankService implementation:
// BankService.java - Thread-Safe Singleton
public class BankService {
// The 'volatile' keyword ensures that multiple threads handle the
// instance variable correctly when it is being initialized.
private static volatile BankService instance;
private double balance = 10000.0;
// A private static object to be used for locking
private static final Object lock = new Object();
// Private constructor to prevent direct instantiation
private BankService() {}
public static BankService getInstance() {
// First check (no lock) for performance
if (instance == null) {
// Acquire lock only if instance is null
synchronized (lock) {
// Second check (inside lock) to ensure only one thread creates the instance
if (instance == null) {
// Simulate the time it takes to initialize the service
try { Thread.sleep(2000); } catch (InterruptedException e) {}
instance = new BankService();
}
}
}
return instance;
}
public synchronized void withdraw(double amount) {
System.out.println("Withdrawal request for: " + amount);
if (balance >= amount) {
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance -= amount;
System.out.println("SUCCESS: Withdrawal of " + amount + ". New balance: " + balance);
} else {
System.out.println("FAILURE: Insufficient funds for withdrawal of " + amount);
}
}
public synchronized void deposit(double amount) {
System.out.println("Deposit request for: " + amount);
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance += amount;
System.out.println("SUCCESS: Deposit of " + amount + ". New balance: " + balance);
}
}
Now, let’s update our Main class to use the getInstance() method:
// Main.java - Using the Singleton
public class Main {
public static void main(String[] args) {
// Thread 1: Withdraw
Thread t1 = new Thread(() -> {
BankService bankService = BankService.getInstance();
bankService.withdraw(7000);
});
// Thread 2: Deposit
Thread t2 = new Thread(() -> {
BankService bankService = BankService.getInstance();
bankService.deposit(5000);
});
t1.start();
t2.start();
}
}
The Correct Output:
Withdrawal request for: 7000
SUCCESS: Withdrawal of 7000. New balance: 3000.0
Deposit request for: 5000
SUCCESS: Deposit of 5000. New balance: 8000.0
Success! The balance is now correct. Because both threads are using the same instance, the synchronized methods correctly queue up the operations, ensuring data consistency.
[!TIP] The first call to
getInstance()will be slower because it has to perform the initialization and locking. Subsequent calls are nearly instantaneous because they just return the already-created instance. This is called lazy initialization.
Best Practices and Edge Cases
While double-checked locking is a classic solution, modern Java offers even better and safer ways to implement Singletons.
1. Eager Initialization
If your object is not resource-heavy or you know you will need it anyway, you can create the instance at class-loading time. This is the simplest way to create a thread-safe Singleton.
public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
2. The Enum Singleton (The Ultimate Solution)
Joshua Bloch, in his book Effective Java, recommends using an enum to implement the Singleton pattern. It’s concise, provides built-in protection against serialization and reflection attacks, and is guaranteed to be thread-safe.
public enum BankServiceEnum {
INSTANCE;
private double balance = 10000.0;
// Methods can be added directly to the enum
public synchronized void withdraw(double amount) {
// ... implementation ...
}
public synchronized void deposit(double amount) {
// ... implementation ...
}
}
// Usage:
// BankServiceEnum.INSTANCE.withdraw(1000);
This approach is considered the gold standard for implementing Singletons in Java today.
Conclusion
The Singleton pattern is a powerful tool for managing resources and state in an application. By ensuring only one instance of a class exists, you can optimize memory usage and prevent the kind of dangerous race conditions we saw in our banking example.
mindmap
root((Singleton Recap))
Problem
Resource Waste
Data Corruption (Race Conditions)
Solution: Singleton Pattern
Core Components
Private Constructor
Private Static Instance
Public Static `getInstance()`
Thread Safety is CRITICAL
Naive implementation fails
Double-Checked Locking works
::icon(fa fa-star) **Enum Singleton is best**
Result
::icon(fa fa-check-circle)
Optimized Memory
Consistent Shared State
While the pattern itself is simple, implementing it correctly in a multi-threaded environment requires care. For modern Java applications, the Enum Singleton is almost always the best choice.
🧠 Pop Quiz: Test Your Knowledge
1. **Why is the constructor of a Singleton class made `private`?**Solution
To prevent other classes from creating new instances of the Singleton using the `new` keyword, which would violate the pattern's core principle of having only one instance.2. **What potential issue does the `volatile` keyword solve in the double-checked locking pattern?**
Solution
It ensures that changes to the `instance` variable are immediately visible to all threads. Without it, a thread might see a partially constructed object due to compiler instruction reordering, leading to subtle and hard-to-debug errors.3. **Why is the Enum Singleton often considered the best implementation in Java?**