How do you implement a thread-safe singleton?

Asked constantlyintermediate1–10 yrs11 min readJava 5 (1.5)

An enum, or a static holder class. Both are lazy, both are thread-safe with no lock in your code, and the enum is the only one that also survives reflection and serialization. Double-checked locking works too, needs volatile, and is the version people write from memory and get wrong.

The Answer

Say this in the room. 45 seconds.

  • The lazy version everyone writes first — if (instance == null) instance = new X() — is check-then-act, so two threads can both pass the check.
  • synchronized on the accessor fixes it and puts a lock on every read forever.
  • Double-checked locking avoids that, requires volatile, and is broken without it. It is correct, and it is the version people misremember.
  • Initialization-on-demand holder is better: a private static nested class, initialised by the JVM on first use. Lazy, thread-safe, no lock you wrote, no volatile to forget.
  • An enum is better still, and is the only form that also survives reflection and serialization without extra code.
  • And the honest answer for most Java jobs: you are using Spring, so the container already gives you one instance per context — and that one is testable and replaceable, which a static singleton is not.

Understand It

The version everyone writes first

private static Config instance;

public static Config get() {
    if (instance == null) {          // two threads can both be here
        instance = new Config();
    }
    return instance;
}

Check, then act. Between the check and the assignment another thread can run the same check, and both construct.

A static field can only be raced once per JVM, at startup, so the bug has exactly one chance to happen and usually does not. Repeating the race makes it visible:

Compiled and run on this build — output varies between runs
int leakedRounds = 0, worst = 1;

for (int round = 0; round < ROUNDS; round++) {
    LazyHolder holder = new LazyHolder();
    Set<Object> seen = ConcurrentHashMap.newKeySet();
    CountDownLatch start = new CountDownLatch(1);

    Thread[] threads = new Thread[THREADS];
    for (int i = 0; i < THREADS; i++) {
        threads[i] = new Thread(() -> {
            try { start.await(); } catch (InterruptedException e) { return; }
            for (int n = 0; n < CALLS; n++) seen.add(holder.get());
        });
        threads[i].start();
    }
    start.countDown();
    for (Thread t : threads) t.join();

    if (seen.size() > 1) leakedRounds++;
    worst = Math.max(worst, seen.size());
}

System.out.println("  rounds run                     : " + ROUNDS);
System.out.println("  rounds with several instances  : " + leakedRounds);
System.out.println("  most instances seen in a round : " + worst);
System.out.println("  a singleton that is not single? " + (leakedRounds > 0 ? "yes" : "no"));
Output
  rounds run                     : 200
  rounds with several instances  : 7
  most instances seen in a round : 2
  a singleton that is not single? yes

A handful of rounds out of two hundred, which is exactly the point. A one-in-thirty startup race is invisible in testing, survives code review, and produces two connection pools or two caches on the one morning the machine was busy.

Note what it took to demonstrate: two hundred repetitions. That is a fair picture of the real risk — low probability, high cost, impossible to reproduce on demand.

The four ways to fix it, in order

1. Eager. private static final Config INSTANCE = new Config(); Correct, trivially, because class initialisation is thread-safe. The objection is that it is built whether used or not — which for most singletons is not a real cost, and this is a better default than its reputation suggests.

2. Synchronized accessor. Correct, and takes a lock on every call for the lifetime of the process to protect one assignment that happens once.

3. Double-checked locking. Correct only with volatile:

private static volatile Config instance;

public static Config get() {
    Config local = instance;
    if (local == null) {
        synchronized (Config.class) {
            local = instance;
            if (local == null) instance = local = new Config();
        }
    }
    return local;
}

Without volatile, another thread can see a non-null reference to an object whose constructor has not finished, because the write publishing the reference may be reordered before the writes initialising the fields. The local variable is not a micro-optimisation either — it avoids re-reading a volatile field on the common path.

Before Java 5 this could not be made correct at all, which is why so much written advice about it is wrong.

4. Initialization-on-demand holder. The one to reach for when you want lazy:

public class Config {
    private static class Holder {
        static final Config INSTANCE = new Config();
    }

    public static Config get() {
        return Holder.INSTANCE;
    }
}

The nested class is not initialised until Holder.INSTANCE is first referenced, and the JVM guarantees class initialisation happens once with proper publication. Lazy, thread-safe, no lock in your code, no volatile to forget. There is nothing to get wrong.

What still breaks it, and what does not

Thread safety is only one of the three ways a singleton stops being single. Reflection is the second:

Compiled and run on this build
Holder a = Holder.get();
Constructor<Holder> ctor = Holder.class.getDeclaredConstructor();
ctor.setAccessible(true);
Holder b = ctor.newInstance();
System.out.println("  holder: same instance after reflection? " + (a == b));

try {
    Constructor<EnumSingleton> ec =
        EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
    ec.setAccessible(true);
    ec.newInstance("HACK", 1);
    System.out.println("  enum  : reflection succeeded");
} catch (Exception e) {
    System.out.println("  enum  : " + e.getClass().getSimpleName() + " — " + e.getMessage());
}
Output
  holder: same instance after reflection? false
  enum  : IllegalArgumentException — Cannot reflectively create enum objects

A private constructor is not a security boundary. setAccessible(true) walks straight past it and the holder pattern hands over a second instance without complaint. The enum cannot be constructed reflectively at all — Constructor.newInstance refuses enums explicitly, in the JDK, by contract.

Serialization is the third:

Compiled and run on this build
Holder original = Holder.get();
Holder copy = roundTrip(original);
System.out.println("  holder: same instance after a round trip? " + (original == copy));

EnumSingleton e1 = EnumSingleton.INSTANCE;
EnumSingleton e2 = roundTrip(e1);
System.out.println("  enum  : same instance after a round trip? " + (e1 == e2));
Output
  holder: same instance after a round trip? false
  enum  : same instance after a round trip? true

Deserializing a Serializable singleton produces a new object, every time, silently. The class-based fix is private Object readResolve() { return Holder.INSTANCE; } — and remembering it forever, including when someone adds Serializable years later for an unrelated reason.

Enums are deserialized by name rather than reconstructed, so identity holds with nothing written by you.

That is the whole case for the enum singleton:

LazyThread-safeSurvives reflectionSurvives serialization
Eager static finalnoyesnoneeds readResolve
Synchronized accessoryesyes, with a lock per callnoneeds readResolve
Double-checked lockingyesyes, with volatilenoneeds readResolve
Holder classyesyesnoneeds readResolve
Enumon first useyesyesyes

The enum's cost is that it cannot extend a class, and that some people find INSTANCE.doThing() odd to read. Neither is usually decisive.

The answer that gets you the job

Everything above is worth knowing and mostly worth not using.

In a Spring application the container already holds one instance per context, which is not the same as one per JVM — and that difference is the follow-up question. A container-managed bean is an ordinary object: constructible in a test with stubs, replaceable by configuration, with no static state to leak between tests.

A hand-written singleton is the opposite. It is global mutable state reachable from anywhere, it cannot be substituted, and in a test suite it carries whatever the previous test did into the next one. That is why "how would you test code that calls Config.get()" is the question after this one, and the honest answer is that you would rather it were injected.

So: know the enum and the holder, use the container, and reserve the hand-written form for genuinely process-wide, stateless things.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "How do you implement a thread-safe singleton?" An enum, or an initialization-on-demand holder class. Both are lazy and thread-safe with no lock in your code. Double-checked locking also works, needs volatile, and is the version most often written wrong.

2. "What is wrong with the lazy null check?" Check-then-act — two threads can both see null and both construct. It happens once per JVM at startup, so it is rare, invisible in tests, and produces two of whatever the singleton was holding.

3. "Why does double-checked locking need volatile?" Without it the write publishing the reference can be reordered ahead of the writes initialising the object, so another thread can see a non-null reference to a half-constructed instance. Ordering, not visibility.

4. "How does the holder class work with no synchronization?" The nested class is initialised on first reference, and the JVM guarantees class initialisation runs once with correct publication. You inherit a lock you did not write.

5. "Can reflection break your singleton?" Yes — setAccessible(true) on the private constructor produces a second instance, for every form except the enum. Constructor.newInstance refuses to construct enums.

6. "What happens if the singleton is serialized?" Deserialization creates a new object, silently, for every class-based form. Add readResolve returning the instance. Enums are deserialized by name, so identity holds with nothing extra.

7. "Why is the enum considered the safest?" It is the only form that is thread-safe, reflection-proof and serialization-proof at once, with no code from you. Its limit is that it cannot extend a class.

8. "Is a Spring singleton bean the same thing?" No. One instance per application context, not per JVM — and it is an ordinary object, so it is testable and replaceable. That is why it beats a static singleton in almost every application.

Code traps

Trap A — predict before you run:

public class Registry {
    private static Registry instance;

    public static synchronized Registry get() {
        if (instance == null) instance = new Registry();
        return instance;
    }

    public Map<String, String> settings = new HashMap<>();
}
Answer

The construction is correct — synchronized makes it safe, at the price of a lock on every call forever.

The actual bug is the last line: a public mutable HashMap on a process-wide object. Every thread now shares an unsynchronised map, and this is the failure people miss because they were looking at the construction. A thread-safe singleton says nothing about the thread safety of what it holds — the same lesson as a thread-safe map holding a plain ArrayList.

Trap B:

public class Cache implements Serializable {
    private static final Cache INSTANCE = new Cache();
    private Cache() { }
    public static Cache get() { return INSTANCE; }
}
Answer

Correct and thread-safe until someone serializes it — then deserialization produces a second Cache, silently, and two parts of the system hold different ones.

Add private Object readResolve() { return INSTANCE; }. Note that implements Serializable was probably added years later by someone making an unrelated class work, with no idea it broke this one — which is the argument for the enum, where there is nothing to remember.

Trap C:

public class Counter {
    private static Counter instance;
    private int count;

    public static Counter get() {
        if (instance == null) {
            synchronized (Counter.class) {
                if (instance == null) instance = new Counter();
            }
        }
        return instance;
    }

    public void increment() { count++; }
}
Answer

Two bugs, and the second survives review.

The field is not volatile, so the double-checked locking is broken — a caller can obtain a reference to a Counter whose constructor has not finished.

And count++ is a read-modify-write on a shared object with no synchronisation at all, so increments are lost however carefully the instance was created. Fixing the singleton does nothing for it: correct construction and correct use are separate problems.

Common wrong answers

Said in interviewsReality
"Make get() synchronized — done."Correct, and a lock on every call forever to guard one assignment.
"Double-checked locking is the standard fix."Only with volatile, and it was unfixable before Java 5.
"A private constructor guarantees one instance."setAccessible(true) walks past it. Only an enum refuses.
"Serialization is fine, it's the same object."A new object every round trip, unless you wrote readResolve.
"A Spring singleton bean is a singleton."One per context, not per JVM — and it is testable, which the static form is not.
"Eager initialisation wastes memory."Usually negligible, and it is correct with no ceremony.

Check Yourself

Q1. Why is the lazy null check dangerous even though the bug is rare?

AnswerThe race happens once per JVM, at startup, so it will not reproduce in a test and will not appear in review — and when it does happen you get two of whatever the singleton was holding, such as two connection pools or two caches, with no error.

Q2. Name the two things an enum singleton gives you that a holder class does not.

AnswerReflection safety — Constructor.newInstance refuses to construct enum objects — and serialization safety, because enums are deserialized by name rather than reconstructed. A holder class needs readResolve for the second and cannot be protected from the first.

Q3. Your singleton is constructed correctly. What have you still not established?

AnswerThat using it is safe. A correctly created singleton holding a mutable HashMap, or with a count++ method, is shared mutable state reachable from every thread. Construction and use are separate problems, and the pattern addresses only one of them.


Practice

TierExerciseTime
Warm-upBreak a singleton three ways10 min
ChallengeFive singletons, rank them20 min
ProductionThe cache that existed twice40 min
InterviewFull round replay10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 5 (1.5)

    The rewritten memory model made double-checked locking correct, provided the field is volatile — and gave enum constants a guaranteed-safe publication.

    Before Java 5 (1.5): Double-checked locking was broken however it was written. A thread could observe a non-null reference to an object whose constructor had not finished, and no keyword available at the time fixed it.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.