How to implement thread-safe Singleton
When an application runs in a multithreaded environment, naive lazy initialization of a Singleton breaks due to a Race Condition:
🟢 Junior Level
When an application runs in a multithreaded environment, naive lazy initialization of a Singleton breaks due to a Race Condition:
// ❌ CRITICAL BUG: Race Condition in Multithreaded Environments!
public class UnsafeSingleton {
private static UnsafeSingleton instance;
private UnsafeSingleton() {}
public static UnsafeSingleton getInstance() {
if (instance == null) {
// If Thread A and Thread B reach here concurrently,
// both execute 'new' and instantiate TWO SEPARATE OBJECTS!
instance = new UnsafeSingleton();
}
return instance;
}
}
To preserve the singleton invariant, object instantiation must be strictly thread-safe.
The Safest and Simplest Solution: Enum Singleton
Since Java 5, the most concise, safe, and robust way to implement a thread-safe Singleton is using an enum:
public enum AppSettings {
INSTANCE;
private String databaseUrl = "jdbc:postgresql://localhost:5432/mydb";
public String getDatabaseUrl() {
return databaseUrl;
}
public void setDatabaseUrl(String databaseUrl) {
this.databaseUrl = databaseUrl;
}
}
// Usage:
public class Main {
public static void main(String[] args) {
AppSettings settings = AppSettings.INSTANCE;
System.out.println(settings.getDatabaseUrl());
}
}
Why Enum is Safe Out-of-the-Box:
- Thread-Safe by JVM Specification: The JVM guarantees that enum constants are instantiated atomically during class loading before first use.
- Native Reflection Defense:
Constructor.newInstance()explicitly throws anIllegalArgumentExceptionwhen attempting to reflectively instantiate an enum. - Native Serialization Safety: The JVM’s serialization mechanism guarantees that deserializing an enum returns the existing canonical instance rather than creating a duplicate.
🟡 Middle Level
The 4 Core Thread-Safe Implementation Patterns
1. Eager Initialization (Thread-Safe, Non-Lazy)
The instance is created by the static initializer during JVM class loading:
public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
- Pros: Trivially simple; 100% thread safety is guaranteed by the JVM ClassLoader.
- Cons: Memory is allocated immediately when the class is loaded, even if the application never uses it.
2. Double-Checked Locking (DCL with volatile)
Lazy initialization where synchronization occurs strictly on the first invocation:
public class DclSingleton {
// volatile is MANDATORY to prevent instruction reordering!
private static volatile DclSingleton instance;
private DclSingleton() {
// Reflection attack defense
if (instance != null) {
throw new IllegalStateException("Instance already exists! Reflection forbidden.");
}
}
public static DclSingleton getInstance() {
DclSingleton local = instance; // Micro-optimization: single volatile read
if (local == null) {
synchronized (DclSingleton.class) {
local = instance;
if (local == null) {
instance = local = new DclSingleton();
}
}
}
return local;
}
}
3. Bill Pugh Singleton (Initialization-on-Demand Holder Idiom)
The industry standard balance between lazy loading, peak performance, and zero synchronization boilerplate:
public class BillPughSingleton {
private BillPughSingleton() {}
// Static nested class is NOT loaded until getInstance() is called
private static class Holder {
private static final BillPughSingleton INSTANCE = new BillPughSingleton();
}
public static BillPughSingleton getInstance() {
return Holder.INSTANCE;
}
}
- How It Works: The outer class
BillPughSingletoncan be loaded by the JVM, but the inner classHolderremains unloaded untilgetInstance()is called. The JVM Class Initialization Lock (JLS §12.4.2) guarantees thread-safe, atomic static field assignment without anysynchronizedoverhead.
4. Hardened Singleton (Protecting Against Reflection, Serialization, and Cloning)
When a singleton must implement Serializable or Cloneable, defensive mechanisms are required:
public class HardenedSingleton implements Serializable {
private static final long serialVersionUID = 1L;
private HardenedSingleton() {
if (Holder.INSTANCE != null) {
throw new IllegalStateException("Duplicate creation via reflection prohibited!");
}
}
private static class Holder {
private static final HardenedSingleton INSTANCE = new HardenedSingleton();
}
public static HardenedSingleton getInstance() {
return Holder.INSTANCE;
}
// Prevents duplicate instance creation during deserialization
protected Object readResolve() {
return getInstance();
}
// Prevents shallow cloning
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException("Cloning of Singleton is forbidden!");
}
}
Implementation Comparison Matrix
| Pattern | Lazy Loading | Thread Safety | Reflection Protection | Serialization Protection | Complexity |
|---|---|---|---|---|---|
| Enum | ❌ No | ✅ JVM | ✅ Native | ✅ Native | Minimal |
| Bill Pugh | ✅ Yes | ✅ JVM | ⚠️ Manual Guard | ⚠️ readResolve() |
Low |
| DCL + volatile | ✅ Yes | ✅ JMM volatile | ⚠️ Manual Guard | ⚠️ readResolve() |
High |
| Eager Static | ❌ No | ✅ JVM | ⚠️ Manual Guard | ⚠️ readResolve() |
Minimal |
| Synchronized Method | ✅ Yes | ✅ Class Monitor | ⚠️ Manual Guard | ⚠️ readResolve() |
Medium (High Contention) |
🔴 Senior Level
Deep JMM Analysis: Instruction Reordering and Hardware Architectures (x86 vs. ARM)
In the expression instance = new DclSingleton(), the JVM and CPU execute three distinct operations:
1. allocate_memory(sizeof(DclSingleton)) -> Allocate raw heap memory
2. invokespecial DclSingleton.<init>() -> Run constructor (initialize fields)
3. instance = ref -> Assign heap memory address to static reference
The Threat Without volatile:
Super-scalar processors (Out-of-Order Execution) and the JIT compiler (C2) may reorder these operations: 1 ──> 3 ──> 2.
[Thread 1] [Thread 2]
1. allocate_memory()
3. instance = addr (Reference published!)
Calls getInstance():
Checks: if (instance == null) -> FALSE!
Returns partially initialized object!
Invokes: obj.doSomething() -> NPE / Corrupted State!
2. invokespecial <init>() (Constructor runs)
Hardware Memory Models: x86 (TSO) vs. ARM64 / Apple Silicon
- x86-64 Architecture: Enforces Total Store Order (TSO)—a strong memory model. The CPU hardware strictly forbids StoreStore instruction reordering. Consequently, buggy DCL implementations without
volatilecould run for years on Intel/AMD servers without surfacing bugs. - ARM64 (AWS Graviton, Apple Silicon, Ampere) & PowerPC: Enforce a Weak Memory Model. The CPU aggressively reorders store and load operations for maximum parallel execution. Running broken DCL code without
volatileon ARM64 immediately leads to intermittent data corruption and crashes.
The Role of volatile:
Under JSR-133 (Java 5+), a write to a volatile variable emits memory barrier instructions (StoreStore before the write and StoreLoad after on x86; dmb ish on ARM64). This establishes a happens-before guarantee: all memory writes performed inside the constructor are guaranteed visible to all other threads before the non-null reference becomes observable.
Modern Optimization via VarHandle (Java 9+)
The volatile modifier enforces sequential consistency across all operations. In ultra-high-throughput systems, Java 9 introduced VarHandle with Acquire/Release semantics, which emit lighter-weight one-way memory fences on ARM architectures:
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
public class VarHandleSingleton {
private static final VarHandle INSTANCE_HANDLE;
static {
try {
INSTANCE_HANDLE = MethodHandles.lookup()
.findStaticVarHandle(VarHandleSingleton.class, "instance", VarHandleSingleton.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
private static VarHandleSingleton instance;
private VarHandleSingleton() {}
public static VarHandleSingleton getInstance() {
VarHandleSingleton local = (VarHandleSingleton) INSTANCE_HANDLE.getAcquire();
if (local == null) {
synchronized (VarHandleSingleton.class) {
local = (VarHandleSingleton) INSTANCE_HANDLE.getAcquire();
if (local == null) {
local = new VarHandleSingleton();
INSTANCE_HANDLE.setRelease(local);
}
}
}
return local;
}
}
- Why It’s Faster:
getAcquire()andsetRelease()emit unidirectional memory fences that avoid the full bidirectional pipeline flushes required by standardvolatilevariables on ARM64 systems.
Class Initialization Lock Mechanics in Bill Pugh Singleton
The Java Language Specification (JLS §12.4.2) details the precise state machine executed during class loading:
- Every class in the JVM is associated with a unique internal Initialization Lock (
Class<Holder>). - The first thread calling
getInstance()triggers the loading ofHolder. It acquires this lock, transitions the class state toinitializing, executes<clinit>, and instantiatesINSTANCE. - Any other threads calling
getInstance()concurrently block on this initialization monitor until the class transitions toinitialized. - Once in the
initializedstate, all subsequent reads bypass synchronization entirely, executing at the speed of a standard static constant read.
4 Tricky Questions
1. Why could broken DCL without volatile run for years on Intel x86 servers without errors, yet fail immediately on AWS Graviton (ARM64)?
Answer:
The x86-64 architecture implements the Total Store Order (TSO) memory model, which prohibits StoreStore hardware reordering. The CPU ensures that writes to memory become visible to other cores in program order, so the constructor fields were almost always committed before the reference assignment. In contrast, ARM64 implements a Weak Memory Model that aggressively reorders stores out-of-order. On ARM64, the reference assignment (3) is frequently visible to other cores before the constructor finishes (2), immediately exposing partially constructed objects.
2. Why does the getInstance() method in DCL use a local variable (DclSingleton local = instance)?
Answer:
It is a Local Variable Optimization targeting CPU register caching.
Reading a volatile field requires an access to L1/L2/L3 cache validated by hardware cache coherency protocols (MESI), which is significantly more expensive than reading a thread stack local variable or CPU register. Storing the reference in a local variable ensures that once the singleton is initialized (which represents $>99.99\%$ of calls), the volatile memory barrier is traversed only once instead of multiple times per method call.
3. Why is Bill Pugh Singleton superior to a synchronized accessor method?
Answer:
A synchronized method acquires the class monitor lock on every single call to getInstance(), creating a severe synchronization bottleneck (lock contention) in high-concurrency environments.
Bill Pugh Singleton delegates synchronization entirely to the JVM ClassLoader (JLS §12.4.2). The ClassLoader lock is engaged only once during the initial loading of the nested Holder class. After initialization, all subsequent calls read a static final reference with zero locks and zero synchronization overhead.
4. How can reflection break a Bill Pugh Singleton, and how do you defend against it?
Answer:
A client can obtain the private constructor via Class.getDeclaredConstructor(), call setAccessible(true) to bypass Java access modifiers, and invoke newInstance() to construct a second instance.
Defense: Inside the private constructor, verify whether the holder instance already exists (or inspect a static boolean initialization flag). If the instance already exists, throw an IllegalStateException:
private BillPughSingleton() {
if (Holder.INSTANCE != null) {
throw new IllegalStateException("Singleton already initialized!");
}
}
🎯 Interview Cheat Sheet
Core Concepts (First 30 Seconds)
- Top 2 Implementations:
- Enum Singleton: Gold standard when lazy loading is not required; natively immune to Reflection and Serialization attacks.
- Bill Pugh Singleton (Holder Idiom): Best choice for lazy loading; uses JVM ClassLoader initialization locks (JLS §12.4.2) for lock-free thread safety.
- Double-Checked Locking (DCL): Must declare the instance as
volatileto prevent CPU instruction reordering from publishing partially constructed objects. - Micro-Optimization: Cache
instancein a local variable inside DCL to reduce volatile memory fence reads to one access.
Key Architectural Gotchas
- Hardware Architecture: DCL without
volatilecrashes on ARM64 / Apple Silicon due to Weak Memory Ordering, even if it appeared stable on x86 Intel CPUs. - Spring Context: Enterprise applications should never use manual singletons; declare beans via
@Service/@Component(managed as singletons by Spring IoC). - VarHandle: In Java 9+, Acquire/Release semantics provide lighter memory barriers than full
volatilefences on ARM systems.
Red Flags (What NOT to Say)
- ❌ “DCL works fine without volatile on modern Java.” (CPU instruction reordering still breaks DCL without volatile).
- ❌ “I use public static synchronized getInstance() in production.” (Causes severe lock contention under concurrent load).
- ❌ “Enum singletons support lazy initialization on demand.” (Enums are loaded eagerly as soon as the enum class is referenced).
- ❌ “A Java Singleton guarantees uniqueness across a distributed cluster.” (It is strictly local to one ClassLoader in one JVM).
Related Topics
- What is Singleton — Fundamental pattern definition and trade-offs
- What is double-checked locking — JMM memory barrier mechanics and CPU reordering
- What are problems with Singleton — Unit testing, global state, and architectural smells
- What pattern categories exist — Creational patterns overview