| Lesson 13 | Robust Singleton Design |
| Objective | Evaluate thread-safe Singleton implementations and explain why correctness depends on the required lifecycle and scope. |
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:
Making getInstance() safe addresses only the first concern.
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.
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.
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.
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.
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.
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.
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:
The chosen policy should protect invariants, not individual getters and setters in isolation.
A process-local Java implementation may still produce more than one logical instance in situations the simple pattern does not control:
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.
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:
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.