Singleton Design Pattern
1. What is Singleton Pattern?
The Singleton pattern is a GOF Creational Pattern that restricts a class to exactly one instance across the entire JVM. It provides a global access point to that single instance through a static factory method. The class itself controls its own instantiation.
One line: Single means one, ton means object. A singleton class allows creating only one object.
In Java it is called a singleton Java class. In C# it is called a singleton C# class. Since it is a GOF pattern, it can be implemented in any OOP language (C++, Java, C#, Python, etc.).
Design patterns are defined by three parts
- Problem: The recurring issue in software development
- Solution: The best proven approach to solve it
- Context: The situation where this solution applies
Not every solution is a design pattern. For a recurring problem, there can be many solutions. Only the best solution that has been proven across multiple projects is called a Design Pattern. The worst solution is called an Anti Pattern.
2. The Problem
Creating multiple objects for a Java class in the following situations is a waste of memory and CPU time:
- If the class has no member variables/attributes/properties (no state)
- If the class has read-only state (only final variables)
- If the class has sharable state (like cache or buffer)
In Java, object creation is a costly process in terms of memory and CPU utilization. Every new call allocates heap memory, runs the constructor, and initializes fields. If a class needs only one instance, creating more wastes resources.
Real-world analogy
Think of all employees in an office sharing a single printer. Every employee does not need their own printer. One printer serves everyone. If we design software for this office, the Printer class should be a singleton -- creating 100 Printer objects for 100 employees is wasteful when one object can serve all.
Three cases where singleton is needed
Case 1: Class with no state
A class that has no member variables, only methods. Creating multiple objects is pointless because every object behaves identically.
// No state -- all objects are identical
public class MathUtils {
private MathUtils() {}
private static MathUtils INSTANCE;
public static MathUtils getInstance() {
if (INSTANCE == null) INSTANCE = new MathUtils();
return INSTANCE;
}
public int add(int a, int b) { return a + b; }
public int multiply(int a, int b) { return a * b; }
}
// Can call methods for multiple times
// No need to create multiple objects
MathUtils.getInstance().add(10, 20);
MathUtils.getInstance().multiply(5, 6);
Case 2: Class with read-only state (final variables)
A class whose state is set once and never changes. Multiple objects all hold the same data.
// Read-only state -- all objects have same data
public class AppConfig {
private static final String DB_URL = "jdbc:mysql://localhost:3306/bankdb";
private static final int MAX_CONNECTIONS = 10;
private static AppConfig INSTANCE;
private AppConfig() {
if (INSTANCE != null)
throw new RuntimeException("Object already created");
}
public static AppConfig getInstance() {
if (INSTANCE == null)
INSTANCE = new AppConfig();
return INSTANCE;
}
public String getDbUrl() { return DB_URL; }
public int getMaxConnections() { return MAX_CONNECTIONS; }
}
Case 3: Class with sharable state (cache, buffer, pool)
A class that holds shared mutable state. Multiple objects would mean duplicate caches or connection pools, leading to inconsistency and wasted memory.
// Sharable state -- one connection pool serves all
public class ConnectionPool {
private static ConnectionPool INSTANCE;
private final List<Connection> pool = new ArrayList<>();
private static final int POOL_SIZE = 10;
private ConnectionPool() {
for (int i = 0; i < POOL_SIZE; i++) {
pool.add(createConnection());
}
}
public static ConnectionPool getInstance() {
if (INSTANCE == null)
INSTANCE = new ConnectionPool();
return INSTANCE;
}
public synchronized Connection getConnection() {
if (pool.isEmpty()) throw new RuntimeException("Pool exhausted");
return pool.remove(0);
}
public synchronized void releaseConnection(Connection conn) {
pool.add(conn);
}
private Connection createConnection() {
// create actual DB connection
return DriverManager.getConnection(
"jdbc:mysql://localhost:3306/bankdb", "root", "password");
}
}
3. The Solution: Three Minimum Standards
A singleton class enforces single-instance creation through three rules:
- Private constructor (infact ALL constructors) -- prevents outsiders from using
newoperator to create objects - Private static reference variable of class type -- holds the single instance. If null, object not yet created. If not null, object already exists.
- Public static factory method with singleton logic -- checks the reference variable, creates object only if null, returns existing object otherwise
Basic Singleton: Printer.java
package com.nt.sdp;
public class Printer {
// Step 2: private static ref variable of class type
private static Printer INSTANCE;
// Step 1: private 0-param constructor
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
// Step 3: static factory method having singleton logic
public static Printer getInstance() {
if (INSTANCE == null) {
INSTANCE = new Printer();
}
return INSTANCE;
}
// business method
public void printMessage(String msg) {
System.out.println(msg);
}
}
Client App: SingletonClassTest1.java
package com.nt.test;
import com.nt.sdp.Printer;
public class SingletonClassTest1 {
public static void main(String[] args) {
// Printer p1 = new Printer(); // INVALID - constructor is private
// Get singleton object
Printer p1 = Printer.getInstance();
Printer p2 = Printer.getInstance();
System.out.println("hash codes: " + p1.hashCode() + " " + p2.hashCode());
System.out.println("p1 == p2? " + (p1 == p2));
}
}
The constructor is called only once. Both p1 and p2 point to the same object (same hashCode, p1 == p2 is true).
We cannot always go by constructor execution to confirm whether an object is created. There are exceptional cases:
- If you create an object for a subclass of an abstract class, the abstract class constructor will execute along with the subclass constructor. But that does not mean an object is created for the abstract class.
- In Java, we cannot create objects for interfaces and abstract classes.
- If the object is created using Cloning or Deserialization, the constructor will NOT be executed because the object is already initialized with data from the source.
4. Pre-defined Singleton Classes in Java
| Class | Why Singleton | How to Get Instance |
|---|---|---|
java.lang.Runtime | One app contains only one runtime. The Runtime class manages the running JVM. | Runtime.getRuntime() |
java.awt.Desktop | One computer contains one desktop. The Desktop class provides access to default browser, email client, etc. | Desktop.getDesktop() |
org.apache.log4j.Logger | All logging activities of an app should be handled by a single Logger object to ensure consistent log output. | Logger.getLogger(Class) |
If a Java class allows creating only one object, but someone still creates one, that class is NOT called singleton. A class is singleton only when it forces/restricts object creation to one. Example: the Test class below is NOT singleton even though you only create one object:
public class Test {
Test t = new Test(); // Creates only one, but nothing stops creating more
// ...
// "Test" is NOT a singleton Java class
}
Servlet component class is not a singleton -- ServletContainer is happy to create one object of Servlet, but it does not force the restriction. If you can bypass the container, you could create more objects.
5. Multithreading Problem
The basic lazy singleton is NOT thread-safe. If multiple threads of the same Client App or different Client Apps call Printer.getInstance() simultaneously with improper thread switching while executing singleton logic, there is a possibility of creating multiple objects for the singleton class.
This is a serious problem when using singleton as a helper class for Servlet/JSP in a web application, which is a multithreaded environment where multiple threads will be created for multiple requests.
Thread switching scenario
Web application scenario (BookMyShowApp)
Thread-unsafe demo code
// TicketBookingOperation.java
package com.nt.test;
import com.nt.sdp.Printer;
public class TicketBookingOperation implements Runnable {
@Override
public void run() {
System.out.println("TicketBookingOperation.run()");
Printer p = Printer.getInstance();
System.out.println(p.hashCode());
}
}
// SingletonClassTest_MultiThreaded.java
public class SingletonClassTest_MultiThreaded {
public static void main(String[] args) {
// create Threads with data
TicketBookingOperation operation = new TicketBookingOperation();
Thread th1 = new Thread(operation);
Thread th2 = new Thread(operation);
Thread th3 = new Thread(operation);
// start threads
th1.start();
th2.start();
th3.start();
}
}
Three different hashCodes = three different objects. The singleton is broken in a multithreaded environment.
6. Six Thread-Safe Solutions
a) Synchronized static factory method
// In Printer.java
public synchronized static Printer getInstance() {
// singleton logic
if (INSTANCE == null) {
try {
Thread.sleep(10000); // simulate slow creation
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
return INSTANCE;
}
Since the synchronized method allows only one thread at a time inside the method, there is no problem of multithreading breaking singleton. However, every call acquires the lock, even after the object exists. This is slow.
Better: Place synchronized(){} block inside the method where the sensitive logic is, instead of making the whole method synchronized.
b) Synchronized block inside static factory method
public static Printer getInstance() {
// singleton logic
synchronized (Printer.class) { // class level locking
if (INSTANCE == null) {
try {
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
}
return INSTANCE;
}
We cannot use this inside a static method, so we cannot pass synchronized(this). To protect static properties of a class, we use class level locking by passing the class name in the form of java.lang.Class object: synchronized(Printer.class).
In Java, .class is a compiler-generated pre-defined public static property of every Java class, returning the object of java.lang.Class having the current class name and info.
Conclusion: If we want to pass a Java class in the app as input value, we can use ClassName.class or the object of java.lang.Class.
c) Double-checked locking (Best 1)
public class Printer {
private static volatile Printer INSTANCE;
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
if (INSTANCE == null) { // 1st NULL check (no lock)
System.out.println("Printer.getInstance()[outside sync block]: "
+ Thread.currentThread().getName());
synchronized (Printer.class) { // class level locking
System.out.println("Printer.getInstance()[inside sync block]: "
+ Thread.currentThread().getName());
if (INSTANCE == null) { // 2nd NULL check (with lock)
try {
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
}
}
return INSTANCE;
}
}
The first null check avoids the synchronized block entirely once the object is created. Only when INSTANCE is null do threads enter the synchronized block, and the second null check inside prevents duplicate creation. The volatile keyword ensures the INSTANCE field is visible across all threads immediately.
This is the most commonly used approach in production code.
d) Eager instantiation
public class Printer {
// object created at class loading time itself (eager)
private static final Printer INSTANCE = new Printer();
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
return INSTANCE; // no checking needed
}
}
Since the object is the static reference variable, the JVM creates it at class loading time. The object is created as soon as the class is loaded, even if it is never used. Thread-safe because class loading is inherently thread-safe in JVM.
Drawback: Creates the object even if never needed (no lazy loading).
e) Inner class based lazy instantiation (Bill Pugh - Best 2)
public class Printer {
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
// Inner class -- loaded only when getInstance() is called
private static class InstanceHolder {
private static final Printer INSTANCE = new Printer();
}
public static Printer getInstance() {
return InstanceHolder.INSTANCE;
}
}
The key: the inner class InstanceHolder is NOT loaded when the outer class Printer is loaded. It is loaded only when getInstance() is first called. The JVM guarantees that class loading is thread-safe. No explicit synchronization needed.
This is the Bill Pugh Singleton -- it combines lazy initialization with thread safety and zero synchronization overhead.
f) ENUM based singleton (Best 3)
package com.nt.sdp;
public enum Printer3 {
INSTANCE; // enum constant -- internally: public static final Printer3 INSTANCE = new Printer3()
public static Printer3 getInstance() {
return INSTANCE;
}
// business method
public void printMessage(String msg) {
System.out.println("printing: " + msg);
}
}
// Usage:
Printer3.INSTANCE.printMessage("Report #1234");
Why is Enum singleton the best?
- No multithreading issues: Eager instantiation happens on enum constant like INSTANCE. There will not be any multi-threading issue.
- Easy to develop: Writing less code with minimum coding standards.
- Clone-safe: The
clone()method is marked as protected final in the internal default super classjava.lang.Enum<E>. So we cannot overrideclone()in our enum.clone()cannot be called on enum constants. Every enum already extendsjava.lang.Enum<E>class internally. It cannot be extended further (Java does not support multiple inheritance). So our enum is NOTCloneableby default. - Serialization-safe: Every enum is Serializable by default. If we perform Serialization on enum constants, when we attempt to do Deserialization, we get already available enum obj/constant (i.e., some internal
readResolve()method functionality is given). - Reflection-safe: For enums, we cannot create objects using Reflection API as they do not contain constructors.
Enum inheritance hierarchy
Enum singleton limitations
- a) We cannot apply few features of Java on enum like inheritance using "extends"
- b) We cannot place instance, static member variables in the enum representing the state of the object
- c) We cannot place
serialVersionUIDwhile working with Enum to perform serialization activity - d) Eager instantiation based singleton is not ideal if the object is never used
7. Breaking Singleton (and How to Prevent)
a) Breaking via Cloning
If a singleton class implements Cloneable (directly or through inheritance), calling clone() creates a second object.
Prevention: Override clone()
// Option 1: Return existing instance (not recommended)
@Override
public Object clone() throws CloneNotSupportedException {
return INSTANCE;
}
// Option 2: Throw exception (BEST solution)
@Override
public Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException(
"In this singleton class, cloning is restricted");
}
b) Breaking via Serialization and Deserialization
The process of converting a Java object's data into a stream of bits and bytes is called Serialization. These bits/bytes can be written to a file or sent over a network. Serialization is possible only on Serializable objects (class must implement java.io.Serializable).
If we perform Serialization on the singleton object and write its data to a file, we can then create as many objects as we want by reading from that file through Deserialization.
Prevention: readResolve() method
// Add this method in your singleton class
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
Full Serialization test code
package com.nt.test;
import java.io.*;
import com.nt.sdp.Printer;
public class SingletonSerializationTest {
public static void doSerialization(Printer printer) {
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("data.ser"))) {
oos.writeObject(printer);
oos.flush();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Object Serialization is done");
}
public static Printer doDeSerialization() {
Printer p = null;
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("data.ser"))) {
p = (Printer) ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}
return p;
}
public static void main(String[] args) {
Printer p1 = Printer.getInstance();
doSerialization(p1);
System.out.println("--------DeSerialization--------");
Printer p2 = doDeSerialization();
Printer p3 = doDeSerialization();
System.out.println(p1.hashCode() + " " + p2.hashCode() + " " + p3.hashCode());
}
}
We never make a singleton class implementing Serializable directly, but it may implement indirectly because classes in the inheritance hierarchy may implement Serializable. In that case, Serialization and Deserialization on the singleton object is possible.
It is recommended to add serialVersionUID to every Serializable class so we will not get InvalidClassException even though the class structure is changed at the time of DeSerialization:
private static final long serialVersionUID = 1L;
If serialVersionUID is not placed, the JVM generates a dynamic one based on the structure of the class. If the structure is modified during DeSerialization, we get:
java.io.InvalidClassException: com.nt.sdp.Printer;
local class incompatible: stream classdesc
serialVersionUID = 1104536345855,
local class serialVersionUID = 8110690610001600652
c) Breaking via Reflection API
Even if the constructor is private, the Reflection API can access it, set it accessible, and create multiple objects. This breaks the singleton guarantee.
package com.nt.test;
import java.lang.reflect.Constructor;
public class SingletonReflectionTest {
public static void main(String[] args) throws Exception {
// Normal way -- gives single object
Printer p1 = Printer.getInstance();
// Reflection API way -- breaks singleton
Class c = Class.forName("com.nt.sdp.Printer");
Constructor[] cons = c.getDeclaredConstructors();
cons[0].setAccessible(true); // bypass private access
Printer p2 = (Printer) cons[0].newInstance();
System.out.println(p1.hashCode() + " " + p2.hashCode());
// Output: DIFFERENT hashCodes -- singleton is broken!
System.out.println("p1 == p2? " + (p1 == p2)); // false
}
}
Prevention: isInstantiated flag
public class Printer {
private static Printer INSTANCE;
private static boolean isInstantiated = false;
private Printer() {
// Stop reflection-based second object creation
if (isInstantiated) {
throw new RuntimeException(
"Object is already created. Cannot create another.");
}
isInstantiated = true;
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
if (INSTANCE == null)
INSTANCE = new Printer();
return INSTANCE;
}
}
8. Breaking Singleton via ClassLoader
JVM internally uses a ClassLoader subsystem to load Java classes. The system has three classloaders:
Notes on ClassLoader behavior
- If a class is loaded by parent classloader, the same class will NOT be loaded by child classloader (Principle of Delegation)
- The class loaded by parent classloader is visible to child classloader, but not the reverse (Principle of Visibility)
- When JVM gives a class to classloader subsystem for loading, it is delegated to all classloaders using the delegation principle
Breaking singleton using Custom ClassLoader
URLClassLoader is a readily available custom classloader which can load classes from specific jar files and also initialize them as needed.
// Using Application ClassLoader (default)
System.out.println("Using Application ClassLoader");
Printer p1 = Printer.getInstance();
Printer p2 = Printer.getInstance();
System.out.println(p1.hashCode() + " " + p2.hashCode());
System.out.println("---");
// Using Custom ClassLoader (URLClassLoader)
System.out.println("Using URLClassLoader");
URLClassLoader loader = new URLClassLoader(
new URL[]{new URL("file:///c/myProj/jar5/1.jar")});
Class c1 = loader.loadClass("com.nt.sdp.Printer");
Constructor cons = c1.getDeclaredConstructors()[0];
cons.setAccessible(true);
Object obj = cons.newInstance();
System.out.println("object hash codes: " + obj.hashCode() + " " + p1.hashCode());
Here URLClassLoader is an independent classloader and not linked with JVM's classloader system. So one object is created using ApplicationClassLoader of ClassLoader sub system and another object will be using URLClassLoader (custom classloader).
There is no solution for this problem from the singleton Java class developer perspective. Because URLClassLoader can break any singleton Java class. The only practical advice is: do not allow untrusted jar files to create CustomClassLoader or use SecurityManager to restrict ClassLoader creation.
Fortunately, this is a rare scenario in production. It only occurs when someone deliberately creates a custom classloader.
9. Singleton Pattern vs Spring Singleton Scope
| Aspect | Singleton Design Pattern | Spring Singleton Scope |
|---|---|---|
| Scope | One object per JVM (per classloader) | One object per Spring IoC container |
| Who controls | The class itself (private constructor) | The Spring container (bean lifecycle) |
| Multiple containers | Not possible. One JVM = one instance | Each container has its own instance |
| Thread safety | Developer's responsibility | Container handles lifecycle, developer handles state |
| Implementation | Private constructor + static factory method | @Scope("singleton") or default scope |
Spring IoC container creating one object for servlet comp class does not mean Spring is making classes as singletons. Spring is just having singleton behavior (one object for singleton scope spring bean), but the class is not a true singleton because there is no private constructor restriction. If you bypass the container, you could create more objects.
10. Full-Length Industry Project: BookMyShow Ticket Booking System
BookMyShow is an online ticket booking platform. Thousands of users access the system simultaneously from browsers and mobile apps. The system needs:
- A single database connection pool shared across all request threads (creating a pool per request would exhaust DB connections)
- A single application logger so all services write to the same log file in order
- A single configuration manager to load settings once and serve all services
- A single cache manager to cache movie and show data, avoiding repeated DB calls
Each of these is a Singleton. The project demonstrates four different singleton implementations working together in a real layered architecture.
High-Level Design (HLD)
Low-Level Design (LLD)
Project Structure
application.properties
# BookMyShow Configuration
app.name=BookMyShow
app.version=1.0.0
# Database
db.url=jdbc:mysql://localhost:3306/bookmyshow_db
db.username=bms_admin
db.password=encrypted_pwd
db.pool.size=20
# Cache
cache.default.ttl.seconds=300
cache.movies.ttl.seconds=600
# Booking
booking.max.seats.per.transaction=10
booking.payment.timeout.seconds=120
# Logging
log.file=bookmyshow.log
log.level=INFO
Connection.java (Simulated DB connection)
package com.bookmyshow.model;
import java.util.UUID;
public class Connection {
private final String id;
private final String url;
private boolean inUse;
public Connection(String url) {
this.id = UUID.randomUUID().toString().substring(0, 8);
this.url = url;
this.inUse = false;
}
public String getId() { return id; }
public String getUrl() { return url; }
public boolean isInUse() { return inUse; }
public void setInUse(boolean inUse) { this.inUse = inUse; }
@Override
public String toString() {
return "Connection[" + id + "]";
}
}
Movie.java
package com.bookmyshow.model;
public class Movie {
private final String id;
private final String title;
private final String genre;
private final int durationMinutes;
public Movie(String id, String title, String genre, int durationMinutes) {
this.id = id;
this.title = title;
this.genre = genre;
this.durationMinutes = durationMinutes;
}
public String getId() { return id; }
public String getTitle() { return title; }
public String getGenre() { return genre; }
public int getDurationMinutes() { return durationMinutes; }
@Override
public String toString() {
return title + " (" + genre + ", " + durationMinutes + " min)";
}
}
Show.java
package com.bookmyshow.model;
public class Show {
private final String id;
private final String movieId;
private final String theatre;
private final String time;
private int availableSeats;
private final double price;
public Show(String id, String movieId, String theatre,
String time, int availableSeats, double price) {
this.id = id;
this.movieId = movieId;
this.theatre = theatre;
this.time = time;
this.availableSeats = availableSeats;
this.price = price;
}
public String getId() { return id; }
public String getMovieId() { return movieId; }
public String getTheatre() { return theatre; }
public String getTime() { return time; }
public int getAvailableSeats() { return availableSeats; }
public double getPrice() { return price; }
public synchronized boolean reserveSeats(int count) {
if (count > availableSeats) return false;
availableSeats -= count;
return true;
}
@Override
public String toString() {
return theatre + " | " + time + " | Seats: " + availableSeats
+ " | Rs " + price;
}
}
Booking.java
package com.bookmyshow.model;
import java.time.LocalDateTime;
import java.util.UUID;
public class Booking {
private final String id;
private final String userId;
private final String showId;
private final int seats;
private final double totalAmount;
private final LocalDateTime bookedAt;
private String status;
public Booking(String userId, String showId, int seats, double totalAmount) {
this.id = "BKG-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
this.userId = userId;
this.showId = showId;
this.seats = seats;
this.totalAmount = totalAmount;
this.bookedAt = LocalDateTime.now();
this.status = "CONFIRMED";
}
public String getId() { return id; }
public String getUserId() { return userId; }
public String getShowId() { return showId; }
public int getSeats() { return seats; }
public double getTotalAmount() { return totalAmount; }
public LocalDateTime getBookedAt() { return bookedAt; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
@Override
public String toString() {
return "Booking{id=" + id + ", user=" + userId + ", show=" + showId
+ ", seats=" + seats + ", amount=Rs " + totalAmount
+ ", status=" + status + "}";
}
}
ConfigManager.java (Enum Singleton)
package com.bookmyshow.singleton;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
/**
* Application configuration manager.
* Enum-based singleton: thread-safe, reflection-safe, serialization-safe.
*/
public enum ConfigManager {
INSTANCE;
private final Properties properties;
ConfigManager() {
this.properties = new Properties();
try (InputStream stream = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
if (stream != null) {
properties.load(stream);
}
} catch (IOException e) {
throw new RuntimeException("Failed to load configuration", e);
}
}
public String get(String key) {
return properties.getProperty(key);
}
public String get(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
public int getInt(String key, int defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public boolean getBoolean(String key, boolean defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
return Boolean.parseBoolean(value.trim());
}
}
AppLogger.java (Bill Pugh Singleton)
package com.bookmyshow.singleton;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Application logger.
* Bill Pugh (inner class) singleton: lazy loaded, thread-safe, no synchronization overhead.
*/
public class AppLogger {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final PrintWriter fileWriter;
private AppLogger() {
String logFile = ConfigManager.INSTANCE.get("log.file", "app.log");
try {
this.fileWriter = new PrintWriter(
new FileWriter(logFile, true), true);
} catch (IOException e) {
throw new RuntimeException("Cannot open log file: " + logFile, e);
}
}
// Inner class loaded only when getInstance() is first called
private static class InstanceHolder {
private static final AppLogger INSTANCE = new AppLogger();
}
public static AppLogger getInstance() {
return InstanceHolder.INSTANCE;
}
public void info(String message) {
log("INFO", message);
}
public void warn(String message) {
log("WARN", message);
}
public void error(String message) {
log("ERROR", message);
}
public void error(String message, Throwable throwable) {
log("ERROR", message + " | " + throwable.getMessage());
}
private void log(String level, String message) {
String timestamp = LocalDateTime.now().format(FORMATTER);
String threadName = Thread.currentThread().getName();
String entry = String.format("[%s] [%-5s] [%s] %s",
timestamp, level, threadName, message);
System.out.println(entry);
fileWriter.println(entry);
}
}
ConnectionPool.java (Double-Checked Locking Singleton)
package com.bookmyshow.singleton;
import com.bookmyshow.model.Connection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Set;
/**
* Database connection pool.
* Double-checked locking singleton: lazy, thread-safe, minimal lock contention.
*/
public class ConnectionPool {
private static volatile ConnectionPool INSTANCE;
private final Queue<Connection> availableConnections;
private final Set<Connection> usedConnections;
private final int maxPoolSize;
private ConnectionPool(int poolSize, String dbUrl) {
this.maxPoolSize = poolSize;
this.availableConnections = new LinkedList<>();
this.usedConnections = new HashSet<>();
for (int i = 0; i < poolSize; i++) {
availableConnections.add(new Connection(dbUrl));
}
AppLogger.getInstance().info(
"ConnectionPool initialized with " + poolSize + " connections");
}
public static ConnectionPool getInstance() {
if (INSTANCE == null) {
synchronized (ConnectionPool.class) {
if (INSTANCE == null) {
String dbUrl = ConfigManager.INSTANCE.get("db.url");
int poolSize = ConfigManager.INSTANCE.getInt("db.pool.size", 10);
INSTANCE = new ConnectionPool(poolSize, dbUrl);
}
}
}
return INSTANCE;
}
public synchronized Connection getConnection() {
if (availableConnections.isEmpty()) {
AppLogger.getInstance().warn(
"Connection pool exhausted. Used: " + usedConnections.size()
+ "/" + maxPoolSize);
throw new RuntimeException("No available connections in pool");
}
Connection conn = availableConnections.poll();
conn.setInUse(true);
usedConnections.add(conn);
return conn;
}
public synchronized void releaseConnection(Connection conn) {
if (conn == null) return;
conn.setInUse(false);
usedConnections.remove(conn);
availableConnections.offer(conn);
}
public int getAvailableCount() {
return availableConnections.size();
}
public int getUsedCount() {
return usedConnections.size();
}
}
CacheManager.java (Inner Class Singleton with TTL)
package com.bookmyshow.singleton;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory cache with time-to-live (TTL) expiry.
* Inner class singleton: lazy loaded, thread-safe.
* Uses ConcurrentHashMap for thread-safe read/write.
*/
public class CacheManager {
private final Map<String, CacheEntry> cache;
private final long defaultTtlMs;
private static class CacheEntry {
final Object value;
final long expiresAt;
CacheEntry(Object value, long ttlMs) {
this.value = value;
this.expiresAt = System.currentTimeMillis() + ttlMs;
}
boolean isExpired() {
return System.currentTimeMillis() > expiresAt;
}
}
private CacheManager() {
this.cache = new ConcurrentHashMap<>();
int ttlSeconds = ConfigManager.INSTANCE.getInt(
"cache.default.ttl.seconds", 300);
this.defaultTtlMs = ttlSeconds * 1000L;
AppLogger.getInstance().info(
"CacheManager initialized with default TTL: " + ttlSeconds + "s");
}
private static class InstanceHolder {
private static final CacheManager INSTANCE = new CacheManager();
}
public static CacheManager getInstance() {
return InstanceHolder.INSTANCE;
}
public void put(String key, Object value) {
put(key, value, defaultTtlMs);
}
public void put(String key, Object value, long ttlMs) {
cache.put(key, new CacheEntry(value, ttlMs));
}
@SuppressWarnings("unchecked")
public <T> T get(String key) {
CacheEntry entry = cache.get(key);
if (entry == null) return null;
if (entry.isExpired()) {
cache.remove(key);
return null;
}
return (T) entry.value;
}
public void remove(String key) {
cache.remove(key);
}
public void clear() {
cache.clear();
}
public int size() {
// Remove expired entries and return count
cache.entrySet().removeIf(e -> e.getValue().isExpired());
return cache.size();
}
}
MovieService.java
package com.bookmyshow.service;
import com.bookmyshow.model.Connection;
import com.bookmyshow.model.Movie;
import com.bookmyshow.model.Show;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.Arrays;
import java.util.List;
public class MovieService {
private final AppLogger logger = AppLogger.getInstance();
private final CacheManager cache = CacheManager.getInstance();
private final ConnectionPool pool = ConnectionPool.getInstance();
public List<Movie> getAllMovies() {
// Check cache first
List<Movie> cached = cache.get("all_movies");
if (cached != null) {
logger.info("Movies loaded from cache");
return cached;
}
// Cache miss: fetch from DB
Connection conn = pool.getConnection();
try {
logger.info("Fetching movies from DB using " + conn);
// Simulated DB query
List<Movie> movies = Arrays.asList(
new Movie("MOV001", "Pathaan", "Action", 146),
new Movie("MOV002", "Jawan", "Action", 169),
new Movie("MOV003", "Animal", "Drama", 201),
new Movie("MOV004", "12th Fail", "Drama", 147),
new Movie("MOV005", "Dunki", "Comedy-Drama", 161)
);
// Store in cache
cache.put("all_movies", movies);
logger.info("Movies cached. Count: " + movies.size());
return movies;
} finally {
pool.releaseConnection(conn);
}
}
public Movie getMovieById(String movieId) {
Movie cached = cache.get("movie_" + movieId);
if (cached != null) return cached;
List<Movie> movies = getAllMovies();
for (Movie m : movies) {
if (m.getId().equals(movieId)) {
cache.put("movie_" + movieId, m);
return m;
}
}
return null;
}
public List<Show> getShowsForMovie(String movieId) {
List<Show> cached = cache.get("shows_" + movieId);
if (cached != null) {
logger.info("Shows for " + movieId + " loaded from cache");
return cached;
}
Connection conn = pool.getConnection();
try {
logger.info("Fetching shows for " + movieId + " from DB using " + conn);
// Simulated DB query
List<Show> shows = Arrays.asList(
new Show("SHW001", movieId, "PVR Phoenix Mall", "10:00 AM", 150, 250.0),
new Show("SHW002", movieId, "PVR Phoenix Mall", "02:30 PM", 150, 300.0),
new Show("SHW003", movieId, "INOX Lido", "06:00 PM", 200, 350.0),
new Show("SHW004", movieId, "INOX Lido", "09:30 PM", 200, 400.0)
);
cache.put("shows_" + movieId, shows);
return shows;
} finally {
pool.releaseConnection(conn);
}
}
}
BookingService.java
package com.bookmyshow.service;
import com.bookmyshow.model.Booking;
import com.bookmyshow.model.Connection;
import com.bookmyshow.model.Show;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConfigManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.List;
public class BookingService {
private final AppLogger logger = AppLogger.getInstance();
private final ConnectionPool pool = ConnectionPool.getInstance();
private final CacheManager cache = CacheManager.getInstance();
public Booking bookTicket(String userId, String showId, int seatCount) {
int maxSeats = ConfigManager.INSTANCE.getInt(
"booking.max.seats.per.transaction", 10);
if (seatCount <= 0 || seatCount > maxSeats) {
logger.error("Invalid seat count: " + seatCount
+ ". Max allowed: " + maxSeats);
throw new IllegalArgumentException(
"Seat count must be between 1 and " + maxSeats);
}
Connection conn = pool.getConnection();
try {
logger.info("Booking " + seatCount + " seats for user "
+ userId + " on show " + showId);
// Find the show and reserve seats
Show show = findShow(showId);
if (show == null) {
logger.error("Show not found: " + showId);
throw new RuntimeException("Show not found: " + showId);
}
boolean reserved = show.reserveSeats(seatCount);
if (!reserved) {
logger.warn("Not enough seats for show " + showId
+ ". Requested: " + seatCount
+ ", Available: " + show.getAvailableSeats());
throw new RuntimeException(
"Not enough seats. Available: " + show.getAvailableSeats());
}
double totalAmount = show.getPrice() * seatCount;
Booking booking = new Booking(userId, showId, seatCount, totalAmount);
// Invalidate show cache since seats changed
cache.remove("shows_" + show.getMovieId());
logger.info("Booking confirmed: " + booking.getId()
+ " | Amount: Rs " + totalAmount);
return booking;
} finally {
pool.releaseConnection(conn);
}
}
public boolean cancelBooking(Booking booking) {
logger.info("Cancelling booking: " + booking.getId());
booking.setStatus("CANCELLED");
logger.info("Booking cancelled: " + booking.getId());
return true;
}
private Show findShow(String showId) {
// Search across all cached shows
MovieService movieService = new MovieService();
for (var movie : movieService.getAllMovies()) {
List<Show> shows = movieService.getShowsForMovie(movie.getId());
for (Show s : shows) {
if (s.getId().equals(showId)) return s;
}
}
return null;
}
}
Application.java (Main Entry Point)
package com.bookmyshow;
import com.bookmyshow.model.Booking;
import com.bookmyshow.model.Movie;
import com.bookmyshow.model.Show;
import com.bookmyshow.service.BookingService;
import com.bookmyshow.service.MovieService;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConfigManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.List;
public class Application {
public static void main(String[] args) throws InterruptedException {
AppLogger logger = AppLogger.getInstance();
logger.info("=== BookMyShow Application Started ===");
logger.info("App: " + ConfigManager.INSTANCE.get("app.name")
+ " v" + ConfigManager.INSTANCE.get("app.version"));
// ---- 1. VERIFY ALL FOUR SINGLETONS ----
System.out.println("\n--- Singleton Verification ---");
ConfigManager c1 = ConfigManager.INSTANCE;
ConfigManager c2 = ConfigManager.INSTANCE;
System.out.println("ConfigManager same? " + (c1 == c2)
+ " | hashCode: " + c1.hashCode());
AppLogger l1 = AppLogger.getInstance();
AppLogger l2 = AppLogger.getInstance();
System.out.println("AppLogger same? " + (l1 == l2)
+ " | hashCode: " + l1.hashCode());
ConnectionPool p1 = ConnectionPool.getInstance();
ConnectionPool p2 = ConnectionPool.getInstance();
System.out.println("ConnectionPool same? " + (p1 == p2)
+ " | hashCode: " + p1.hashCode());
CacheManager m1 = CacheManager.getInstance();
CacheManager m2 = CacheManager.getInstance();
System.out.println("CacheManager same? " + (m1 == m2)
+ " | hashCode: " + m1.hashCode());
// ---- 2. BROWSE MOVIES ----
System.out.println("\n--- Available Movies ---");
MovieService movieService = new MovieService();
List<Movie> movies = movieService.getAllMovies();
for (Movie m : movies) {
System.out.println(" " + m);
}
// Call again -- should come from cache
System.out.println("\n--- Movies (2nd call - from cache) ---");
movieService.getAllMovies();
// ---- 3. BROWSE SHOWS ----
System.out.println("\n--- Shows for Pathaan ---");
List<Show> shows = movieService.getShowsForMovie("MOV001");
for (Show s : shows) {
System.out.println(" " + s);
}
// ---- 4. BOOK TICKETS ----
System.out.println("\n--- Booking Tickets ---");
BookingService bookingService = new BookingService();
Booking booking1 = bookingService.bookTicket("USER001", "SHW003", 4);
System.out.println(" " + booking1);
Booking booking2 = bookingService.bookTicket("USER002", "SHW003", 2);
System.out.println(" " + booking2);
// ---- 5. CANCEL BOOKING ----
System.out.println("\n--- Cancel Booking ---");
bookingService.cancelBooking(booking1);
System.out.println(" " + booking1);
// ---- 6. CONNECTION POOL STATUS ----
System.out.println("\n--- Connection Pool Status ---");
System.out.println(" Available: " + p1.getAvailableCount());
System.out.println(" In use: " + p1.getUsedCount());
// ---- 7. CACHE STATUS ----
System.out.println("\n--- Cache Status ---");
System.out.println(" Cached items: " + m1.size());
// ---- 8. MULTITHREADED BOOKING TEST ----
System.out.println("\n--- Multithreaded Booking Test (5 users) ---");
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
try {
BookingService svc = new BookingService();
Booking b = svc.bookTicket("USER_MT_" + idx, "SHW004", 2);
System.out.println(" Thread-" + idx + ": " + b.getId()
+ " | Seats: " + b.getSeats()
+ " | Amount: Rs " + b.getTotalAmount());
} catch (Exception e) {
System.out.println(" Thread-" + idx + ": FAILED - "
+ e.getMessage());
}
}, "BookingThread-" + i);
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("\n--- Final Pool Status ---");
System.out.println(" Available: " + p1.getAvailableCount());
System.out.println(" In use: " + p1.getUsedCount());
logger.info("=== BookMyShow Application Finished ===");
}
}
SingletonThreadSafetyTest.java
package com.bookmyshow.singleton;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SingletonThreadSafetyTest {
@Test
void configManager_isSingleton() {
assertSame(ConfigManager.INSTANCE, ConfigManager.INSTANCE);
}
@Test
void appLogger_isSingleton() {
assertSame(AppLogger.getInstance(), AppLogger.getInstance());
}
@Test
void connectionPool_isSingleton() {
assertSame(ConnectionPool.getInstance(), ConnectionPool.getInstance());
}
@Test
void cacheManager_isSingleton() {
assertSame(CacheManager.getInstance(), CacheManager.getInstance());
}
@Test
void allSingletons_threadSafe() throws InterruptedException {
int threadCount = 100;
AppLogger[] loggers = new AppLogger[threadCount];
ConnectionPool[] pools = new ConnectionPool[threadCount];
CacheManager[] caches = new CacheManager[threadCount];
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
loggers[idx] = AppLogger.getInstance();
pools[idx] = ConnectionPool.getInstance();
caches[idx] = CacheManager.getInstance();
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (int i = 1; i < threadCount; i++) {
assertSame(loggers[0], loggers[i],
"AppLogger must be same across all threads");
assertSame(pools[0], pools[i],
"ConnectionPool must be same across all threads");
assertSame(caches[0], caches[i],
"CacheManager must be same across all threads");
}
}
@Test
void connectionPool_getAndRelease() {
ConnectionPool pool = ConnectionPool.getInstance();
int before = pool.getAvailableCount();
var conn = pool.getConnection();
assertEquals(before - 1, pool.getAvailableCount());
assertEquals(1, pool.getUsedCount());
pool.releaseConnection(conn);
assertEquals(before, pool.getAvailableCount());
assertEquals(0, pool.getUsedCount());
}
@Test
void cacheManager_putAndGet() {
CacheManager cache = CacheManager.getInstance();
cache.put("test_key", "test_value");
assertEquals("test_value", cache.get("test_key"));
}
@Test
void cacheManager_expiredEntryReturnsNull() throws InterruptedException {
CacheManager cache = CacheManager.getInstance();
cache.put("expire_key", "value", 50); // 50ms TTL
Thread.sleep(100);
assertNull(cache.get("expire_key"));
}
@Test
void configManager_readsProperties() {
assertEquals("BookMyShow", ConfigManager.INSTANCE.get("app.name"));
assertEquals(20, ConfigManager.INSTANCE.getInt("db.pool.size", 5));
}
}
Expected Output
Key observations from the project
- Four singletons, four approaches: ConfigManager (enum), AppLogger (Bill Pugh), ConnectionPool (double-checked locking), CacheManager (inner class). Each chosen for a specific reason.
- All services share the same instances: MovieService and BookingService both call
getInstance()and get the exact same object (verified by hashCode). - Thread safety verified: 5 concurrent booking threads all get the same singleton instances and all bookings succeed without data corruption.
- Connection pool manages lifecycle: Connections are borrowed and returned. Pool never exceeds the configured size.
- Cache reduces DB calls: First movie list call hits DB. Second call returns from cache. No redundant queries.
- Configuration drives behavior: Pool size, cache TTL, max seats all come from properties file. No hardcoded values.
- Logger captures everything: Every operation is logged with timestamp, level, thread name, and message. Written to console and file simultaneously.
11. Smaller Example: Configuration Manager
National Bank of India needs a centralized configuration manager for their banking application. The config manager loads database URLs, API keys, feature flags, and connection pool sizes from a properties file. Every service (Account Service, Transfer Service, Notification Service) must read from the same configuration. If each service creates its own config object, they might read stale or inconsistent data. The config must be loaded once, shared everywhere, and be thread-safe because multiple request threads access it simultaneously.
High-Level Design (HLD)
Low-Level Design (LLD)
Project Structure
application.properties
# Database Configuration
db.url=jdbc:mysql://localhost:3306/national_bank_db
db.username=nbi_admin
db.password=encrypted_pwd_here
db.pool.size=20
db.pool.timeout=30000
# API Configuration
api.base.url=https://api.nationalbankofIndia.com/v1
api.timeout=5000
api.retry.count=3
# Feature Flags
feature.upi.enabled=true
feature.international.transfer.enabled=false
feature.sms.notification.enabled=true
# Notification
notification.sms.gateway=https://sms.gateway.nbi.com
notification.email.from=noreply@nationalbankofIndia.com
ConfigManager.java
package com.nbi.banking.config;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectStreamException;
import java.io.Serializable;
import java.util.Properties;
import java.util.Set;
public class ConfigManager implements Serializable {
private static final long serialVersionUID = 1L;
private static volatile ConfigManager INSTANCE;
private static boolean isInstantiated = false;
private final Properties properties;
private ConfigManager() {
// Reflection protection
if (isInstantiated) {
throw new RuntimeException(
"ConfigManager is a singleton. Use getInstance() instead.");
}
isInstantiated = true;
this.properties = new Properties();
loadProperties();
}
public static ConfigManager getInstance() {
if (INSTANCE == null) {
synchronized (ConfigManager.class) {
if (INSTANCE == null) {
INSTANCE = new ConfigManager();
}
}
}
return INSTANCE;
}
public String get(String key) {
return properties.getProperty(key);
}
public String get(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
public int getInt(String key, int defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public boolean getBoolean(String key, boolean defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
return Boolean.parseBoolean(value.trim());
}
public Set getAllKeys() {
return properties.stringPropertyNames();
}
public synchronized void reload() {
properties.clear();
loadProperties();
System.out.println("Configuration reloaded successfully");
}
private void loadProperties() {
try (InputStream stream = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
if (stream == null) {
throw new RuntimeException(
"application.properties not found on classpath");
}
properties.load(stream);
System.out.println("Loaded " + properties.size()
+ " configuration properties");
} catch (IOException e) {
throw new RuntimeException(
"Failed to load configuration", e);
}
}
// Serialization protection
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
// Clone protection
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException(
"ConfigManager is a singleton. Cloning is not allowed.");
}
}
AccountService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
public class AccountService {
private final ConfigManager config = ConfigManager.getInstance();
public void getAccountDetails(String accountNumber) {
String dbUrl = config.get("db.url");
int poolSize = config.getInt("db.pool.size", 10);
System.out.println("Fetching account " + accountNumber
+ " from database: " + dbUrl
+ " (pool size: " + poolSize + ")");
// actual DB query logic here
}
}
TransferService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
import java.math.BigDecimal;
public class TransferService {
private final ConfigManager config = ConfigManager.getInstance();
public boolean transfer(String from, String to, BigDecimal amount) {
boolean upiEnabled = config.getBoolean("feature.upi.enabled", false);
int timeout = config.getInt("api.timeout", 5000);
System.out.println("Transfer " + amount + " from " + from + " to " + to);
System.out.println("UPI enabled: " + upiEnabled + ", timeout: " + timeout + "ms");
// Verify same config instance across services
System.out.println("Config instance hashCode: "
+ config.hashCode());
return true;
}
}
NotificationService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
public class NotificationService {
private final ConfigManager config = ConfigManager.getInstance();
public void sendSmsNotification(String phone, String message) {
boolean smsEnabled = config.getBoolean(
"feature.sms.notification.enabled", false);
if (!smsEnabled) {
System.out.println("SMS notification is disabled");
return;
}
String gateway = config.get("notification.sms.gateway");
System.out.println("Sending SMS via " + gateway
+ " to " + phone + ": " + message);
// Verify same config instance
System.out.println("Config instance hashCode: "
+ config.hashCode());
}
}
Application.java
package com.nbi.banking;
import com.nbi.banking.config.ConfigManager;
import com.nbi.banking.service.AccountService;
import com.nbi.banking.service.TransferService;
import com.nbi.banking.service.NotificationService;
import java.math.BigDecimal;
public class Application {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== National Bank of India - Banking Application ===");
System.out.println();
// 1. Verify singleton behavior
ConfigManager config1 = ConfigManager.getInstance();
ConfigManager config2 = ConfigManager.getInstance();
System.out.println("Singleton verification:");
System.out.println(" config1 hashCode: " + config1.hashCode());
System.out.println(" config2 hashCode: " + config2.hashCode());
System.out.println(" config1 == config2? " + (config1 == config2));
System.out.println();
// 2. Use services (all share same ConfigManager instance)
AccountService accountService = new AccountService();
TransferService transferService = new TransferService();
NotificationService notificationService = new NotificationService();
accountService.getAccountDetails("SAV2001");
System.out.println();
transferService.transfer("SAV2001", "CUR3001",
new BigDecimal("50000.00"));
System.out.println();
notificationService.sendSmsNotification(
"+91-9876543210",
"Transfer of Rs 50,000 from SAV2001 to CUR3001 completed");
System.out.println();
// 3. Multithreaded access verification
System.out.println("=== Multithreaded Access Test ===");
Thread[] threads = new Thread[10];
int[] hashCodes = new int[10];
for (int i = 0; i < 10; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
ConfigManager cm = ConfigManager.getInstance();
hashCodes[idx] = cm.hashCode();
System.out.println("Thread-" + idx
+ " config hashCode: " + cm.hashCode());
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
// Verify all threads got the same instance
boolean allSame = true;
for (int i = 1; i < 10; i++) {
if (hashCodes[i] != hashCodes[0]) {
allSame = false;
break;
}
}
System.out.println("All threads got same instance? " + allSame);
System.out.println();
// 4. Read all configuration keys
System.out.println("=== All Configuration Keys ===");
for (String key : config1.getAllKeys()) {
System.out.println(" " + key + " = " + config1.get(key));
}
}
}
ConfigManagerTest.java
package com.nbi.banking.config;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ConfigManagerTest {
@Test
void getInstance_returnsSameInstance() {
ConfigManager c1 = ConfigManager.getInstance();
ConfigManager c2 = ConfigManager.getInstance();
assertSame(c1, c2);
}
@Test
void getInstance_isThreadSafe() throws InterruptedException {
ConfigManager[] results = new ConfigManager[100];
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
final int idx = i;
threads[i] = new Thread(
() -> results[idx] = ConfigManager.getInstance());
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (ConfigManager result : results) {
assertSame(results[0], result);
}
}
@Test
void get_returnsConfigValue() {
ConfigManager config = ConfigManager.getInstance();
String dbUrl = config.get("db.url");
assertNotNull(dbUrl);
assertTrue(dbUrl.contains("national_bank_db"));
}
@Test
void getInt_returnsIntValue() {
ConfigManager config = ConfigManager.getInstance();
int poolSize = config.getInt("db.pool.size", 5);
assertEquals(20, poolSize);
}
@Test
void getInt_returnsDefaultForMissingKey() {
ConfigManager config = ConfigManager.getInstance();
int value = config.getInt("nonexistent.key", 42);
assertEquals(42, value);
}
@Test
void getBoolean_returnsBooleanValue() {
ConfigManager config = ConfigManager.getInstance();
assertTrue(config.getBoolean("feature.upi.enabled", false));
assertFalse(config.getBoolean(
"feature.international.transfer.enabled", true));
}
@Test
void clone_throwsException() {
ConfigManager config = ConfigManager.getInstance();
assertThrows(CloneNotSupportedException.class,
() -> config.clone());
}
@Test
void getAllKeys_returnsNonEmptySet() {
ConfigManager config = ConfigManager.getInstance();
assertFalse(config.getAllKeys().isEmpty());
}
}
Expected Output
12. When to Use and When NOT to Use
When to use Singleton
| Scenario | Example | Why Singleton |
|---|---|---|
| Configuration management | Loading app config from properties/yaml | Config is read-only after load. One source of truth for all services. |
| Logging | Application-wide logger | All components write to same log file. One writer prevents file corruption. |
| Database connection pool | HikariCP, C3P0 pool manager | One pool shared by all threads. Multiple pools waste connections. |
| Cache manager | In-memory cache (Guava, Caffeine) | One cache serves all reads. Duplicate caches waste memory and lose consistency. |
| Thread pool manager | Executor service manager | One pool controls thread count. Multiple pools can exhaust system resources. |
| Hardware access | Printer spooler, file system access | One manager coordinates access to shared hardware. |
| Registry / Service locator | JNDI lookup, bean factory | One registry holds all references. Multiple registries cause inconsistency. |
When NOT to use Singleton
| Scenario | Why NOT | Use Instead |
|---|---|---|
| Classes with per-request state | Each request needs its own state (user data, session). Singleton shares state across all requests. | New instance per request (prototype scope) |
| Unit testing | Singletons carry state between tests. One test can pollute another. Hard to mock. | Dependency injection with interfaces |
| When subclassing is needed | Singleton has a private constructor. Cannot extend it. Limits flexibility. | Factory pattern with interface |
| In distributed systems | Singleton is per-JVM. In a cluster with 10 JVMs, you have 10 instances, not one. | Distributed cache (Redis, Hazelcast) |
| When lazy init overhead matters | Double-checked locking adds complexity. If the class is always used, eager init is simpler. | Eager init or Spring bean |
| God object / too many responsibilities | Singleton should not become a dumping ground for unrelated state. | Split into focused classes |
Singleton becomes an anti-pattern when:
- It holds mutable state that changes per request (user sessions, request data)
- It is used as a global variable to avoid passing dependencies
- It makes unit testing impossible because you cannot inject mock implementations
- It grows into a god class with 50+ methods doing unrelated things
Modern approach: Use dependency injection (Spring, Guice) to manage object lifecycle instead of manually coding singletons. Let the framework decide the scope.
Singleton Design Pattern in Java
1. What is Singleton Pattern?
The Singleton pattern is a GOF Creational Pattern that restricts a class to exactly one instance across the entire JVM. It provides a global access point to that single instance through a static factory method. The class itself controls its own instantiation.
One line: Single means one, ton means object. A singleton class allows creating only one object.
In Java it is called a singleton Java class. In C# it is called a singleton C# class. Since it is a GOF pattern, it can be implemented in any OOP language (C++, Java, C#, Python, etc.).
Design patterns are defined by three parts
- Problem: The recurring issue in software development
- Solution: The best proven approach to solve it
- Context: The situation where this solution applies
Not every solution is a design pattern. For a recurring problem, there can be many solutions. Only the best solution that has been proven across multiple projects is called a Design Pattern. The worst solution is called an Anti Pattern.
2. The Problem
Creating multiple objects for a Java class in the following situations is a waste of memory and CPU time:
- If the class has no member variables/attributes/properties (no state)
- If the class has read-only state (only final variables)
- If the class has sharable state (like cache or buffer)
In Java, object creation is a costly process in terms of memory and CPU utilization. Every new call allocates heap memory, runs the constructor, and initializes fields. If a class needs only one instance, creating more wastes resources.
Real-world analogy
Think of all employees in an office sharing a single printer. Every employee does not need their own printer. One printer serves everyone. If we design software for this office, the Printer class should be a singleton -- creating 100 Printer objects for 100 employees is wasteful when one object can serve all.
Three cases where singleton is needed
Case 1: Class with no state
A class that has no member variables, only methods. Creating multiple objects is pointless because every object behaves identically.
// No state -- all objects are identical
public class MathUtils {
private MathUtils() {}
private static MathUtils INSTANCE;
public static MathUtils getInstance() {
if (INSTANCE == null) INSTANCE = new MathUtils();
return INSTANCE;
}
public int add(int a, int b) { return a + b; }
public int multiply(int a, int b) { return a * b; }
}
// Can call methods for multiple times
// No need to create multiple objects
MathUtils.getInstance().add(10, 20);
MathUtils.getInstance().multiply(5, 6);
Case 2: Class with read-only state (final variables)
A class whose state is set once and never changes. Multiple objects all hold the same data.
// Read-only state -- all objects have same data
public class AppConfig {
private static final String DB_URL = "jdbc:mysql://localhost:3306/bankdb";
private static final int MAX_CONNECTIONS = 10;
private static AppConfig INSTANCE;
private AppConfig() {
if (INSTANCE != null)
throw new RuntimeException("Object already created");
}
public static AppConfig getInstance() {
if (INSTANCE == null)
INSTANCE = new AppConfig();
return INSTANCE;
}
public String getDbUrl() { return DB_URL; }
public int getMaxConnections() { return MAX_CONNECTIONS; }
}
Case 3: Class with sharable state (cache, buffer, pool)
A class that holds shared mutable state. Multiple objects would mean duplicate caches or connection pools, leading to inconsistency and wasted memory.
// Sharable state -- one connection pool serves all
public class ConnectionPool {
private static ConnectionPool INSTANCE;
private final List<Connection> pool = new ArrayList<>();
private static final int POOL_SIZE = 10;
private ConnectionPool() {
for (int i = 0; i < POOL_SIZE; i++) {
pool.add(createConnection());
}
}
public static ConnectionPool getInstance() {
if (INSTANCE == null)
INSTANCE = new ConnectionPool();
return INSTANCE;
}
public synchronized Connection getConnection() {
if (pool.isEmpty()) throw new RuntimeException("Pool exhausted");
return pool.remove(0);
}
public synchronized void releaseConnection(Connection conn) {
pool.add(conn);
}
private Connection createConnection() {
// create actual DB connection
return DriverManager.getConnection(
"jdbc:mysql://localhost:3306/bankdb", "root", "password");
}
}
3. The Solution: Three Minimum Standards
A singleton class enforces single-instance creation through three rules:
- Private constructor (infact ALL constructors) -- prevents outsiders from using
newoperator to create objects - Private static reference variable of class type -- holds the single instance. If null, object not yet created. If not null, object already exists.
- Public static factory method with singleton logic -- checks the reference variable, creates object only if null, returns existing object otherwise
Basic Singleton: Printer.java
package com.nt.sdp;
public class Printer {
// Step 2: private static ref variable of class type
private static Printer INSTANCE;
// Step 1: private 0-param constructor
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
// Step 3: static factory method having singleton logic
public static Printer getInstance() {
if (INSTANCE == null) {
INSTANCE = new Printer();
}
return INSTANCE;
}
// business method
public void printMessage(String msg) {
System.out.println(msg);
}
}
Client App: SingletonClassTest1.java
package com.nt.test;
import com.nt.sdp.Printer;
public class SingletonClassTest1 {
public static void main(String[] args) {
// Printer p1 = new Printer(); // INVALID - constructor is private
// Get singleton object
Printer p1 = Printer.getInstance();
Printer p2 = Printer.getInstance();
System.out.println("hash codes: " + p1.hashCode() + " " + p2.hashCode());
System.out.println("p1 == p2? " + (p1 == p2));
}
}
The constructor is called only once. Both p1 and p2 point to the same object (same hashCode, p1 == p2 is true).
We cannot always go by constructor execution to confirm whether an object is created. There are exceptional cases:
- If you create an object for a subclass of an abstract class, the abstract class constructor will execute along with the subclass constructor. But that does not mean an object is created for the abstract class.
- In Java, we cannot create objects for interfaces and abstract classes.
- If the object is created using Cloning or Deserialization, the constructor will NOT be executed because the object is already initialized with data from the source.
4. Pre-defined Singleton Classes in Java
| Class | Why Singleton | How to Get Instance |
|---|---|---|
java.lang.Runtime | One app contains only one runtime. The Runtime class manages the running JVM. | Runtime.getRuntime() |
java.awt.Desktop | One computer contains one desktop. The Desktop class provides access to default browser, email client, etc. | Desktop.getDesktop() |
org.apache.log4j.Logger | All logging activities of an app should be handled by a single Logger object to ensure consistent log output. | Logger.getLogger(Class) |
If a Java class allows creating only one object, but someone still creates one, that class is NOT called singleton. A class is singleton only when it forces/restricts object creation to one. Example: the Test class below is NOT singleton even though you only create one object:
public class Test {
Test t = new Test(); // Creates only one, but nothing stops creating more
// ...
// "Test" is NOT a singleton Java class
}
Servlet component class is not a singleton -- ServletContainer is happy to create one object of Servlet, but it does not force the restriction. If you can bypass the container, you could create more objects.
5. Multithreading Problem
The basic lazy singleton is NOT thread-safe. If multiple threads of the same Client App or different Client Apps call Printer.getInstance() simultaneously with improper thread switching while executing singleton logic, there is a possibility of creating multiple objects for the singleton class.
This is a serious problem when using singleton as a helper class for Servlet/JSP in a web application, which is a multithreaded environment where multiple threads will be created for multiple requests.
Thread switching scenario
Web application scenario (BookMyShowApp)
Thread-unsafe demo code
// TicketBookingOperation.java
package com.nt.test;
import com.nt.sdp.Printer;
public class TicketBookingOperation implements Runnable {
@Override
public void run() {
System.out.println("TicketBookingOperation.run()");
Printer p = Printer.getInstance();
System.out.println(p.hashCode());
}
}
// SingletonClassTest_MultiThreaded.java
public class SingletonClassTest_MultiThreaded {
public static void main(String[] args) {
// create Threads with data
TicketBookingOperation operation = new TicketBookingOperation();
Thread th1 = new Thread(operation);
Thread th2 = new Thread(operation);
Thread th3 = new Thread(operation);
// start threads
th1.start();
th2.start();
th3.start();
}
}
Three different hashCodes = three different objects. The singleton is broken in a multithreaded environment.
6. Six Thread-Safe Solutions
a) Synchronized static factory method
// In Printer.java
public synchronized static Printer getInstance() {
// singleton logic
if (INSTANCE == null) {
try {
Thread.sleep(10000); // simulate slow creation
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
return INSTANCE;
}
Since the synchronized method allows only one thread at a time inside the method, there is no problem of multithreading breaking singleton. However, every call acquires the lock, even after the object exists. This is slow.
Better: Place synchronized(){} block inside the method where the sensitive logic is, instead of making the whole method synchronized.
b) Synchronized block inside static factory method
public static Printer getInstance() {
// singleton logic
synchronized (Printer.class) { // class level locking
if (INSTANCE == null) {
try {
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
}
return INSTANCE;
}
We cannot use this inside a static method, so we cannot pass synchronized(this). To protect static properties of a class, we use class level locking by passing the class name in the form of java.lang.Class object: synchronized(Printer.class).
In Java, .class is a compiler-generated pre-defined public static property of every Java class, returning the object of java.lang.Class having the current class name and info.
Conclusion: If we want to pass a Java class in the app as input value, we can use ClassName.class or the object of java.lang.Class.
c) Double-checked locking (Best 1)
public class Printer {
private static volatile Printer INSTANCE;
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
if (INSTANCE == null) { // 1st NULL check (no lock)
System.out.println("Printer.getInstance()[outside sync block]: "
+ Thread.currentThread().getName());
synchronized (Printer.class) { // class level locking
System.out.println("Printer.getInstance()[inside sync block]: "
+ Thread.currentThread().getName());
if (INSTANCE == null) { // 2nd NULL check (with lock)
try {
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
}
INSTANCE = new Printer();
}
}
}
return INSTANCE;
}
}
The first null check avoids the synchronized block entirely once the object is created. Only when INSTANCE is null do threads enter the synchronized block, and the second null check inside prevents duplicate creation. The volatile keyword ensures the INSTANCE field is visible across all threads immediately.
This is the most commonly used approach in production code.
d) Eager instantiation
public class Printer {
// object created at class loading time itself (eager)
private static final Printer INSTANCE = new Printer();
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
return INSTANCE; // no checking needed
}
}
Since the object is the static reference variable, the JVM creates it at class loading time. The object is created as soon as the class is loaded, even if it is never used. Thread-safe because class loading is inherently thread-safe in JVM.
Drawback: Creates the object even if never needed (no lazy loading).
e) Inner class based lazy instantiation (Bill Pugh - Best 2)
public class Printer {
private Printer() {
System.out.println("Printer:: 0-param constructor");
}
// Inner class -- loaded only when getInstance() is called
private static class InstanceHolder {
private static final Printer INSTANCE = new Printer();
}
public static Printer getInstance() {
return InstanceHolder.INSTANCE;
}
}
The key: the inner class InstanceHolder is NOT loaded when the outer class Printer is loaded. It is loaded only when getInstance() is first called. The JVM guarantees that class loading is thread-safe. No explicit synchronization needed.
This is the Bill Pugh Singleton -- it combines lazy initialization with thread safety and zero synchronization overhead.
f) ENUM based singleton (Best 3)
package com.nt.sdp;
public enum Printer3 {
INSTANCE; // enum constant -- internally: public static final Printer3 INSTANCE = new Printer3()
public static Printer3 getInstance() {
return INSTANCE;
}
// business method
public void printMessage(String msg) {
System.out.println("printing: " + msg);
}
}
// Usage:
Printer3.INSTANCE.printMessage("Report #1234");
Why is Enum singleton the best?
- No multithreading issues: Eager instantiation happens on enum constant like INSTANCE. There will not be any multi-threading issue.
- Easy to develop: Writing less code with minimum coding standards.
- Clone-safe: The
clone()method is marked as protected final in the internal default super classjava.lang.Enum<E>. So we cannot overrideclone()in our enum.clone()cannot be called on enum constants. Every enum already extendsjava.lang.Enum<E>class internally. It cannot be extended further (Java does not support multiple inheritance). So our enum is NOTCloneableby default. - Serialization-safe: Every enum is Serializable by default. If we perform Serialization on enum constants, when we attempt to do Deserialization, we get already available enum obj/constant (i.e., some internal
readResolve()method functionality is given). - Reflection-safe: For enums, we cannot create objects using Reflection API as they do not contain constructors.
Enum inheritance hierarchy
Enum singleton limitations
- a) We cannot apply few features of Java on enum like inheritance using "extends"
- b) We cannot place instance, static member variables in the enum representing the state of the object
- c) We cannot place
serialVersionUIDwhile working with Enum to perform serialization activity - d) Eager instantiation based singleton is not ideal if the object is never used
7. Breaking Singleton (and How to Prevent)
a) Breaking via Cloning
If a singleton class implements Cloneable (directly or through inheritance), calling clone() creates a second object.
Prevention: Override clone()
// Option 1: Return existing instance (not recommended)
@Override
public Object clone() throws CloneNotSupportedException {
return INSTANCE;
}
// Option 2: Throw exception (BEST solution)
@Override
public Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException(
"In this singleton class, cloning is restricted");
}
b) Breaking via Serialization and Deserialization
The process of converting a Java object's data into a stream of bits and bytes is called Serialization. These bits/bytes can be written to a file or sent over a network. Serialization is possible only on Serializable objects (class must implement java.io.Serializable).
If we perform Serialization on the singleton object and write its data to a file, we can then create as many objects as we want by reading from that file through Deserialization.
Prevention: readResolve() method
// Add this method in your singleton class
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
Full Serialization test code
package com.nt.test;
import java.io.*;
import com.nt.sdp.Printer;
public class SingletonSerializationTest {
public static void doSerialization(Printer printer) {
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("data.ser"))) {
oos.writeObject(printer);
oos.flush();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("Object Serialization is done");
}
public static Printer doDeSerialization() {
Printer p = null;
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("data.ser"))) {
p = (Printer) ois.readObject();
} catch (Exception e) {
e.printStackTrace();
}
return p;
}
public static void main(String[] args) {
Printer p1 = Printer.getInstance();
doSerialization(p1);
System.out.println("--------DeSerialization--------");
Printer p2 = doDeSerialization();
Printer p3 = doDeSerialization();
System.out.println(p1.hashCode() + " " + p2.hashCode() + " " + p3.hashCode());
}
}
We never make a singleton class implementing Serializable directly, but it may implement indirectly because classes in the inheritance hierarchy may implement Serializable. In that case, Serialization and Deserialization on the singleton object is possible.
It is recommended to add serialVersionUID to every Serializable class so we will not get InvalidClassException even though the class structure is changed at the time of DeSerialization:
private static final long serialVersionUID = 1L;
If serialVersionUID is not placed, the JVM generates a dynamic one based on the structure of the class. If the structure is modified during DeSerialization, we get:
java.io.InvalidClassException: com.nt.sdp.Printer;
local class incompatible: stream classdesc
serialVersionUID = 1104536345855,
local class serialVersionUID = 8110690610001600652
c) Breaking via Reflection API
Even if the constructor is private, the Reflection API can access it, set it accessible, and create multiple objects. This breaks the singleton guarantee.
package com.nt.test;
import java.lang.reflect.Constructor;
public class SingletonReflectionTest {
public static void main(String[] args) throws Exception {
// Normal way -- gives single object
Printer p1 = Printer.getInstance();
// Reflection API way -- breaks singleton
Class c = Class.forName("com.nt.sdp.Printer");
Constructor[] cons = c.getDeclaredConstructors();
cons[0].setAccessible(true); // bypass private access
Printer p2 = (Printer) cons[0].newInstance();
System.out.println(p1.hashCode() + " " + p2.hashCode());
// Output: DIFFERENT hashCodes -- singleton is broken!
System.out.println("p1 == p2? " + (p1 == p2)); // false
}
}
Prevention: isInstantiated flag
public class Printer {
private static Printer INSTANCE;
private static boolean isInstantiated = false;
private Printer() {
// Stop reflection-based second object creation
if (isInstantiated) {
throw new RuntimeException(
"Object is already created. Cannot create another.");
}
isInstantiated = true;
System.out.println("Printer:: 0-param constructor");
}
public static Printer getInstance() {
if (INSTANCE == null)
INSTANCE = new Printer();
return INSTANCE;
}
}
8. Breaking Singleton via ClassLoader
JVM internally uses a ClassLoader subsystem to load Java classes. The system has three classloaders:
Notes on ClassLoader behavior
- If a class is loaded by parent classloader, the same class will NOT be loaded by child classloader (Principle of Delegation)
- The class loaded by parent classloader is visible to child classloader, but not the reverse (Principle of Visibility)
- When JVM gives a class to classloader subsystem for loading, it is delegated to all classloaders using the delegation principle
Breaking singleton using Custom ClassLoader
URLClassLoader is a readily available custom classloader which can load classes from specific jar files and also initialize them as needed.
// Using Application ClassLoader (default)
System.out.println("Using Application ClassLoader");
Printer p1 = Printer.getInstance();
Printer p2 = Printer.getInstance();
System.out.println(p1.hashCode() + " " + p2.hashCode());
System.out.println("---");
// Using Custom ClassLoader (URLClassLoader)
System.out.println("Using URLClassLoader");
URLClassLoader loader = new URLClassLoader(
new URL[]{new URL("file:///c/myProj/jar5/1.jar")});
Class c1 = loader.loadClass("com.nt.sdp.Printer");
Constructor cons = c1.getDeclaredConstructors()[0];
cons.setAccessible(true);
Object obj = cons.newInstance();
System.out.println("object hash codes: " + obj.hashCode() + " " + p1.hashCode());
Here URLClassLoader is an independent classloader and not linked with JVM's classloader system. So one object is created using ApplicationClassLoader of ClassLoader sub system and another object will be using URLClassLoader (custom classloader).
There is no solution for this problem from the singleton Java class developer perspective. Because URLClassLoader can break any singleton Java class. The only practical advice is: do not allow untrusted jar files to create CustomClassLoader or use SecurityManager to restrict ClassLoader creation.
Fortunately, this is a rare scenario in production. It only occurs when someone deliberately creates a custom classloader.
9. Singleton Pattern vs Spring Singleton Scope
| Aspect | Singleton Design Pattern | Spring Singleton Scope |
|---|---|---|
| Scope | One object per JVM (per classloader) | One object per Spring IoC container |
| Who controls | The class itself (private constructor) | The Spring container (bean lifecycle) |
| Multiple containers | Not possible. One JVM = one instance | Each container has its own instance |
| Thread safety | Developer's responsibility | Container handles lifecycle, developer handles state |
| Implementation | Private constructor + static factory method | @Scope("singleton") or default scope |
Spring IoC container creating one object for servlet comp class does not mean Spring is making classes as singletons. Spring is just having singleton behavior (one object for singleton scope spring bean), but the class is not a true singleton because there is no private constructor restriction. If you bypass the container, you could create more objects.
10. Full-Length Industry Project: BookMyShow Ticket Booking System
BookMyShow is an online ticket booking platform. Thousands of users access the system simultaneously from browsers and mobile apps. The system needs:
- A single database connection pool shared across all request threads (creating a pool per request would exhaust DB connections)
- A single application logger so all services write to the same log file in order
- A single configuration manager to load settings once and serve all services
- A single cache manager to cache movie and show data, avoiding repeated DB calls
Each of these is a Singleton. The project demonstrates four different singleton implementations working together in a real layered architecture.
High-Level Design (HLD)
Low-Level Design (LLD)
Project Structure
application.properties
# BookMyShow Configuration
app.name=BookMyShow
app.version=1.0.0
# Database
db.url=jdbc:mysql://localhost:3306/bookmyshow_db
db.username=bms_admin
db.password=encrypted_pwd
db.pool.size=20
# Cache
cache.default.ttl.seconds=300
cache.movies.ttl.seconds=600
# Booking
booking.max.seats.per.transaction=10
booking.payment.timeout.seconds=120
# Logging
log.file=bookmyshow.log
log.level=INFO
Connection.java (Simulated DB connection)
package com.bookmyshow.model;
import java.util.UUID;
public class Connection {
private final String id;
private final String url;
private boolean inUse;
public Connection(String url) {
this.id = UUID.randomUUID().toString().substring(0, 8);
this.url = url;
this.inUse = false;
}
public String getId() { return id; }
public String getUrl() { return url; }
public boolean isInUse() { return inUse; }
public void setInUse(boolean inUse) { this.inUse = inUse; }
@Override
public String toString() {
return "Connection[" + id + "]";
}
}
Movie.java
package com.bookmyshow.model;
public class Movie {
private final String id;
private final String title;
private final String genre;
private final int durationMinutes;
public Movie(String id, String title, String genre, int durationMinutes) {
this.id = id;
this.title = title;
this.genre = genre;
this.durationMinutes = durationMinutes;
}
public String getId() { return id; }
public String getTitle() { return title; }
public String getGenre() { return genre; }
public int getDurationMinutes() { return durationMinutes; }
@Override
public String toString() {
return title + " (" + genre + ", " + durationMinutes + " min)";
}
}
Show.java
package com.bookmyshow.model;
public class Show {
private final String id;
private final String movieId;
private final String theatre;
private final String time;
private int availableSeats;
private final double price;
public Show(String id, String movieId, String theatre,
String time, int availableSeats, double price) {
this.id = id;
this.movieId = movieId;
this.theatre = theatre;
this.time = time;
this.availableSeats = availableSeats;
this.price = price;
}
public String getId() { return id; }
public String getMovieId() { return movieId; }
public String getTheatre() { return theatre; }
public String getTime() { return time; }
public int getAvailableSeats() { return availableSeats; }
public double getPrice() { return price; }
public synchronized boolean reserveSeats(int count) {
if (count > availableSeats) return false;
availableSeats -= count;
return true;
}
@Override
public String toString() {
return theatre + " | " + time + " | Seats: " + availableSeats
+ " | Rs " + price;
}
}
Booking.java
package com.bookmyshow.model;
import java.time.LocalDateTime;
import java.util.UUID;
public class Booking {
private final String id;
private final String userId;
private final String showId;
private final int seats;
private final double totalAmount;
private final LocalDateTime bookedAt;
private String status;
public Booking(String userId, String showId, int seats, double totalAmount) {
this.id = "BKG-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
this.userId = userId;
this.showId = showId;
this.seats = seats;
this.totalAmount = totalAmount;
this.bookedAt = LocalDateTime.now();
this.status = "CONFIRMED";
}
public String getId() { return id; }
public String getUserId() { return userId; }
public String getShowId() { return showId; }
public int getSeats() { return seats; }
public double getTotalAmount() { return totalAmount; }
public LocalDateTime getBookedAt() { return bookedAt; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
@Override
public String toString() {
return "Booking{id=" + id + ", user=" + userId + ", show=" + showId
+ ", seats=" + seats + ", amount=Rs " + totalAmount
+ ", status=" + status + "}";
}
}
ConfigManager.java (Enum Singleton)
package com.bookmyshow.singleton;
import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;
/**
* Application configuration manager.
* Enum-based singleton: thread-safe, reflection-safe, serialization-safe.
*/
public enum ConfigManager {
INSTANCE;
private final Properties properties;
ConfigManager() {
this.properties = new Properties();
try (InputStream stream = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
if (stream != null) {
properties.load(stream);
}
} catch (IOException e) {
throw new RuntimeException("Failed to load configuration", e);
}
}
public String get(String key) {
return properties.getProperty(key);
}
public String get(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
public int getInt(String key, int defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public boolean getBoolean(String key, boolean defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
return Boolean.parseBoolean(value.trim());
}
}
AppLogger.java (Bill Pugh Singleton)
package com.bookmyshow.singleton;
import java.io.FileWriter;
import java.io.IOException;
import java.io.PrintWriter;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* Application logger.
* Bill Pugh (inner class) singleton: lazy loaded, thread-safe, no synchronization overhead.
*/
public class AppLogger {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
private final PrintWriter fileWriter;
private AppLogger() {
String logFile = ConfigManager.INSTANCE.get("log.file", "app.log");
try {
this.fileWriter = new PrintWriter(
new FileWriter(logFile, true), true);
} catch (IOException e) {
throw new RuntimeException("Cannot open log file: " + logFile, e);
}
}
// Inner class loaded only when getInstance() is first called
private static class InstanceHolder {
private static final AppLogger INSTANCE = new AppLogger();
}
public static AppLogger getInstance() {
return InstanceHolder.INSTANCE;
}
public void info(String message) {
log("INFO", message);
}
public void warn(String message) {
log("WARN", message);
}
public void error(String message) {
log("ERROR", message);
}
public void error(String message, Throwable throwable) {
log("ERROR", message + " | " + throwable.getMessage());
}
private void log(String level, String message) {
String timestamp = LocalDateTime.now().format(FORMATTER);
String threadName = Thread.currentThread().getName();
String entry = String.format("[%s] [%-5s] [%s] %s",
timestamp, level, threadName, message);
System.out.println(entry);
fileWriter.println(entry);
}
}
ConnectionPool.java (Double-Checked Locking Singleton)
package com.bookmyshow.singleton;
import com.bookmyshow.model.Connection;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Set;
/**
* Database connection pool.
* Double-checked locking singleton: lazy, thread-safe, minimal lock contention.
*/
public class ConnectionPool {
private static volatile ConnectionPool INSTANCE;
private final Queue<Connection> availableConnections;
private final Set<Connection> usedConnections;
private final int maxPoolSize;
private ConnectionPool(int poolSize, String dbUrl) {
this.maxPoolSize = poolSize;
this.availableConnections = new LinkedList<>();
this.usedConnections = new HashSet<>();
for (int i = 0; i < poolSize; i++) {
availableConnections.add(new Connection(dbUrl));
}
AppLogger.getInstance().info(
"ConnectionPool initialized with " + poolSize + " connections");
}
public static ConnectionPool getInstance() {
if (INSTANCE == null) {
synchronized (ConnectionPool.class) {
if (INSTANCE == null) {
String dbUrl = ConfigManager.INSTANCE.get("db.url");
int poolSize = ConfigManager.INSTANCE.getInt("db.pool.size", 10);
INSTANCE = new ConnectionPool(poolSize, dbUrl);
}
}
}
return INSTANCE;
}
public synchronized Connection getConnection() {
if (availableConnections.isEmpty()) {
AppLogger.getInstance().warn(
"Connection pool exhausted. Used: " + usedConnections.size()
+ "/" + maxPoolSize);
throw new RuntimeException("No available connections in pool");
}
Connection conn = availableConnections.poll();
conn.setInUse(true);
usedConnections.add(conn);
return conn;
}
public synchronized void releaseConnection(Connection conn) {
if (conn == null) return;
conn.setInUse(false);
usedConnections.remove(conn);
availableConnections.offer(conn);
}
public int getAvailableCount() {
return availableConnections.size();
}
public int getUsedCount() {
return usedConnections.size();
}
}
CacheManager.java (Inner Class Singleton with TTL)
package com.bookmyshow.singleton;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory cache with time-to-live (TTL) expiry.
* Inner class singleton: lazy loaded, thread-safe.
* Uses ConcurrentHashMap for thread-safe read/write.
*/
public class CacheManager {
private final Map<String, CacheEntry> cache;
private final long defaultTtlMs;
private static class CacheEntry {
final Object value;
final long expiresAt;
CacheEntry(Object value, long ttlMs) {
this.value = value;
this.expiresAt = System.currentTimeMillis() + ttlMs;
}
boolean isExpired() {
return System.currentTimeMillis() > expiresAt;
}
}
private CacheManager() {
this.cache = new ConcurrentHashMap<>();
int ttlSeconds = ConfigManager.INSTANCE.getInt(
"cache.default.ttl.seconds", 300);
this.defaultTtlMs = ttlSeconds * 1000L;
AppLogger.getInstance().info(
"CacheManager initialized with default TTL: " + ttlSeconds + "s");
}
private static class InstanceHolder {
private static final CacheManager INSTANCE = new CacheManager();
}
public static CacheManager getInstance() {
return InstanceHolder.INSTANCE;
}
public void put(String key, Object value) {
put(key, value, defaultTtlMs);
}
public void put(String key, Object value, long ttlMs) {
cache.put(key, new CacheEntry(value, ttlMs));
}
@SuppressWarnings("unchecked")
public <T> T get(String key) {
CacheEntry entry = cache.get(key);
if (entry == null) return null;
if (entry.isExpired()) {
cache.remove(key);
return null;
}
return (T) entry.value;
}
public void remove(String key) {
cache.remove(key);
}
public void clear() {
cache.clear();
}
public int size() {
// Remove expired entries and return count
cache.entrySet().removeIf(e -> e.getValue().isExpired());
return cache.size();
}
}
MovieService.java
package com.bookmyshow.service;
import com.bookmyshow.model.Connection;
import com.bookmyshow.model.Movie;
import com.bookmyshow.model.Show;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.Arrays;
import java.util.List;
public class MovieService {
private final AppLogger logger = AppLogger.getInstance();
private final CacheManager cache = CacheManager.getInstance();
private final ConnectionPool pool = ConnectionPool.getInstance();
public List<Movie> getAllMovies() {
// Check cache first
List<Movie> cached = cache.get("all_movies");
if (cached != null) {
logger.info("Movies loaded from cache");
return cached;
}
// Cache miss: fetch from DB
Connection conn = pool.getConnection();
try {
logger.info("Fetching movies from DB using " + conn);
// Simulated DB query
List<Movie> movies = Arrays.asList(
new Movie("MOV001", "Pathaan", "Action", 146),
new Movie("MOV002", "Jawan", "Action", 169),
new Movie("MOV003", "Animal", "Drama", 201),
new Movie("MOV004", "12th Fail", "Drama", 147),
new Movie("MOV005", "Dunki", "Comedy-Drama", 161)
);
// Store in cache
cache.put("all_movies", movies);
logger.info("Movies cached. Count: " + movies.size());
return movies;
} finally {
pool.releaseConnection(conn);
}
}
public Movie getMovieById(String movieId) {
Movie cached = cache.get("movie_" + movieId);
if (cached != null) return cached;
List<Movie> movies = getAllMovies();
for (Movie m : movies) {
if (m.getId().equals(movieId)) {
cache.put("movie_" + movieId, m);
return m;
}
}
return null;
}
public List<Show> getShowsForMovie(String movieId) {
List<Show> cached = cache.get("shows_" + movieId);
if (cached != null) {
logger.info("Shows for " + movieId + " loaded from cache");
return cached;
}
Connection conn = pool.getConnection();
try {
logger.info("Fetching shows for " + movieId + " from DB using " + conn);
// Simulated DB query
List<Show> shows = Arrays.asList(
new Show("SHW001", movieId, "PVR Phoenix Mall", "10:00 AM", 150, 250.0),
new Show("SHW002", movieId, "PVR Phoenix Mall", "02:30 PM", 150, 300.0),
new Show("SHW003", movieId, "INOX Lido", "06:00 PM", 200, 350.0),
new Show("SHW004", movieId, "INOX Lido", "09:30 PM", 200, 400.0)
);
cache.put("shows_" + movieId, shows);
return shows;
} finally {
pool.releaseConnection(conn);
}
}
}
BookingService.java
package com.bookmyshow.service;
import com.bookmyshow.model.Booking;
import com.bookmyshow.model.Connection;
import com.bookmyshow.model.Show;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConfigManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.List;
public class BookingService {
private final AppLogger logger = AppLogger.getInstance();
private final ConnectionPool pool = ConnectionPool.getInstance();
private final CacheManager cache = CacheManager.getInstance();
public Booking bookTicket(String userId, String showId, int seatCount) {
int maxSeats = ConfigManager.INSTANCE.getInt(
"booking.max.seats.per.transaction", 10);
if (seatCount <= 0 || seatCount > maxSeats) {
logger.error("Invalid seat count: " + seatCount
+ ". Max allowed: " + maxSeats);
throw new IllegalArgumentException(
"Seat count must be between 1 and " + maxSeats);
}
Connection conn = pool.getConnection();
try {
logger.info("Booking " + seatCount + " seats for user "
+ userId + " on show " + showId);
// Find the show and reserve seats
Show show = findShow(showId);
if (show == null) {
logger.error("Show not found: " + showId);
throw new RuntimeException("Show not found: " + showId);
}
boolean reserved = show.reserveSeats(seatCount);
if (!reserved) {
logger.warn("Not enough seats for show " + showId
+ ". Requested: " + seatCount
+ ", Available: " + show.getAvailableSeats());
throw new RuntimeException(
"Not enough seats. Available: " + show.getAvailableSeats());
}
double totalAmount = show.getPrice() * seatCount;
Booking booking = new Booking(userId, showId, seatCount, totalAmount);
// Invalidate show cache since seats changed
cache.remove("shows_" + show.getMovieId());
logger.info("Booking confirmed: " + booking.getId()
+ " | Amount: Rs " + totalAmount);
return booking;
} finally {
pool.releaseConnection(conn);
}
}
public boolean cancelBooking(Booking booking) {
logger.info("Cancelling booking: " + booking.getId());
booking.setStatus("CANCELLED");
logger.info("Booking cancelled: " + booking.getId());
return true;
}
private Show findShow(String showId) {
// Search across all cached shows
MovieService movieService = new MovieService();
for (var movie : movieService.getAllMovies()) {
List<Show> shows = movieService.getShowsForMovie(movie.getId());
for (Show s : shows) {
if (s.getId().equals(showId)) return s;
}
}
return null;
}
}
Application.java (Main Entry Point)
package com.bookmyshow;
import com.bookmyshow.model.Booking;
import com.bookmyshow.model.Movie;
import com.bookmyshow.model.Show;
import com.bookmyshow.service.BookingService;
import com.bookmyshow.service.MovieService;
import com.bookmyshow.singleton.AppLogger;
import com.bookmyshow.singleton.CacheManager;
import com.bookmyshow.singleton.ConfigManager;
import com.bookmyshow.singleton.ConnectionPool;
import java.util.List;
public class Application {
public static void main(String[] args) throws InterruptedException {
AppLogger logger = AppLogger.getInstance();
logger.info("=== BookMyShow Application Started ===");
logger.info("App: " + ConfigManager.INSTANCE.get("app.name")
+ " v" + ConfigManager.INSTANCE.get("app.version"));
// ---- 1. VERIFY ALL FOUR SINGLETONS ----
System.out.println("\n--- Singleton Verification ---");
ConfigManager c1 = ConfigManager.INSTANCE;
ConfigManager c2 = ConfigManager.INSTANCE;
System.out.println("ConfigManager same? " + (c1 == c2)
+ " | hashCode: " + c1.hashCode());
AppLogger l1 = AppLogger.getInstance();
AppLogger l2 = AppLogger.getInstance();
System.out.println("AppLogger same? " + (l1 == l2)
+ " | hashCode: " + l1.hashCode());
ConnectionPool p1 = ConnectionPool.getInstance();
ConnectionPool p2 = ConnectionPool.getInstance();
System.out.println("ConnectionPool same? " + (p1 == p2)
+ " | hashCode: " + p1.hashCode());
CacheManager m1 = CacheManager.getInstance();
CacheManager m2 = CacheManager.getInstance();
System.out.println("CacheManager same? " + (m1 == m2)
+ " | hashCode: " + m1.hashCode());
// ---- 2. BROWSE MOVIES ----
System.out.println("\n--- Available Movies ---");
MovieService movieService = new MovieService();
List<Movie> movies = movieService.getAllMovies();
for (Movie m : movies) {
System.out.println(" " + m);
}
// Call again -- should come from cache
System.out.println("\n--- Movies (2nd call - from cache) ---");
movieService.getAllMovies();
// ---- 3. BROWSE SHOWS ----
System.out.println("\n--- Shows for Pathaan ---");
List<Show> shows = movieService.getShowsForMovie("MOV001");
for (Show s : shows) {
System.out.println(" " + s);
}
// ---- 4. BOOK TICKETS ----
System.out.println("\n--- Booking Tickets ---");
BookingService bookingService = new BookingService();
Booking booking1 = bookingService.bookTicket("USER001", "SHW003", 4);
System.out.println(" " + booking1);
Booking booking2 = bookingService.bookTicket("USER002", "SHW003", 2);
System.out.println(" " + booking2);
// ---- 5. CANCEL BOOKING ----
System.out.println("\n--- Cancel Booking ---");
bookingService.cancelBooking(booking1);
System.out.println(" " + booking1);
// ---- 6. CONNECTION POOL STATUS ----
System.out.println("\n--- Connection Pool Status ---");
System.out.println(" Available: " + p1.getAvailableCount());
System.out.println(" In use: " + p1.getUsedCount());
// ---- 7. CACHE STATUS ----
System.out.println("\n--- Cache Status ---");
System.out.println(" Cached items: " + m1.size());
// ---- 8. MULTITHREADED BOOKING TEST ----
System.out.println("\n--- Multithreaded Booking Test (5 users) ---");
Thread[] threads = new Thread[5];
for (int i = 0; i < 5; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
try {
BookingService svc = new BookingService();
Booking b = svc.bookTicket("USER_MT_" + idx, "SHW004", 2);
System.out.println(" Thread-" + idx + ": " + b.getId()
+ " | Seats: " + b.getSeats()
+ " | Amount: Rs " + b.getTotalAmount());
} catch (Exception e) {
System.out.println(" Thread-" + idx + ": FAILED - "
+ e.getMessage());
}
}, "BookingThread-" + i);
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
System.out.println("\n--- Final Pool Status ---");
System.out.println(" Available: " + p1.getAvailableCount());
System.out.println(" In use: " + p1.getUsedCount());
logger.info("=== BookMyShow Application Finished ===");
}
}
SingletonThreadSafetyTest.java
package com.bookmyshow.singleton;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class SingletonThreadSafetyTest {
@Test
void configManager_isSingleton() {
assertSame(ConfigManager.INSTANCE, ConfigManager.INSTANCE);
}
@Test
void appLogger_isSingleton() {
assertSame(AppLogger.getInstance(), AppLogger.getInstance());
}
@Test
void connectionPool_isSingleton() {
assertSame(ConnectionPool.getInstance(), ConnectionPool.getInstance());
}
@Test
void cacheManager_isSingleton() {
assertSame(CacheManager.getInstance(), CacheManager.getInstance());
}
@Test
void allSingletons_threadSafe() throws InterruptedException {
int threadCount = 100;
AppLogger[] loggers = new AppLogger[threadCount];
ConnectionPool[] pools = new ConnectionPool[threadCount];
CacheManager[] caches = new CacheManager[threadCount];
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
loggers[idx] = AppLogger.getInstance();
pools[idx] = ConnectionPool.getInstance();
caches[idx] = CacheManager.getInstance();
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (int i = 1; i < threadCount; i++) {
assertSame(loggers[0], loggers[i],
"AppLogger must be same across all threads");
assertSame(pools[0], pools[i],
"ConnectionPool must be same across all threads");
assertSame(caches[0], caches[i],
"CacheManager must be same across all threads");
}
}
@Test
void connectionPool_getAndRelease() {
ConnectionPool pool = ConnectionPool.getInstance();
int before = pool.getAvailableCount();
var conn = pool.getConnection();
assertEquals(before - 1, pool.getAvailableCount());
assertEquals(1, pool.getUsedCount());
pool.releaseConnection(conn);
assertEquals(before, pool.getAvailableCount());
assertEquals(0, pool.getUsedCount());
}
@Test
void cacheManager_putAndGet() {
CacheManager cache = CacheManager.getInstance();
cache.put("test_key", "test_value");
assertEquals("test_value", cache.get("test_key"));
}
@Test
void cacheManager_expiredEntryReturnsNull() throws InterruptedException {
CacheManager cache = CacheManager.getInstance();
cache.put("expire_key", "value", 50); // 50ms TTL
Thread.sleep(100);
assertNull(cache.get("expire_key"));
}
@Test
void configManager_readsProperties() {
assertEquals("BookMyShow", ConfigManager.INSTANCE.get("app.name"));
assertEquals(20, ConfigManager.INSTANCE.getInt("db.pool.size", 5));
}
}
Expected Output
Key observations from the project
- Four singletons, four approaches: ConfigManager (enum), AppLogger (Bill Pugh), ConnectionPool (double-checked locking), CacheManager (inner class). Each chosen for a specific reason.
- All services share the same instances: MovieService and BookingService both call
getInstance()and get the exact same object (verified by hashCode). - Thread safety verified: 5 concurrent booking threads all get the same singleton instances and all bookings succeed without data corruption.
- Connection pool manages lifecycle: Connections are borrowed and returned. Pool never exceeds the configured size.
- Cache reduces DB calls: First movie list call hits DB. Second call returns from cache. No redundant queries.
- Configuration drives behavior: Pool size, cache TTL, max seats all come from properties file. No hardcoded values.
- Logger captures everything: Every operation is logged with timestamp, level, thread name, and message. Written to console and file simultaneously.
11. Smaller Example: Configuration Manager
National Bank of India needs a centralized configuration manager for their banking application. The config manager loads database URLs, API keys, feature flags, and connection pool sizes from a properties file. Every service (Account Service, Transfer Service, Notification Service) must read from the same configuration. If each service creates its own config object, they might read stale or inconsistent data. The config must be loaded once, shared everywhere, and be thread-safe because multiple request threads access it simultaneously.
High-Level Design (HLD)
Low-Level Design (LLD)
Project Structure
application.properties
# Database Configuration
db.url=jdbc:mysql://localhost:3306/national_bank_db
db.username=nbi_admin
db.password=encrypted_pwd_here
db.pool.size=20
db.pool.timeout=30000
# API Configuration
api.base.url=https://api.nationalbankofIndia.com/v1
api.timeout=5000
api.retry.count=3
# Feature Flags
feature.upi.enabled=true
feature.international.transfer.enabled=false
feature.sms.notification.enabled=true
# Notification
notification.sms.gateway=https://sms.gateway.nbi.com
notification.email.from=noreply@nationalbankofIndia.com
ConfigManager.java
package com.nbi.banking.config;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectStreamException;
import java.io.Serializable;
import java.util.Properties;
import java.util.Set;
public class ConfigManager implements Serializable {
private static final long serialVersionUID = 1L;
private static volatile ConfigManager INSTANCE;
private static boolean isInstantiated = false;
private final Properties properties;
private ConfigManager() {
// Reflection protection
if (isInstantiated) {
throw new RuntimeException(
"ConfigManager is a singleton. Use getInstance() instead.");
}
isInstantiated = true;
this.properties = new Properties();
loadProperties();
}
public static ConfigManager getInstance() {
if (INSTANCE == null) {
synchronized (ConfigManager.class) {
if (INSTANCE == null) {
INSTANCE = new ConfigManager();
}
}
}
return INSTANCE;
}
public String get(String key) {
return properties.getProperty(key);
}
public String get(String key, String defaultValue) {
return properties.getProperty(key, defaultValue);
}
public int getInt(String key, int defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
public boolean getBoolean(String key, boolean defaultValue) {
String value = properties.getProperty(key);
if (value == null) return defaultValue;
return Boolean.parseBoolean(value.trim());
}
public Set getAllKeys() {
return properties.stringPropertyNames();
}
public synchronized void reload() {
properties.clear();
loadProperties();
System.out.println("Configuration reloaded successfully");
}
private void loadProperties() {
try (InputStream stream = getClass().getClassLoader()
.getResourceAsStream("application.properties")) {
if (stream == null) {
throw new RuntimeException(
"application.properties not found on classpath");
}
properties.load(stream);
System.out.println("Loaded " + properties.size()
+ " configuration properties");
} catch (IOException e) {
throw new RuntimeException(
"Failed to load configuration", e);
}
}
// Serialization protection
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
// Clone protection
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException(
"ConfigManager is a singleton. Cloning is not allowed.");
}
}
AccountService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
public class AccountService {
private final ConfigManager config = ConfigManager.getInstance();
public void getAccountDetails(String accountNumber) {
String dbUrl = config.get("db.url");
int poolSize = config.getInt("db.pool.size", 10);
System.out.println("Fetching account " + accountNumber
+ " from database: " + dbUrl
+ " (pool size: " + poolSize + ")");
// actual DB query logic here
}
}
TransferService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
import java.math.BigDecimal;
public class TransferService {
private final ConfigManager config = ConfigManager.getInstance();
public boolean transfer(String from, String to, BigDecimal amount) {
boolean upiEnabled = config.getBoolean("feature.upi.enabled", false);
int timeout = config.getInt("api.timeout", 5000);
System.out.println("Transfer " + amount + " from " + from + " to " + to);
System.out.println("UPI enabled: " + upiEnabled + ", timeout: " + timeout + "ms");
// Verify same config instance across services
System.out.println("Config instance hashCode: "
+ config.hashCode());
return true;
}
}
NotificationService.java
package com.nbi.banking.service;
import com.nbi.banking.config.ConfigManager;
public class NotificationService {
private final ConfigManager config = ConfigManager.getInstance();
public void sendSmsNotification(String phone, String message) {
boolean smsEnabled = config.getBoolean(
"feature.sms.notification.enabled", false);
if (!smsEnabled) {
System.out.println("SMS notification is disabled");
return;
}
String gateway = config.get("notification.sms.gateway");
System.out.println("Sending SMS via " + gateway
+ " to " + phone + ": " + message);
// Verify same config instance
System.out.println("Config instance hashCode: "
+ config.hashCode());
}
}
Application.java
package com.nbi.banking;
import com.nbi.banking.config.ConfigManager;
import com.nbi.banking.service.AccountService;
import com.nbi.banking.service.TransferService;
import com.nbi.banking.service.NotificationService;
import java.math.BigDecimal;
public class Application {
public static void main(String[] args) throws InterruptedException {
System.out.println("=== National Bank of India - Banking Application ===");
System.out.println();
// 1. Verify singleton behavior
ConfigManager config1 = ConfigManager.getInstance();
ConfigManager config2 = ConfigManager.getInstance();
System.out.println("Singleton verification:");
System.out.println(" config1 hashCode: " + config1.hashCode());
System.out.println(" config2 hashCode: " + config2.hashCode());
System.out.println(" config1 == config2? " + (config1 == config2));
System.out.println();
// 2. Use services (all share same ConfigManager instance)
AccountService accountService = new AccountService();
TransferService transferService = new TransferService();
NotificationService notificationService = new NotificationService();
accountService.getAccountDetails("SAV2001");
System.out.println();
transferService.transfer("SAV2001", "CUR3001",
new BigDecimal("50000.00"));
System.out.println();
notificationService.sendSmsNotification(
"+91-9876543210",
"Transfer of Rs 50,000 from SAV2001 to CUR3001 completed");
System.out.println();
// 3. Multithreaded access verification
System.out.println("=== Multithreaded Access Test ===");
Thread[] threads = new Thread[10];
int[] hashCodes = new int[10];
for (int i = 0; i < 10; i++) {
final int idx = i;
threads[i] = new Thread(() -> {
ConfigManager cm = ConfigManager.getInstance();
hashCodes[idx] = cm.hashCode();
System.out.println("Thread-" + idx
+ " config hashCode: " + cm.hashCode());
});
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
// Verify all threads got the same instance
boolean allSame = true;
for (int i = 1; i < 10; i++) {
if (hashCodes[i] != hashCodes[0]) {
allSame = false;
break;
}
}
System.out.println("All threads got same instance? " + allSame);
System.out.println();
// 4. Read all configuration keys
System.out.println("=== All Configuration Keys ===");
for (String key : config1.getAllKeys()) {
System.out.println(" " + key + " = " + config1.get(key));
}
}
}
ConfigManagerTest.java
package com.nbi.banking.config;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class ConfigManagerTest {
@Test
void getInstance_returnsSameInstance() {
ConfigManager c1 = ConfigManager.getInstance();
ConfigManager c2 = ConfigManager.getInstance();
assertSame(c1, c2);
}
@Test
void getInstance_isThreadSafe() throws InterruptedException {
ConfigManager[] results = new ConfigManager[100];
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
final int idx = i;
threads[i] = new Thread(
() -> results[idx] = ConfigManager.getInstance());
}
for (Thread t : threads) t.start();
for (Thread t : threads) t.join();
for (ConfigManager result : results) {
assertSame(results[0], result);
}
}
@Test
void get_returnsConfigValue() {
ConfigManager config = ConfigManager.getInstance();
String dbUrl = config.get("db.url");
assertNotNull(dbUrl);
assertTrue(dbUrl.contains("national_bank_db"));
}
@Test
void getInt_returnsIntValue() {
ConfigManager config = ConfigManager.getInstance();
int poolSize = config.getInt("db.pool.size", 5);
assertEquals(20, poolSize);
}
@Test
void getInt_returnsDefaultForMissingKey() {
ConfigManager config = ConfigManager.getInstance();
int value = config.getInt("nonexistent.key", 42);
assertEquals(42, value);
}
@Test
void getBoolean_returnsBooleanValue() {
ConfigManager config = ConfigManager.getInstance();
assertTrue(config.getBoolean("feature.upi.enabled", false));
assertFalse(config.getBoolean(
"feature.international.transfer.enabled", true));
}
@Test
void clone_throwsException() {
ConfigManager config = ConfigManager.getInstance();
assertThrows(CloneNotSupportedException.class,
() -> config.clone());
}
@Test
void getAllKeys_returnsNonEmptySet() {
ConfigManager config = ConfigManager.getInstance();
assertFalse(config.getAllKeys().isEmpty());
}
}
Expected Output
12. When to Use and When NOT to Use
When to use Singleton
| Scenario | Example | Why Singleton |
|---|---|---|
| Configuration management | Loading app config from properties/yaml | Config is read-only after load. One source of truth for all services. |
| Logging | Application-wide logger | All components write to same log file. One writer prevents file corruption. |
| Database connection pool | HikariCP, C3P0 pool manager | One pool shared by all threads. Multiple pools waste connections. |
| Cache manager | In-memory cache (Guava, Caffeine) | One cache serves all reads. Duplicate caches waste memory and lose consistency. |
| Thread pool manager | Executor service manager | One pool controls thread count. Multiple pools can exhaust system resources. |
| Hardware access | Printer spooler, file system access | One manager coordinates access to shared hardware. |
| Registry / Service locator | JNDI lookup, bean factory | One registry holds all references. Multiple registries cause inconsistency. |
When NOT to use Singleton
| Scenario | Why NOT | Use Instead |
|---|---|---|
| Classes with per-request state | Each request needs its own state (user data, session). Singleton shares state across all requests. | New instance per request (prototype scope) |
| Unit testing | Singletons carry state between tests. One test can pollute another. Hard to mock. | Dependency injection with interfaces |
| When subclassing is needed | Singleton has a private constructor. Cannot extend it. Limits flexibility. | Factory pattern with interface |
| In distributed systems | Singleton is per-JVM. In a cluster with 10 JVMs, you have 10 instances, not one. | Distributed cache (Redis, Hazelcast) |
| When lazy init overhead matters | Double-checked locking adds complexity. If the class is always used, eager init is simpler. | Eager init or Spring bean |
| God object / too many responsibilities | Singleton should not become a dumping ground for unrelated state. | Split into focused classes |
Singleton becomes an anti-pattern when:
- It holds mutable state that changes per request (user sessions, request data)
- It is used as a global variable to avoid passing dependencies
- It makes unit testing impossible because you cannot inject mock implementations
- It grows into a god class with 50+ methods doing unrelated things
Modern approach: Use dependency injection (Spring, Guice) to manage object lifecycle instead of manually coding singletons. Let the framework decide the scope.