The previous article in this course built the same shape twice without naming it: an interface with several implementations, chosen by whoever constructs the object. That shape has a name, and it is one of the five in this article. Design patterns are mostly not new ideas — they are names for structures you have already written, which is exactly what makes them worth learning and exactly what makes them easy to over-apply.
Most articles on this subject are five UML diagrams and five toy classes called AnimalFactory. This one is not. Every pattern here is demonstrated inside the JDK itself, with the runtime class names printed by a program that was actually run, and every pattern gets a concrete account of where it turns into damage. Where a broken version is described, the broken version was compiled and run and its real output or exception is quoted.
![]()
Every line of program output, every exception and every javac message below was produced on OpenJDK 21.0.6 for arm64. Where a claim is a design judgement rather than something a program can decide, it says so.
What a pattern is, and what it costs
A pattern is a name for a recurring arrangement of objects and the problem it solves. It is not a library, not an annotation and not a language feature: javac has no idea whether the class you just wrote is a singleton. That means a pattern can never be "correct" on its own — it is correct relative to a change you expect to have to make.
The five below are the ones you will meet first, and all five are already in the JDK you are compiling against.
| Pattern | The problem it names | Where the JDK already does it |
|---|---|---|
| Singleton | one instance, and who is allowed to create it | Runtime.getRuntime(), Collections.emptyList() |
| Factory | the caller should not have to name the implementation | Integer.valueOf, List.of, EnumSet.noneOf, Calendar.getInstance |
| Builder | too many constructor parameters to keep straight | StringBuilder, HttpRequest.newBuilder() |
| Observer | one change, several parties that want to know | listener interfaces everywhere, plus the deprecated java.util.Observable |
| Strategy | the algorithm itself is a parameter | Comparator, Runnable, ThreadFactory |
The two identity checks in the first row are real:
System.out.println("Runtime.getRuntime() == Runtime.getRuntime() ? "
+ (Runtime.getRuntime() == Runtime.getRuntime()));
System.out.println("Collections.emptyList() == Collections.emptyList() ? "
+ (Collections.emptyList() == Collections.emptyList()));
Runtime.getRuntime() == Runtime.getRuntime() ? true
Collections.emptyList() == Collections.emptyList() ? true
Every pattern costs something countable: extra class files, extra objects, extra indirection a reader has to walk through, or a reference the garbage collector can no longer take. This article names the number wherever there is one to name.
Singleton: the interesting part is initialisation
A singleton is a class with exactly one instance and a global point of access to it. Stated like that it sounds trivial, and the trivial part — a private constructor and a static field — really is trivial. The interesting question is when that instance gets created and what happens if two threads ask for it at the same time.
The eager singleton
The simplest form initialises the field when the class initialises.
final class Config {
static { System.out.println("Config class initialised"); }
static final Config INSTANCE = new Config();
private Config() { System.out.println("Config constructed"); }
String get(String key) { return "value-of-" + key; }
}
public class Eager {
public static void main(String[] args) {
System.out.println("main started");
System.out.println(Config.INSTANCE.get("port"));
System.out.println("one object? " + (Config.INSTANCE == Config.INSTANCE));
}
}
main started
Config class initialised
Config constructed
value-of-port
one object? true
Read the order. main started prints first, which means Config was not initialised when the program started — the JVM initialises a class on first active use, not at load time. So the "eager" singleton is already lazy at the granularity that usually matters, and it is thread-safe for free, because the JVM guarantees a class initialiser runs exactly once.
Its one real limitation: the instance is created the first time anything touches Config, including a read of an unrelated static constant on the same class. If construction is expensive and some code paths never need it, that is a reason to want finer-grained laziness.
The lazy singleton, and the race it loses
The obvious way to get that laziness is a null check.
final class LazyConfig {
static final AtomicInteger CONSTRUCTED = new AtomicInteger();
private static LazyConfig instance;
private LazyConfig() {
CONSTRUCTED.incrementAndGet();
// A real constructor does work; this stands in for it.
long acc = 0;
for (int i = 0; i < 200_000; i++) acc += i;
if (acc == -1) throw new AssertionError();
}
static LazyConfig getInstance() {
if (instance == null) { // check
instance = new LazyConfig(); // then act
}
return instance;
}
}
Eight threads released together from a CyclicBarrier, each calling getInstance() once, collecting the results into an identity-free set:
int threads = 8;
var barrier = new CyclicBarrier(threads);
var seen = ConcurrentHashMap.<LazyConfig>newKeySet();
var pool = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
pool.execute(() -> {
try { barrier.await(); } catch (Exception e) { throw new RuntimeException(e); }
seen.add(LazyConfig.getInstance());
});
}
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
System.out.println("constructor ran = " + LazyConfig.CONSTRUCTED.get() + " time(s)");
System.out.println("distinct instances = " + seen.size());
System.out.println("singleton held? = " + (seen.size() == 1));
constructor ran = 8 time(s)
distinct instances = 8
singleton held? = false
Eight objects, on five consecutive runs. The class that promised one instance produced one per thread. if (instance == null) reads the field, and instance = new LazyConfig() writes it, and nothing joins those two operations into one step, so every thread can read null before any thread has written. This is check-then-act, the most common shape of concurrency bug there is, and here it is not a rare interleaving — with all eight threads released at a barrier it happens every time.
Marking getInstance() synchronized fixes it correctly. The usual objection is that every subsequent call then pays for a lock it does not need, which is what motivated the next form.
Double-checked locking, and why volatile is not optional
Double-checked locking checks the field without a lock, takes the lock only if it looks unset, and checks again inside.
final class Dcl {
private static volatile Dcl instance; // volatile is not decoration
private Dcl() { /* ... */ }
static Dcl getInstance() {
Dcl local = instance; // one volatile read on the fast path
if (local == null) {
synchronized (Dcl.class) {
local = instance;
if (local == null) instance = local = new Dcl();
}
}
return local;
}
}
Run through the same eight-thread barrier:
constructor ran = 1 time(s)
distinct instances = 1
singleton held? = true
The volatile is the whole reason this version is correct, and it is worth being precise about why. new Dcl() is not one operation. It allocates memory, runs the constructor to fill in the fields, and assigns the reference to instance. Without volatile, nothing in the Java memory model forbids a thread from observing the assignment to instance before it observes the constructor's writes to that object's fields. A second thread would then take the fast path, see a non-null reference, and use an object whose fields are still at their default values. That is called unsafe publication. Declaring the field volatile makes the write to instance a release and every read of it an acquire, which orders the constructor's writes before any reader can see the reference.
Being honest about the demonstration: I did not reproduce unsafe publication, and I do not think you reliably can on demand. It depends on the processor's memory model and on what the JIT decided to do on that run, so a program that "proves" it usually proves only that this machine happened to be strict. The argument for volatile here is an argument from the memory model, not from a measurement — and that is exactly why the next form is the one to reach for.
The initialisation-on-demand holder
A nested class is initialised on its first use, not on the outer class's. That single fact gives laziness and thread safety with no lock in your code at all.
final class Settings {
static { System.out.println(" [Settings initialised]"); }
private Settings() { System.out.println(" [Settings constructed]"); }
private static class HolderOf {
static { System.out.println(" [Settings.HolderOf initialised]"); }
static final Settings INSTANCE = new Settings();
}
static Settings getInstance() { return HolderOf.INSTANCE; }
int port() { return 8080; }
}
main started
touching the outer class: Settings
still no instance yet
first getInstance():
[Settings initialised]
[Settings.HolderOf initialised]
[Settings constructed]
second getInstance():
same instance? true, port 8080
Two things are proved by that trace. Reading Settings.class.getName() printed nothing from either initialiser, because a class literal is not an active use and does not trigger initialisation. And HolderOf was initialised on the first getInstance() and not before — the construction happened exactly once, at the point of first demand.
Running the same program under -Xlog:class+init=info:stdout:none and filtering to the two lines that matter confirms it from the JVM's own side:
first getInstance():
396 Initializing 'Settings' (0x0000000501000bf0) by thread "main"
[Settings initialised]
397 Initializing 'Settings$HolderOf' (0x0000000501000bf0) by thread "main"
[Settings.HolderOf initialised]
[Settings constructed]
There is no lock in that source file, and it is still correct under any number of threads, because the JVM takes the class-initialisation lock for you and holds it until the initialiser finishes. Every other thread that arrives while a class is being initialised blocks and then sees the finished result.

There is a fifth form — a single-constant enum — which the language guarantees is unique against both serialization and reflection. That form is covered in depth in the advanced enums article in this series, including the exact exception reflection throws, so it is not repeated here.
Where a singleton becomes damage. Not in its initialisation, which is what everything above is about, but in what it does to the code that uses it. A singleton is global mutable state with a nicer name, and the harm is that it is invisible at the call site:
final class SequenceGenerator {
private static final SequenceGenerator INSTANCE = new SequenceGenerator();
private int next = 1;
private SequenceGenerator() {}
static SequenceGenerator getInstance() { return INSTANCE; }
synchronized int next() { return next++; }
}
final class OrderService {
// Nothing in this signature says a shared counter is involved.
String create(String item) {
return "ORD-" + SequenceGenerator.getInstance().next() + "/" + item;
}
}
Two checks, run in the declared order and then in the reverse order, in the same JVM:
-- declared order --
testFirstOrderIsOne -> ORD-1/keyboard
testSecondOrder -> ORD-2/mouse
-- reordered --
testSecondOrder -> ORD-1/mouse
testFirstOrderIsOne -> ORD-2/keyboard
The answer depends on execution order, and there is no seam to reset the counter: the field is private static final and the constructor is private, so nothing outside the class can put it back. OrderService.create takes one argument and secretly depends on process-wide state. Passing the generator in as a constructor parameter costs one field and removes the whole problem, which is the same "invert the dependency you actually need to replace" judgement the previous article ended on.
Factory: naming, caching, and returning something else
A constructor has three limitations the language gives you no way around: it must be named after the class, it must return an instance of exactly that class, and it must return a new object. A static factory method has none of the three.
Static factory methods versus constructors
The naming limitation bites immediately. Cartesian and polar coordinates are both two double values:
final class Point {
private final double x, y;
Point(double x, double y) { this.x = x; this.y = y; }
// Same signature after the parameter names are gone.
Point(double r, double theta) {
this.x = r * Math.cos(theta);
this.y = r * Math.sin(theta);
}
}
TwoCtors.java:7: error: constructor Point(double,double) is already defined in class Point
Point(double r, double theta) {
^
1 error
Parameter names are not part of a signature, so the two constructors are the same constructor. Static factory methods have names, so there is no clash — and while you are there, the third limitation goes too:
final class Point {
private final double x, y;
private Point(double x, double y) { this.x = x; this.y = y; }
static Point ofCartesian(double x, double y) { return new Point(x, y); }
static Point ofPolar(double r, double theta) {
return new Point(r * Math.cos(theta), r * Math.sin(theta));
}
private static final Point ORIGIN = new Point(0, 0);
/** Not required to return a new object. */
static Point origin() { return ORIGIN; }
@Override public String toString() {
return String.format("(%.2f, %.2f)", x, y);
}
}
ofCartesian(3, 4) = (3.00, 4.00)
ofPolar(5, PI/2) = (0.00, 5.00)
origin() == origin() ? true
The call site now says which interpretation it meant, and origin() hands back the same object every time because nothing forces it not to.
Integer.valueOf and the cache
The JDK's most-used static factory is the one autoboxing calls for you. Integer.valueOf keeps a cache of the boxed values in the range -128 to 127 and returns the cached object when the argument falls in it.
Integer a = Integer.valueOf(127), b = Integer.valueOf(127);
Integer c = Integer.valueOf(128), d = Integer.valueOf(128);
System.out.println("valueOf(127) == valueOf(127) ? " + (a == b));
System.out.println("valueOf(128) == valueOf(128) ? " + (c == d));
Set<Integer> ids = Collections.newSetFromMap(new IdentityHashMap<>());
for (int pass = 0; pass < 3; pass++)
for (int i = -128; i <= 127; i++) ids.add(Integer.valueOf(i));
System.out.println("768 calls over -128..127 produced " + ids.size() + " objects");
valueOf(127) == valueOf(127) ? true
valueOf(128) == valueOf(128) ? false
valueOf(-128) == valueOf(-128)? true
valueOf(-129) == valueOf(-129)? false
768 calls over -128..127 produced 256 objects
768 calls over 1000..1255 produced 768 objects
Same number of calls, same code, three times as many objects on one side of the boundary as on the other. A constructor could not do that, which is precisely why the constructor was taken away: compiling new Integer(127) under -Xlint:deprecation gives
Ctor.java:3: warning: [removal] Integer(int) in Integer has been deprecated and marked for removal
Integer boxed = new Integer(127);
^
1 warning
The identity results are also the reason == on boxed types is a trap. Nothing about Integer promises identity; the cache is an implementation detail that happens to be specified for that range, and the correct comparison is equals or an unboxed int.

One factory, several implementation classes
The second limitation — returning exactly the declared class — is the one that changes designs. A factory can return a different class per call and the caller never knows.
System.out.println("List.of() -> " + List.of().getClass().getName());
System.out.println("List.of(1) -> " + List.of(1).getClass().getName());
System.out.println("List.of(1,2) -> " + List.of(1, 2).getClass().getName());
System.out.println("List.of(1,2,3) -> " + List.of(1, 2, 3).getClass().getName());
System.out.println("Calendar th -> " + Calendar.getInstance(
Locale.forLanguageTag("th-TH-u-ca-buddhist")).getClass().getName());
System.out.println("Calendar ja -> " + Calendar.getInstance(
Locale.forLanguageTag("ja-JP-u-ca-japanese")).getClass().getName());
System.out.println("Calendar us -> " + Calendar.getInstance(Locale.US).getClass().getName());
List.of() -> java.util.ImmutableCollections$ListN
List.of(1) -> java.util.ImmutableCollections$List12
List.of(1,2) -> java.util.ImmutableCollections$List12
List.of(1,2,3) -> java.util.ImmutableCollections$ListN
new ArrayList<>() -> java.util.ArrayList
Calendar th -> sun.util.BuddhistCalendar
Calendar ja -> java.util.JapaneseImperialCalendar
Calendar us -> java.util.GregorianCalendar
List12 stores one or two elements in two fields with no array at all; ListN has an array. The declared type is List in every case, none of those classes is public, and new ArrayList() is the only line that could not have chosen.
EnumSet picks on a different axis — the number of constants in the enum. Two enums, one with 64 constants and one with 65, and the same factory call:
Small has 64 constants -> java.util.RegularEnumSet
Big has 65 constants -> java.util.JumboEnumSet
same factory call, same declared type: java.util.EnumSet
The boundary is exactly 64 because RegularEnumSet keeps the whole set in one long, one bit per constant. Add a sixty-fifth constant and it no longer fits, so the factory hands back the array-backed implementation instead. Nothing at the call site changes.
There is a second, more literal Factory Method: an abstract method on a base class that lets a subclass decide which object the base class's algorithm works with.
abstract class Report {
protected abstract Formatter createFormatter(); // the factory method
final String render(List<String> rows) {
Formatter f = createFormatter();
StringBuilder sb = new StringBuilder(f.header());
for (String r : rows) sb.append(f.row(r));
return sb.append(f.footer()).toString();
}
}
CsvReport -> value\nkeyboard\nmouse\n
JsonReport -> ["keyboard","mouse"]
formatter chosen by CsvReport = CsvReport$1
formatter chosen by JsonReport = JsonReport$1
render is final and identical for both; only the object it is handed differs. Calendar.getInstance above is the JDK's own version of the same idea, with the locale rather than a subclass making the choice.
Where a factory becomes damage. When there is one implementation and there always will be. ClockFactory.create() returning new SystemClock() adds an interface, a factory class and a level of indirection, and buys exactly nothing: the reader has to open three files to find one new. This is a design judgement rather than something a program decides, and the test I use is whether a second implementation exists now — not whether one is imaginable. A static factory method on the class itself is different and almost always worth it, because naming and caching pay off immediately; a separate factory class for a single type usually does not.
Builder: from telescoping constructors to a builder the compiler checks
The telescoping constructor
When a class has more optional fields than a constructor can carry legibly, the usual response is a ladder of constructors that delegate to the widest one.
final class HttpClientConfig {
HttpClientConfig(String host, int port) { this(host, port, 10, 30, true, true); }
HttpClientConfig(String host, int port, int connectTimeoutSeconds, int readTimeoutSeconds) {
this(host, port, connectTimeoutSeconds, readTimeoutSeconds, true, true);
}
HttpClientConfig(String host, int port, int connectTimeoutSeconds,
int readTimeoutSeconds, boolean followRedirects, boolean useCompression) {
/* ... assign six fields ... */
}
}
The failure mode is not that it is ugly. It is that adjacent parameters of the same type are interchangeable to the compiler:
// Intended: connect 5s, read 60s, follow redirects, no compression.
var cfg = new HttpClientConfig("api.example.com", 443, 60, 5, false, true);
System.out.println(cfg);
host=api.example.com port=443 connect=60s read=5s redirects=false compression=true
Both pairs are swapped. It compiles without a warning, runs without an exception, and produces a client with a five-second read timeout that will fail on any slow response. Nothing in the type system can help, because (int, int) and (boolean, boolean) say nothing about which is which.
The builder
A builder replaces the positional arguments with named calls on a mutable object, and produces the immutable one at the end.
final class ClientConfig {
private final String host; private final int port;
private final int connectTimeoutSeconds; private final int readTimeoutSeconds;
private final boolean followRedirects; private final boolean useCompression;
private ClientConfig(Builder b) {
this.host = b.host; this.port = b.port;
this.connectTimeoutSeconds = b.connectTimeoutSeconds;
this.readTimeoutSeconds = b.readTimeoutSeconds;
this.followRedirects = b.followRedirects; this.useCompression = b.useCompression;
}
static Builder builder() { return new Builder(); }
static final class Builder {
private String host = "localhost"; private int port = 80;
private int connectTimeoutSeconds = 10; private int readTimeoutSeconds = 30;
private boolean followRedirects = true; private boolean useCompression = true;
Builder host(String v) { this.host = v; return this; }
Builder port(int v) { this.port = v; return this; }
Builder connectTimeoutSeconds(int v) { this.connectTimeoutSeconds = v; return this; }
Builder readTimeoutSeconds(int v) { this.readTimeoutSeconds = v; return this; }
Builder followRedirects(boolean v) { this.followRedirects = v; return this; }
Builder useCompression(boolean v) { this.useCompression = v; return this; }
ClientConfig build() {
if (port < 1 || port > 65535)
throw new IllegalArgumentException("port out of range: " + port);
return new ClientConfig(this);
}
}
}
var cfg = ClientConfig.builder()
.host("api.example.com")
.port(443)
.connectTimeoutSeconds(5)
.readTimeoutSeconds(60)
.followRedirects(false)
.build();
System.out.println(cfg);
try {
ClientConfig.builder().host("api.example.com").port(70000).build();
} catch (IllegalArgumentException e) {
System.out.println("caught: " + e);
}
host=api.example.com port=443 connect=5s read=60s redirects=false compression=true
caught: java.lang.IllegalArgumentException: port out of range: 70000
Two things changed. The five and the sixty cannot be swapped, because each one is written next to the name of the field it sets. And build() is a single place where cross-field validation can run, which a constructor ladder does not give you without repeating the check.

Every method except build() returns this, which is what makes the chain work and what makes the whole chain one construction rather than several. StringBuilder is the same shape and says so out loud:
StringBuilder sb = new StringBuilder();
Object same = sb.append("GET ").append('/').append("orders").append(' ').append(200);
System.out.println("StringBuilder result = " + sb);
System.out.println("append() returns this? = " + (same == sb));
StringBuilder result = GET /orders 200
append() returns this? = true
HttpRequest.newBuilder() is the modern JDK's version, and it also demonstrates the factory point from the previous section — the builder and the product are both hidden implementation classes:
builder class = jdk.internal.net.http.HttpRequestBuilderImpl
built request = GET https://example.com/orders
built class = jdk.internal.net.http.ImmutableHttpRequest
newBuilder().build() -> java.lang.IllegalStateException: uri is null
That last line is the weakness of an ordinary builder. A URI is required, and forgetting it is a runtime failure, not a compile error. Our own builder has the same hole in a quieter form:
host=localhost port=80 connect=10s read=30s redirects=true compression=true
That is ClientConfig.builder().build() — no host, no port, no complaint, a fully-formed object pointing at nothing.
The staged builder the compiler checks
The hole is closable. Give each required field its own interface, and have each setter return the interface for the next stage, so build() does not exist on the type you are holding until the required fields have been set.
final class Endpoint {
private final String host; private final int port; private final int timeoutSeconds;
private Endpoint(String host, int port, int timeoutSeconds) {
this.host = host; this.port = port; this.timeoutSeconds = timeoutSeconds;
}
/** Stage 1: the only thing you can do is name a host. */
interface NeedsHost { NeedsPort host(String host); }
/** Stage 2: the only thing you can do is name a port. */
interface NeedsPort { Ready port(int port); }
/** Stage 3: optional settings, and now build() exists. */
interface Ready { Ready timeoutSeconds(int s); Endpoint build(); }
static NeedsHost builder() { return new Stages(); }
private static final class Stages implements NeedsHost, NeedsPort, Ready {
private String host; private int port; private int timeoutSeconds = 30;
public NeedsPort host(String host) { this.host = host; return this; }
public Ready port(int port) { this.port = port; return this; }
public Ready timeoutSeconds(int s) { this.timeoutSeconds = s; return this; }
public Endpoint build() { return new Endpoint(host, port, timeoutSeconds); }
}
@Override public String toString() {
return host + ":" + port + " timeout=" + timeoutSeconds + "s";
}
}
api.example.com:443 timeout=30s
db.internal:5432 timeout=5s
One class implements all three interfaces, so there is exactly one object at runtime; the staging exists only in the types the caller can see. And the mistake that the ordinary builder accepted silently now does not compile:
Endpoint e = Endpoint.builder().build(); // no host, no port
Endpoint f = Endpoint.builder().host("a").build(); // no port
StagedBad.java:3: error: cannot find symbol
Endpoint e = Endpoint.builder().build(); // no host, no port
^
symbol: method build()
location: interface NeedsHost
StagedBad.java:4: error: cannot find symbol
Endpoint f = Endpoint.builder().host("a").build(); // no port
^
symbol: method build()
location: interface NeedsPort
2 errors
The cost is one interface per required field and a fixed order the caller must follow, which is why this is worth doing for a type with two or three genuinely required fields and not for one with eight.
Where a builder becomes damage. When there is nothing to get wrong. Three fields, all required, no optional ones:
record Money(String currency, long amount, int scale) {}
Money[currency=VND, amount=250000, scale=0]
The builder version of the same type produces the same line from 21 source lines instead of 7 and three class files instead of two. A record gives you the constructor, equals, hashCode and toString, and a three-argument constructor is still readable at the call site. My rule of thumb — and this is judgement, not measurement — is that a builder starts paying somewhere around four or five parameters, or as soon as several of them are optional, or as soon as two adjacent parameters share a type.
Observer: registration, notification, and two things that go wrong
The observer pattern lets an object announce that something happened without knowing who cares. The subject keeps a list of listeners; interested parties add themselves to it; a change walks the list.
Registration and notification
interface PriceListener {
void onPrice(String symbol, long cents);
}
final class PriceFeed {
private final List<PriceListener> listeners = new ArrayList<>();
void addListener(PriceListener l) { listeners.add(l); }
void removeListener(PriceListener l) { listeners.remove(l); }
int listenerCount() { return listeners.size(); }
void publish(String symbol, long cents) {
for (PriceListener l : listeners) l.onPrice(symbol, cents);
}
}
listeners = 3
publish AAPL 19250:
chart : AAPL 19250
audit : AAPL 19250
alerts : AAPL 19250
PriceFeed names no listener and imports nothing about charts or auditing. That is the whole benefit, and it is real. Both of the problems below come from the same fifteen lines.
Before them, the historical note: java.util.Observer and java.util.Observable are still present on OpenJDK 21 and still work. javap -v shows the annotation they carry:
Deprecated: true
RuntimeVisibleAnnotations:
0: #88(#89=s#90)
java.lang.Deprecated(
since="9"
)
Deprecated since Java 9, and — importantly — not marked forRemoval, so they compile and run today with a warning. They are still worth avoiding: Observable is a class, so a subject must spend its one inheritance slot on it, and setChanged() is protected, which means you cannot even fire an event without subclassing.
@SuppressWarnings("deprecation")
class Feed extends Observable {
void publish(String item) {
setChanged(); // protected: only a subclass can call it
notifyObservers(item);
}
}
notified with: build-42
observer count = 1
The listener that is never collected
The subject holds a strong reference to every registered listener, and that reference is exactly as long-lived as the subject. A screen that registers itself and is closed without unregistering is not garbage:
/** A screen that registers itself and is never unregistered. */
final class ChartScreen implements PriceListener2 {
private final byte[] pixels = new byte[4 * 1024 * 1024]; // 4 MB of retained state
public void onPrice(String symbol, long cents) { pixels[0] = 1; }
}
ChartScreen screen = new ChartScreen();
feed.addListener(screen);
WeakReference<ChartScreen> ref = new WeakReference<>(screen);
screen = null; // the screen is closed; nothing else refers to it
System.gc(); Thread.yield(); System.gc();
System.out.println("screen closed, feed still holds " + feed.listenerCount() + " listener(s)");
System.out.println("collected? " + (ref.get() == null));
feed.removeListener(ref.get());
System.gc(); Thread.yield(); System.gc();
System.out.println("after removeListener, feed holds " + feed.listenerCount() + " listener(s)");
System.out.println("collected? " + (ref.get() == null));
screen closed, feed still holds 1 listener(s)
collected? false
after removeListener, feed holds 0 listener(s)
collected? true
Same on five consecutive runs. System.gc() is a request rather than a command, so the second collected? true is not guaranteed by the specification — but the first collected? false is, and it is the half that matters: while the feed holds the reference, no collector may take the object.
Four megabytes per leaked screen, and the leak scales with how many times the screen was opened. The version of this bug that is genuinely hard to fix is registering a lambda inline, because then there is no reference to pass to removeListener later. The fixes are ordinary: give the registration a lifetime that matches the listener's, return a handle from addListener that unregisters when closed, or hold the listeners weakly and accept that a listener nobody else references may stop being notified.
The listener that throws
The loop has no isolation. One listener that throws ends the broadcast for everybody after it.
void publishNaive(String symbol, long cents) {
for (PriceListener3 l : listeners) l.onPrice(symbol, cents);
}
publishNaive:
chart : AAPL 19250
publish() threw: java.lang.IllegalStateException: audit sink offline
alerts was never notified.
The alerts listener did nothing wrong and was never called, and the exception surfaced in whichever code published the price — code that has no idea what an audit sink is. Catching per listener fixes it:
void publishIsolated(String symbol, long cents) {
for (PriceListener3 l : listeners) {
try {
l.onPrice(symbol, cents);
} catch (RuntimeException e) {
System.out.println(" listener failed, continuing: " + e);
}
}
}
publishIsolated:
chart : AAPL 19250
listener failed, continuing: java.lang.IllegalStateException: audit sink offline
alerts : AAPL 19250

There is a third failure worth knowing because it does not look like a failure. A listener that unsubscribes itself during notification modifies the list the loop is iterating:
L2 once = new L2() {
public void on(String e) {
System.out.println(" once: unsubscribing itself");
bus.remove(this);
}
};
with 2 listeners:
once: unsubscribing itself
fire() returned normally
with 3 listeners:
once: unsubscribing itself
fire() threw: java.util.ConcurrentModificationException
With two listeners the second one is silently skipped and nothing is reported, because ArrayList's iterator checks cursor != size and the removal made those equal. With three it throws. The same code, the same mistake, and whether you find out depends on how many listeners happened to be registered.
Where an observer becomes damage. When it is used for control flow rather than notification. A listener list makes the call graph undiscoverable: you cannot answer "what happens when this fires" by reading the publishing method, only by finding every addListener call in the program, including the ones in other modules. That cost is worth paying when the subject genuinely must not know its audience — a UI widget, a cache, a message feed. It is not worth paying to connect two classes that are always used together and always in the same order; a direct method call is readable, traceable, and cannot leak.
Strategy: the interface with several implementations, now with a name
This is the shape the previous article built twice — a shipping calculator with a ShippingMethod interface per method, and a FeatureFlags class taking a FlagSource — and did not name either time. The name is Strategy: the algorithm is an object, the caller picks which one, and the code that uses it never branches.
Comparator is the JDK's strategy type
Comparator is the cleanest strategy interface in the JDK. One abstract method, and javap -v confirms what that makes it:
public interface java.util.Comparator<T> {
public abstract int compare(T, T);
...
}
RuntimeVisibleAnnotations:
0: #248()
java.lang.FunctionalInterface
One call site, three interchangeable objects, three different results:
record Item(String name, int weight) {}
final class ByWeight implements Comparator<Item> {
public int compare(Item a, Item b) { return Integer.compare(a.weight(), b.weight()); }
}
static int byName(Item a, Item b) { return a.name().compareTo(b.name()); }
static void show(String label, Comparator<Item> c) {
List<Item> l = new ArrayList<>(List.of(
new Item("crate", 30), new Item("box", 10), new Item("bag", 20)));
l.sort(c); // the call site never changes
List<String> names = new ArrayList<>();
for (Item i : l) names.add(i.name());
System.out.println(label + " -> " + names);
}
named class -> [box, bag, crate]
lambda -> [box, bag, crate]
method ref -> [bag, box, crate]
built by JDK -> [crate, bag, box]
l.sort(c) is identical in all four runs. Collections.sort, Arrays.sort, TreeMap, PriorityQueue and List.sort all take the strategy as a parameter rather than declaring how to order things, which is why they work on types the JDK has never seen.

Runnable is the same shape with a different signature: new Thread(runnable) hands a mechanism the thing to do, and Thread knows nothing about it. So is ThreadFactory, and so is every java.util.function type you pass to a method.
When the named interface earns its keep
Honest qualification: in modern Java most strategies are a lambda or a method reference, and writing a named one-method interface plus two implementing classes for something a lambda expresses in one line is the over-application this article keeps warning about. Three of the four rows above needed no class at all.
A named interface earns its keep when the strategy carries state or declares more than one method — neither of which a lambda can do.
/** Two methods and per-instance state: not expressible as one lambda. */
interface RetryPolicy {
boolean shouldRetry(int attempt, RuntimeException failure);
int backoffUnits(int attempt);
String describe();
}
final class ExponentialRetry implements RetryPolicy {
private final int maxAttempts;
ExponentialRetry(int maxAttempts) { this.maxAttempts = maxAttempts; }
public boolean shouldRetry(int attempt, RuntimeException f) {
return attempt < maxAttempts && !(f instanceof IllegalArgumentException);
}
public int backoffUnits(int attempt) { return 1 << (attempt - 1); }
public String describe() { return "exponential(max=" + maxAttempts + ")"; }
}
fixed(max=5, units=2) -> ok on attempt 3 [wait 2u, wait 2u]
exponential(max=5) -> ok on attempt 3 [wait 1u, wait 2u]
exponential(max=5) -> gave up after attempt 1 []
fixed(max=5, units=2) -> gave up after attempt 5 [wait 2u, wait 2u, wait 2u, wait 2u]
Backoff is reported in abstract units rather than milliseconds so the trace is deterministic. Both policies hold configuration (maxAttempts, the fixed delay) and answer three separate questions, and the third line shows a policy deciding that an IllegalArgumentException is not worth retrying while the fixed policy retries it four times. None of that fits in a lambda.
Where a strategy becomes damage. When there is one implementation. An interface named TaxStrategy with one class named StandardTaxStrategy is a wrapper with a longer name — the reader follows an indirection and arrives at the same arithmetic they would have found in the method. The signal to watch for is an interface whose only implementation is named after it.
Where each pattern turns into damage
All five can be applied until they cost more than they return, and the failure looks the same every time: more types, the same answer. Here is a strategy and a factory applied to a calculation that has one rule:
interface TaxStrategy { long taxCents(long netCents); }
final class StandardTaxStrategy implements TaxStrategy {
public long taxCents(long netCents) { return Math.round(netCents * 0.10); }
}
final class TaxStrategyFactory {
static TaxStrategy create() { return new StandardTaxStrategy(); }
}
// What all of that replaced.
static long taxCents(long netCents) { return Math.round(netCents * 0.10); }
via strategy + factory = 1617
via one method = 1617
strategy + factory : 4 class files: Over.class StandardTaxStrategy.class TaxStrategy.class TaxStrategyFactory.class
one static method : 1 class file: Plain.class
Same number, four times the class files, and a reader who has to open three declarations to find one multiplication.
| Pattern | The smell that you have over-applied it | What to write instead |
|---|---|---|
| Singleton | a class reaches for getInstance() inside a method, and nothing in the signature says so | pass the object in as a constructor parameter |
| Factory | a factory class with one create() method returning one implementation | call the constructor, or add a static factory method to the class itself |
| Builder | a builder for three required fields with no optional ones | a record, or a plain constructor |
| Observer | a listener used to sequence two classes that are always used together | a direct method call |
| Strategy | an interface whose only implementation is named after it | the method body, inlined where it was |
⚠️ The most reliable question for all five is whether the variation you are building for has ever happened. A second implementation, a second construction path, a second listener — if none of them exists yet and none is scheduled, the pattern is insurance against a change that may never arrive, and the premium is paid by every person who reads the code in the meantime.
The one pattern here with a mandatory technical answer rather than a judgement call is the singleton, and the answer is initialisation: an unsynchronised null check is wrong, double-checked locking needs volatile, and the holder idiom gets both properties from a rule the JVM already enforces. Everything else in this article is a trade you make with a specific expected change in mind.
FAQ
What is the difference between a design pattern and a Java feature?
A feature is something the compiler knows about; a pattern is something only you and the next reader know about. javac will not tell you that a class was meant to be a singleton, will not check that a builder set its required fields — unless you stage it, as above — and has no opinion on whether an interface with one implementation was a good idea. That is why every pattern in this article is described by what it costs as well as what it does.
Is the singleton pattern an anti-pattern in Java?
Not on its own. The uniqueness is fine and often correct — Runtime.getRuntime() is a singleton and nobody objects. What draws the criticism is the global access: a class that calls Something.getInstance() inside a method has a dependency that does not appear in its signature, cannot be replaced for a check, and shares mutable state with everything else in the process. Keep the single instance and pass it in as a constructor parameter, and the objection disappears.
Why does Integer.valueOf(127) == Integer.valueOf(127) return true but 128 does not?
Because valueOf returns a cached object for the range -128 to 127 and allocates outside it, which is a factory doing something a constructor cannot. The lesson is not about the boundary: it is that identity comparison on boxed types is meaningless. Use equals, or compare unboxed int values.
When should I write a builder instead of a constructor?
When the parameter list has stopped being readable at the call site — roughly four or five parameters, or fewer if several are optional, or immediately if two adjacent parameters share a type and could be swapped without a compiler error. Below that, a record or a plain constructor is shorter and clearer. This is a judgement, and the demonstration above shows what it looks like when it goes the wrong way: 21 source lines and three class files to produce the same line a 7-line record produced.
Are java.util.Observer and Observable still usable in Java 21?
They still exist and still run. Both are annotated @Deprecated(since = "9") and neither is marked for removal, so code using them compiles with a warning on OpenJDK 21. Avoid them anyway: Observable is a class rather than an interface, so a subject spends its single inheritance slot on it, and setChanged() is protected, so you cannot publish an event without subclassing. A one-method listener interface of your own is smaller and better.
Do I still need the Strategy pattern now that Java has lambdas?
You still need the idea; you usually do not need the interface. A lambda or a method reference is a strategy — list.sort((a, b) -> ...) is the pattern, fully applied. Write a named interface when the strategy holds state, declares more than one method, or needs a name that documents the domain concept. Otherwise the lambda is the same pattern with less code.
Which of these five should I learn first?
Factory, in its static-factory-method form, because it pays off in code you write today: a named constructor, a cached instance, the freedom to return a subtype. Builder next, because the telescoping-constructor bug above is real and silent. Strategy after that, since you are already using it every time you pass a Comparator. Singleton last, and mostly so you know why its initialisation is subtle and why global access is the part to avoid.
Conclusion
Five patterns, and one honest summary of each. A singleton's difficulty is not uniqueness but initialisation: an unsynchronised null check produced eight objects from eight threads, double-checked locking needs volatile to prevent a reader from seeing a half-constructed object, and the holder idiom gets laziness and thread safety from the class-initialisation rule the JVM already enforces. A factory buys three freedoms a constructor cannot have — a name, a cached instance, a different implementation class per call — which is why List.of(1, 2) and List.of(1, 2, 3) come back as different classes and EnumSet switches implementation at exactly 64 constants. A builder turns positional arguments into named calls, and a staged builder turns a missing required field from a runtime IllegalStateException into a compile error. An observer decouples a subject from its audience and charges for it in retained references and untraceable control flow. A strategy makes the algorithm a parameter, and in modern Java it is usually a lambda.
The other half of each section matters as much. A singleton hides global state; a factory for one implementation is indirection with no payoff; a builder for three fields is longer than the record it replaced; an observer used for sequencing makes the call graph undiscoverable; a strategy interface with one implementation is a wrapper. Four class files and one static method produced the same 1617.
This closes Part 1 of the course, which has been about the language and how to design with it: the four OOP principles, nested and inner classes, advanced enums, generics, SOLID, and now the patterns those principles turn into. Part 2 moves from designing types to using the ones the JDK already ships, starting with the Collection Framework in depth — and it opens with the comparison that decides most day-to-day choices: ArrayList versus LinkedList versus Vector, what each one actually stores, and which operations really cost what.