Loading episodes…
0:00 0:00

The Singleton Pattern in Java: A Visual Guide to Thread-Safe Objects

00:00
BACK TO HOME

The Singleton Pattern in Java: A Visual Guide to Thread-Safe Objects

10xTeam November 06, 2025 13 min read

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:

  1. 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.

  2. 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 synchronized keyword locks on the object instance (this). Since each thread still has its own separate instance of BankService, 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:

  1. Make the constructor private: This prevents anyone from creating a new instance with the new keyword.
  2. Create a private static variable: This will hold the single instance of the class.
  3. Create a public static access 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?**
Solution It is inherently thread-safe, protects against instantiation via reflection, and handles serialization and deserialization correctly out-of-the-box, preventing the creation of new instances. It's also extremely concise.

Join the 10xdev Community

Subscribe and get 8+ free PDFs that contain detailed roadmaps with recommended learning periods for each programming language or field, along with links to free resources such as books, YouTube tutorials, and courses with certificates.

Audio Interrupted

We lost the audio stream. Retry with shorter sentences?