The Observer pattern lets a subject notify many listeners when its state changes, without either side knowing the concrete types of the other. It's the foundation of UI event handling, pub/sub messaging, and reactive streams — and it has three classic ways to shoot you in the foot.
A minimal implementation
public interface Listener<E> {
void onEvent(E event);
}
public class EventBus<E> {
private final List<Listener<E>> listeners = new CopyOnWriteArrayList<>();
public void subscribe(Listener<E> l) { listeners.add(l); }
public void unsubscribe(Listener<E> l) { listeners.remove(l); }
public void publish(E event) {
for (Listener<E> l : listeners) {
try {
l.onEvent(event);
} catch (RuntimeException ex) {
log.warn("listener failed", ex); // don't kill the loop
}
}
}
}
CopyOnWriteArrayList lets publish iterate without holding a lock while subscribers come and go. Fine when listeners change rarely and events fire often — common for app-level events.
Pitfall 1: memory leaks from forgotten listeners
The subject holds a strong reference to every listener. If a listener is a short-lived object (a UI panel, a request-scoped bean) and forgets to unsubscribe, the subject keeps it (and everything it transitively references) alive forever. This is the single most common cause of permgen-style leaks in long-running JVMs.
Fixes:
- Document the lifecycle: every
subscribereturns aSubscriptionwithclose()and callers are expected to call it (often via try-with-resources for short-lived subs). - Use weak references for listeners the subject doesn't own.
- In Spring, lean on
ApplicationContextlifecycle so beans are torn down with the container.
public Subscription subscribe(Listener<E> l) {
listeners.add(l);
return () -> listeners.remove(l); // AutoCloseable lambda
}
Pitfall 2: reentrancy and recursive notification
A listener handles an event and, inside onEvent, triggers another event on the same subject. If the subject's publish re-enters itself mid-iteration, you get either a stack overflow, an inconsistent listener list, or — worst — events delivered in an order nobody can reason about.
Fixes:
- Disallow nested publish: maintain an
inFlightflag and queue follow-up events. - Or accept reentrancy explicitly and document the ordering contract.
- Detect it in development with an assertion if reentrancy is forbidden.
Pitfall 3: one bad listener breaks the rest
If publish just iterates and calls onEvent, the first RuntimeException from any listener short-circuits the loop and the remaining listeners never hear about the event. Now you have partial-delivery semantics that depend on subscription order — a debugging nightmare.
Fix: wrap each invocation in try/catch and log (shown above). Optionally collect exceptions and surface them as a MultipleFailuresException if the caller needs to know.
What the JDK provides — and why the old API is deprecated
java.util.Observer and java.util.Observable were deprecated in Java 9 because they're not generic, not serializable-safe, conflate two responsibilities, and have none of the lifecycle/error semantics above. Don't use them in new code.