Design Patterns «Prev Next»

Lesson 13Robust Singleton Design
ObjectiveEvaluate thread-safe Singleton implementations and explain why correctness depends on the required lifecycle and scope.

Is There a Perfect Singleton Design?

There is no universally perfect Singleton. An implementation can be correct for a defined language runtime, lifecycle, concurrency model, and deployment boundary. It can still be the wrong architecture if global access hides dependencies or if the application later needs more than one instance.

A robust evaluation separates two concerns:

  1. Creation safety: can concurrent callers observe more than one instance or a partially initialized object?
  2. Object safety: can concurrent operations corrupt the mutable state held by that instance?

Making getInstance() safe addresses only the first concern.

What Is Wrong with the Basic Lazy Version?

public final class ChocolateBoiler {
    private static ChocolateBoiler instance;

    private ChocolateBoiler() {
    }

    public static ChocolateBoiler getInstance() {
        if (instance == null) {
            instance = new ChocolateBoiler();
        }
        return instance;
    }
}

Two threads can both read instance == null before either assignment becomes visible to the other. Each can construct an object, violating the intended process-local uniqueness. The code also provides no explicit safe-publication mechanism for the initialized reference.

Option 1: Eager Initialization

public final class ChocolateBoiler {
    private static final ChocolateBoiler INSTANCE =
            new ChocolateBoiler();

    private ChocolateBoiler() {
    }

    public static ChocolateBoiler getInstance() {
        return INSTANCE;
    }
}

Java class initialization safely publishes the instance. This is often the clearest implementation when construction is inexpensive and failure during class initialization is acceptable. The object is created when the class is initialized, even if no client later needs it.

Option 2: Synchronized Accessor

public final class ChocolateBoiler {
    private static ChocolateBoiler instance;

    private ChocolateBoiler() {
    }

    public static synchronized ChocolateBoiler getInstance() {
        if (instance == null) {
            instance = new ChocolateBoiler();
        }
        return instance;
    }
}

Synchronizing the accessor makes lazy creation straightforward and correct. Every call acquires the class monitor. That overhead is frequently insignificant, so measurement should precede a more complicated optimization. The modifier order public static synchronized is conventional; changing the order of Java modifiers does not change the method's behavior.

Option 3: Initialization-on-Demand Holder

public final class ChocolateBoiler {
    private ChocolateBoiler() {
    }

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

    public static ChocolateBoiler getInstance() {
        return Holder.INSTANCE;
    }
}

The nested holder is initialized when it is first referenced. The Java Virtual Machine provides the synchronization and safe publication. This gives lazy initialization without a lock on every accessor call and is usually preferable to handwritten double-checked locking.

Option 4: Enum Singleton

public enum ChocolateBoiler {
    INSTANCE;

    public void fill() {
        // domain operation
    }
}

An enum offers concise construction control and built-in serialization semantics. It also resists ordinary reflective construction. It is a strong choice when an enum-shaped API fits the design, but it still creates globally reachable state and cannot extend another class.

Double-Checked Locking, When Legacy Constraints Require It

public final class ChocolateBoiler {
    private static volatile ChocolateBoiler instance;

    private ChocolateBoiler() {
    }

    public static ChocolateBoiler getInstance() {
        ChocolateBoiler current = instance;
        if (current == null) {
            synchronized (ChocolateBoiler.class) {
                current = instance;
                if (current == null) {
                    current = new ChocolateBoiler();
                    instance = current;
                }
            }
        }
        return current;
    }
}

The volatile declaration is essential because it supplies the visibility and ordering guarantees required by this idiom. Double-checked locking is more difficult to review than the holder idiom and should not be selected merely because it looks faster.

Construction Safety Does Not Protect Mutable State

Suppose ChocolateBoiler tracks empty and boiled flags. A safely constructed Singleton can still experience race conditions when two threads call fill(), boil(), and drain(). Domain operations need their own concurrency policy, such as:

  • immutable state transitions;
  • synchronized compound operations;
  • an internal lock that protects related fields;
  • atomic variables for genuinely independent values; or
  • single-threaded message processing.

The chosen policy should protect invariants, not individual getters and setters in isolation.

Lifecycle and Platform Escape Routes

A process-local Java implementation may still produce more than one logical instance in situations the simple pattern does not control:

  • different class loaders can load separate copies of the class;
  • multiple application processes each own their own static state;
  • cloning or custom serialization code can create additional objects;
  • reflection can bypass ordinary access rules for non-enum classes; and
  • test suites may retain state between tests because the instance outlives one test case.

Do not add defensive machinery without a real threat model. Document the supported boundary and test that boundary. If uniqueness must span processes, use external coordination instead of relying on a static field.

Reframing the Diagnostic Question

For the original unsynchronized lazy ChocolateBoiler, synchronizing getInstance() is the smallest listed correction for concurrent creation. It is not proof of a “perfect” Singleton. A complete review must also ask:

  1. Is process-local uniqueness a genuine requirement?
  2. Is lazy initialization necessary?
  3. How is mutable state protected after construction?
  4. How are cleanup, failure, and test isolation handled?
  5. Would dependency injection expose the dependency more clearly?

Decision Checklist

  • Prefer eager initialization when construction is cheap and unconditional.
  • Prefer the holder idiom for a conventional lazily initialized Java class.
  • Consider an enum when its API and lifecycle fit the domain.
  • Use a synchronized accessor when simplicity matters more than avoiding an unmeasured lock cost.
  • Avoid double-checked locking unless constraints justify the additional complexity.
  • Prefer dependency injection when the real requirement is one configured service instance rather than global access.

The most robust Singleton is not the version with the cleverest accessor. It is the version whose uniqueness boundary, concurrency behavior, lifecycle, and architectural cost are explicit.

SEMrush Software 13 SEMrush Banner 13