Skip to content

Java Singleton Pattern: Thread-Safe, Reflection-Proof, Serialization-Safe

BackendBytes Engineering Team
BackendBytes Engineering Team
6 min read
Java Singleton Pattern: Thread-Safe, Reflection-Proof, Serialization-Safe

Key Takeaways

  • Enum singleton: the JVM guarantees exactly one instance, handles serialization automatically, blocks reflection-based construction with zero boilerplate — use this for 99% of production singletons
  • Holder pattern (Bill Pugh): lazy initialization without synchronization via inner class load semantics, but needs constructor guards and readResolve() for reflection and serialization attacks
  • Double-checked locking: only when your singleton must extend a concrete class (enums cannot); volatile is mandatory on the field or you get partially constructed visibility
  • Singletons are per-JVM, not per-deployment — N Kubernetes pods create N separate singleton instances, each with its own connection pool; scale the pool size with the replica count, not the other way round

The classic singleton-on-K8s connection-pool exhaustion pattern. Team deploys a service with a singleton HikariCP pool of, say, 20 connections. The JVM-singleton scope is correct per pod. Then the deployment scales horizontally to N pods. N × 20 connections hit a Postgres instance configured with max_connections = 200. The connection-limit-exceeded error arrives at 2 AM. The singleton wasn't wrong; the mental model "one instance globally" doesn't survive contact with Kubernetes. We've debugged this on multiple Java services.

The pattern looks trivial — private constructor, static field, public accessor — but the JVM fights you at every turn: threads race to initialize it, serialization creates new instances, and reflection bypasses private. A singleton that ignores these realities usually has one instance. This article covers four approaches, the three attacks that break naive implementations, and when to skip the pattern entirely.

TL;DR

Use enum singletons by default[Effective Java] — the JVM guarantees one instance and blocks reflection and serialization attacks automatically. For lazy initialization, use the Bill Pugh holder pattern. Reserve double-checked locking only when you must extend a base class. And in Spring applications, prefer @Component beans over manual singletons.

  • Enum singleton: zero boilerplate, reflection-proof, handles serialization automatically
  • Holder pattern: lazy initialization without locks; needs constructor guard for reflection
  • Double-checked locking: only if extending a class; requires volatile field
  • Singletons are per-JVM, not per-deployment — N pods = N instances, not 1
graph TD
    Start[Need a single instance?] --> Q1{Must extend<br/>a concrete class?}
    Q1 -->|No| Q2{Spring app?}
    Q1 -->|Yes| DCL[Double-checked locking<br/>+ volatile field]
    Q2 -->|Yes| Bean["@Component bean<br/>container-managed"]
    Q2 -->|No| Q3{Lazy init required?}
    Q3 -->|No| Enum[Enum singleton<br/>JVM guarantees]
    Q3 -->|Yes| Holder[Holder pattern<br/>Bill Pugh idiom]
    Enum --> Best[Default choice ✓]
    Bean --> Best
    style Best fill:#efe
    style Enum fill:#efe
    style Bean fill:#efe
    style DCL fill:#fee

The diagram is the picker: pick the pattern by what's forced on you. Enum is the right default; you only leave it for explicit reasons (must extend a class, must initialise lazily without container support). Most production "singleton bugs" are someone reaching for double-checked locking by reflex when Enum or @Component would have been simpler and safer.

The quick start: singleton patterns compared

The decision tree for "which singleton pattern" — route by what your code is doing, not by familiarity:

graph TD
    Need[I need a single<br/>shared instance] --> DI{Using a DI<br/>container?}
    DI -->|Spring / Guice / Dagger| Bean["@Component bean<br/>let the container manage<br/>scope = singleton by default"]
    DI -->|No DI / library code| Lazy{Need lazy<br/>initialisation?}
    Lazy -->|No — eager fine| Enum[Enum singleton<br/>INSTANCE pattern<br/>JVM-guaranteed thread + serialisation safety]
    Lazy -->|Yes — only init on first call| MustExtend{Must extend<br/>a base class?}
    MustExtend -->|No — design freedom| Holder[Holder Bill Pugh<br/>private static class<br/>JVM lazy class load]
    MustExtend -->|Yes — extends a parent| DCL[Double-checked locking<br/>volatile + synchronized<br/>+ readResolve guard]
    DCL --> Reflect[+ private constructor<br/>throws on second call<br/>blocks reflection attack]
    Holder --> Reflect
    style Enum fill:#dfd
    style Bean fill:#dfd
    style Holder fill:#ffd
    style DCL fill:#fdd

The diagram captures the decision in one picture: enum is the only pattern where the JVM does the safety guarantees for you. Reach for it unless something forces you out — DI, lazy + class extension, or both.

PatternThread-safeLazyReflection-proofSerialization-safeBoilerplateBest for
EnumJVM guaranteeOn first accessJVM enforcedAutomaticMinimalDefault choice
Holder (Bill Pugh)JVM guaranteeOn first callNo (needs guard)No (needs readResolve)LowLazy init without DI
Double-checked lockingIf volatileOn first callNo (needs guard)No (needs readResolve)MediumMust extend class
Spring @ComponentContainer-managedOn context initN/AN/ANone (annotation)Application code

Use enum by default. It is the only pattern where thread safety, serialization safety, and reflection safety are all guaranteed by the JVM rather than your code.

The enum singleton

[Effective Java]
public enum ConnectionPool {
    INSTANCE;
 
    private final HikariDataSource dataSource;
 
    ConnectionPool() {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl("jdbc:postgresql://localhost:5432/app");
        config.setMaximumPoolSize(20);
        this.dataSource = new HikariDataSource(config);
    }
 
    public Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }
}
 
// Usage: ConnectionPool.INSTANCE.getConnection()

The JVM guarantees enum constants are instantiated exactly once. You get three properties for free:

Thread safety: Class initialization is locked (JLS 12.4.2). Two threads accessing INSTANCE simultaneously see only one initialization — no race window.

Serialization safety: Java's serialization treats enums specially: it serializes only the constant name and deserializes via Enum.valueOf(), never creating new instances.

Reflection safety: Constructor.newInstance() explicitly checks for enum types and throws IllegalArgumentException if found.

The only limitation: enums cannot extend a concrete class (they implicitly extend java.lang.Enum). If your singleton must inherit from a framework-provided abstract class, use the holder pattern or double-checked locking instead. Enums can implement interfaces freely.

The holder pattern (Bill Pugh)

[Effective Java]
public class ConfigManager {
    private final Properties properties;
 
    private ConfigManager() {
        this.properties = new Properties();
        try (var in = getClass().getResourceAsStream("/app.properties")) {
            if (in != null) properties.load(in);
        } catch (IOException e) {
            throw new UncheckedIOException("Failed to load config", e);
        }
    }
 
    private static class Holder {
        static final ConfigManager INSTANCE = new ConfigManager();
    }
 
    public static ConfigManager getInstance() {
        return Holder.INSTANCE;
    }
 
    public String get(String key) {
        return properties.getProperty(key);
    }
}

The inner class Holder is not loaded when ConfigManager loads. It loads only on first getInstance() call. Class initialization is thread-safe by the JLS — no synchronized or volatile needed.

Advantage: genuinely lazy. If getInstance() is never called, the singleton is never created.

Weakness: no built-in defense against reflection or serialization. If your class implements Serializable, or if code uses Constructor.setAccessible(true), a second instance can be created. Add a constructor guard and/or readResolve() method to defend against both.

Double-checked locking (only if extending a class)

[Effective Java]
public class MetricsRegistry extends AbstractRegistry {
    private static volatile MetricsRegistry instance;
 
    private MetricsRegistry() {
        super(Clock.systemUTC());
    }
 
    public static MetricsRegistry getInstance() {
        MetricsRegistry local = instance;    // read volatile once
        if (local == null) {
            synchronized (MetricsRegistry.class) {
                local = instance;
                if (local == null) {
                    instance = local = new MetricsRegistry();
                }
            }
        }
        return local;
    }
}

Use DCL only when your singleton must extend a concrete class (enums cannot). The volatile keyword is mandatory: without it, Thread B can see a non-null reference before Thread A finishes the constructor, accessing an object with default-value fields.

The local variable reduces volatile reads on the fast path from two to one. Prefer the holder pattern—it achieves the same lazy, thread-safe initialization with less code and zero chance of forgetting volatile.

Defending against reflection and serialization

[Effective Java]

Non-enum singletons are vulnerable to reflection and serialization. If you use the holder or DCL pattern, add these defenses:

Reflection attack: Constructor.newInstance() can bypass private constructors. Add a guard:

private ConfigManager() {
    if (Holder.INSTANCE != null) {
        throw new IllegalStateException("Use getInstance()");
    }
    // ... normal initialization
}

Serialization attack: Java deserialization creates a new instance by default. Add readResolve():

private Object readResolve() {
    return Holder.INSTANCE;
}

ObjectInputStream calls readResolve() after construction and substitutes the returned value. Enum singletons handle both attacks automatically—the JVM refuses reflective construction and serializes only the constant name.

Singletons at scale: one per JVM, not per deployment

Every pattern guarantees one instance per JVM. In Kubernetes with 40 pods, that means 40 instances. A ConnectionPool singleton with maximumPoolSize = 20 creates 20 * 40 = 800 total connections—against PostgreSQL's max_connections = 200[PostgreSQL Docs, Connection Config]. This scope mismatch was the 2 AM incident.

Two solutions:

  1. Connection proxy. PgBouncer or RDS Proxy between your pods and database. Each pod connects to the proxy; the proxy multiplexes onto a smaller number of real database connections. Standard for production deployments beyond a handful of replicas.
// Connect to proxy, not directly to PostgreSQL
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://pgbouncer:6432/app");
config.setMaximumPoolSize(10); // local pool to proxy, not to DB
  1. Spring @Component beans. Singletons are scoped per ApplicationContext. In Spring Boot with one context per JVM, you get one instance per pod—but with full testability and no global static state. Spring caches one context per distinct test configuration and reuses it, so identically-configured @SpringBootTest classes share a single set of pools; differing configurations spin up separate contexts (and pools), and @DirtiesContext forces a rebuild — so keep test configuration consistent to avoid pool multiplication.

Production checklist

  • Pick enum singleton by default. Only choose holder pattern if you need lazy initialization, or DCL if you must extend a class.
  • Add constructor guards and readResolve() to non-enum singletons if they might be serialized or accessed via reflection.
  • Use Spring @Component beans in application code. They are singleton-scoped by default, mockable in tests, and fully testable without reflection hacks.
  • Account for per-JVM scope in distributed systems. A singleton with 20-connection pool across 40 pods = 800 connections total. Use connection proxies (PgBouncer, RDS Proxy) to multiplex.
  • Verify thread-safety in tests. Spawn 50+ threads calling getInstance() concurrently—all must see the same instance.
  • Never make a singleton Serializable unless you implement readResolve(). Standard Java deserialization creates a new instance.
  • Test Spring singleton scope carefully. Each @SpringBootTest creates its own ApplicationContext; beans are re-instantiated across test classes unless you share context configuration.

Singleton in Spring, in tests, in serverless: the three production angles

The textbook treatment of singletons stops at the JVM. Real systems live inside DI containers, test runners, and cold-starting serverless functions — each of which subtly redefines what "singleton" means.

Spring redefines the boundary. A @Component is singleton-scoped per ApplicationContext, not per JVM. Switching scope is a one-line change, but the runtime behaviour differs dramatically — prototype beans are constructed on every injection point and never tracked by the container after that.

// Singleton-scoped (default): one instance shared across the whole context.
@Component
public class RateLimiter {
    private final Map<String, AtomicLong> counters = new ConcurrentHashMap<>();
    public boolean allow(String key, long max) {
        return counters.computeIfAbsent(key, k -> new AtomicLong()).incrementAndGet() <= max;
    }
}
 
// Prototype-scoped: a new instance per injection. Useful for stateful, short-lived helpers.
@Component
@Scope("prototype")
public class RequestContextHolder {
    private final Map<String, Object> attributes = new HashMap<>();
    public void put(String k, Object v) { attributes.put(k, v); }
    public Object get(String k) { return attributes.get(k); }
}
 
// Beware: injecting a prototype into a singleton freezes the prototype to the singleton's lifetime
// unless you use ObjectProvider<RequestContextHolder> or a method-level @Lookup.

The classic mistake: @Autowired a prototype field into a @Service and assume each call gets a fresh instance. It does not — the singleton is constructed once and the prototype reference is captured exactly once with it. Use ObjectProvider or a @Lookup method when the lifecycle truly needs to differ.

Tests are where stale singletons explode. Static singleton state survives across test classes because the JVM doesn't unload the class between tests. Either reset state explicitly, or tell Spring to throw the context away.

class RateLimiterTest {
 
    // Pattern 1: explicit reset on a manual singleton.
    @BeforeEach
    void resetSingleton() throws Exception {
        Field counters = RateLimiterSingleton.class.getDeclaredField("counters");
        counters.setAccessible(true);
        ((Map<?, ?>) counters.get(RateLimiterSingleton.INSTANCE)).clear();
    }
 
    // Pattern 2: ask Spring to drop the context after a destructive test.
    @SpringBootTest
    @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
    static class DirtyingTests {
        @Autowired RateLimiter rateLimiter;
        // mutations here will not bleed into other test classes that share this context
    }
}

@DirtiesContext is expensive — it rebuilds the entire ApplicationContext, including HikariCP pools, on the next test that requests it. Use it surgically, not by default. For pure manual singletons, prefer the reflective reset (or, better, refactor to constructor injection so the test owns the instance).

Serverless changes the cost model entirely. AWS Lambda and Cloud Run cold-start a fresh JVM per container instance. Lazy-init singletons defer their constructor until the first request — which means the first user pays the latency tax. Eager init (the enum default) front-loads it into the container bootstrap, where the platform usually doesn't bill you.

// Eager: classloading triggers construction. Cold-start cost lands in init phase.
public enum FeatureFlagsEager {
    INSTANCE;
    private final Map<String, Boolean> flags;
    FeatureFlagsEager() {
        // ~80 ms on a cold Lambda init: parsing JSON, fetching from SSM
        this.flags = loadFromParameterStore();
    }
    public boolean isEnabled(String key) { return flags.getOrDefault(key, false); }
}
 
// Lazy holder: first request pays the cost. Looks cheap until you read p99 latency.
public final class FeatureFlagsLazy {
    private FeatureFlagsLazy() {}
    private static class Holder {
        static final Map<String, Boolean> FLAGS = loadFromParameterStore();
    }
    public static boolean isEnabled(String key) { return Holder.FLAGS.getOrDefault(key, false); }
}

On a Lambda with provisioned concurrency or SnapStart, eager init is essentially free — the platform runs your INIT phase before any request lands. Lazy init pushes that work into request-handling time, which Lambda meters and the user observes. The rule of thumb: in serverless, prefer eager init for any singleton whose construction is bounded and deterministic.

Synchronized methods are not the modern answer. A common reflex when "thread-safe singleton" comes up is to slap synchronized on the accessor. That works, but every read pays a monitor-acquire — and the holder idiom gives you the same guarantee with zero runtime locking, courtesy of the JVM's class-init semantics.

// Synchronized accessor: correct, but every getInstance() takes a monitor.
public class CacheRegistrySynchronized {
    private static CacheRegistrySynchronized instance;
    private CacheRegistrySynchronized() {}
    public static synchronized CacheRegistrySynchronized getInstance() {
        if (instance == null) instance = new CacheRegistrySynchronized();
        return instance;
    }
}
 
// Holder idiom: lazy, thread-safe, no synchronisation on the hot path.
public class CacheRegistryHolder {
    private CacheRegistryHolder() {}
    private static class H {
        static final CacheRegistryHolder INSTANCE = new CacheRegistryHolder();
    }
    public static CacheRegistryHolder getInstance() { return H.INSTANCE; }
}

Double-checked locking exists for the case where you must extend a concrete class and need lazy init — but if neither constraint applies, the holder idiom is strictly simpler, strictly faster, and strictly easier to get right. JVMs since Java 5 give you the same guarantee for free.

Singleton vs static utility class: pick by what you actually need

A reflex many engineers reach for is "I need a single shared thing — make it a static utility class." That works for stateless helpers (think string formatters, math functions, parsers with no configuration), but it collapses the moment the helper grows state, dependencies, or a lifecycle. A static class cannot implement an interface in the way callers expect, cannot be passed as a parameter, cannot be mocked, and cannot be initialised lazily without bolting on a holder pattern around static fields anyway. The "lighter" option ends up heavier.

The split is best reasoned about by what you can hand off to the rest of the program. A singleton is an object — it has identity, it can be stored in a field, it can be substituted with a fake in tests, it can be replaced via dependency injection. A static utility class is a namespace — pure functions that take everything they need as arguments. If a method needs a configured database client, a thread pool, or a metrics registry, it is no longer a pure function and the namespace model breaks down.

// Static utility class: correct shape for stateless helpers.
// No identity, no lifecycle, no dependencies — just functions in a namespace.
public final class HexCodec {
    private HexCodec() { throw new AssertionError("no instances"); }
 
    public static String encode(byte[] bytes) {
        StringBuilder out = new StringBuilder(bytes.length * 2);
        for (byte b : bytes) out.append(String.format("%02x", b));
        return out.toString();
    }
 
    public static byte[] decode(String hex) {
        int len = hex.length();
        byte[] out = new byte[len / 2];
        for (int i = 0; i < len; i += 2) {
            out[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16);
        }
        return out;
    }
}
 
// Singleton: correct shape for stateful, configured, swappable services.
// Has identity (INSTANCE), holds resources (the meter registry), can be
// passed by reference, can be substituted in tests via an interface.
public interface MetricsSink {
    void increment(String name);
    void record(String name, long value);
}
 
public enum MicrometerSink implements MetricsSink {
    INSTANCE;
    private final MeterRegistry registry = new SimpleMeterRegistry();
    @Override public void increment(String name) { registry.counter(name).increment(); }
    @Override public void record(String name, long value) { registry.timer(name).record(java.time.Duration.ofMillis(value)); }
}

The rule of thumb: if every method on the class would be static and take no shared configuration, a static utility is the right shape. The instant one method needs configured state — a connection pool, a clock, a feature flag service — you want a singleton that can be passed to consumers and faked in tests. Forcing state into a static class typically means parking it in mutable static fields, which is exactly the global state that singletons were already trying to avoid.

Serialization safety: readResolve() is mandatory for non-enum singletons

Any non-enum singleton that implements Serializable is, by default, broken. Java's default deserialization protocol creates a brand-new instance via reflection — bypassing the constructor entirely — and returns it from ObjectInputStream.readObject(). So a process that round-trips its singleton through a cache, a session replication layer, or a debugger stash silently ends up with two "singletons" referring to disjoint state. The fix is a single private method that the serialization machinery calls last.

import java.io.ObjectStreamException;
import java.io.Serial;
import java.io.Serializable;
 
public final class LicenseCache implements Serializable {
    @Serial private static final long serialVersionUID = 1L;
 
    // Mark every non-transient field so they survive the round trip and the
    // post-deserialize substitute still has the data it needs.
    private final java.util.concurrent.ConcurrentMap<String, byte[]> entries =
        new java.util.concurrent.ConcurrentHashMap<>();
 
    private LicenseCache() {}
 
    private static final class Holder {
        static final LicenseCache INSTANCE = new LicenseCache();
    }
 
    public static LicenseCache getInstance() { return Holder.INSTANCE; }
 
    public byte[] get(String key) { return entries.get(key); }
    public void put(String key, byte[] value) { entries.put(key, value); }
 
    // Called by ObjectInputStream after the deserialized graph is built.
    // Whatever object we return here REPLACES the deserialized one — so we
    // throw the freshly-created clone away and hand back the canonical instance.
    @Serial
    private Object readResolve() throws ObjectStreamException {
        return Holder.INSTANCE;
    }
}

There are three subtleties most write-ups skip. First, readResolve must be private (or at least non-public) and return Object — the JDK looks it up reflectively and invokes it whether the access modifier exposes it or not, so widening it just leaks an attractive nuisance. Second, fields that are not transient will be deserialized into the throwaway clone before readResolve runs; that is wasted work but harmless, except that any custom readObject you write must remain valid because it still gets called. Third, if your singleton extends a Serializable parent, every superclass field has to be either transient or covered by your readResolve return value — otherwise an attacker who controls the byte stream can poison superclass state on the deserialized clone before you swap it out.

The simpler answer for almost any case: do not make a singleton Serializable at all. There is rarely a real reason — caches, registries, and pools should be reconstructed at process start, not serialised across restarts. If you must, the enum form already solves this without any boilerplate, because enum serialization is by name and the JVM resolves the constant on the receiving side.

Reflection attacks and why enum is the only complete defence

Java's reflection API can call setAccessible(true) on a private constructor and then newInstance() it. For every non-enum singleton — holder pattern, double-checked locking, eager static field — that single call produces a second instance. In production you will not normally face an adversarial classloader, but you will face frameworks that helpfully reflect over your code: serialization libraries, dependency injection scanners, deep-copy utilities, hot-reload tooling, and ill-advised "test helpers" that reach in to reset state.

The defence for non-enum singletons is a constructor guard that fails the second invocation, plus auditing every reflection-using dependency on your classpath. It is correct but defensive and easy to forget — which is exactly why Effective Java settled on enums as the recommended default.

// Constructor-guard defence on a holder singleton. Correct, but every
// non-enum singleton in the codebase needs this same boilerplate.
public final class TokenCache {
    private static volatile boolean constructed = false;
 
    private TokenCache() {
        // Synchronize the guard or two threads racing through reflection
        // can both pass the check before either sets the flag.
        synchronized (TokenCache.class) {
            if (constructed) {
                throw new IllegalStateException(
                    "TokenCache already constructed — refusing reflective second instance");
            }
            constructed = true;
        }
    }
 
    private static final class Holder {
        static final TokenCache INSTANCE = new TokenCache();
    }
 
    public static TokenCache getInstance() { return Holder.INSTANCE; }
}
 
// The enum equivalent. The JVM enforces single-instance semantics at the
// reflection layer itself — Constructor.newInstance() throws on enum types,
// no boilerplate, no race window, no dependency on a static flag.
public enum TokenCacheEnum {
    INSTANCE;
    // ... fields and methods, identical to the holder version
}

The relevant guarantee is in java.lang.reflect.Constructor.newInstance, which explicitly throws IllegalArgumentException when invoked on an enum type. You cannot turn this off with setAccessible(true) — the check sits before the access check. Combined with the JVM's special-cased serialization of enum constants by name, the enum form is the only singleton shape where the language itself, rather than your if-guard, holds the invariant. For 99% of production singletons that is the right trade: less code, no race, no maintenance burden when a new framework starts reflecting over your classes. [Effective Java]


Frequently Asked Questions

What is the best way to implement a singleton in Java?

Use a single-element enum — the JVM guarantees exactly one instance, handles serialization automatically, and blocks reflection-based construction with zero boilerplate. Use the holder-class (Bill Pugh) pattern if you need lazy initialization, or double-checked locking only when the singleton must extend a concrete class.

Why can reflection break a Java singleton?

Reflection can call Constructor.newInstance() to bypass the private constructor and create additional instances. Enum singletons are immune because the JVM explicitly blocks reflective construction of enum types. For non-enum singletons, throw an exception in the constructor if an instance already exists.

Is a Java singleton one instance per JVM or per deployment?

One instance per JVM (per ClassLoader, technically). In Kubernetes with 40 pods, each pod runs its own JVM with its own singleton instance. A singleton connection pool of 20 connections across 40 pods creates 800 total connections, not 20.

Should I use the singleton pattern or dependency injection?

Prefer dependency injection. Spring beans are singleton-scoped by default, giving you the same single-instance guarantee with full testability, no global state, and proper lifecycle management. Manual singletons are harder to test and create tight coupling.

Keep Reading

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next