The subject-notifies-observers pattern behind most event systems — push vs pull models, and the classic memory leak from un-removed observers.
Published September 23, 2026
A subject maintains a list of observers and notifies all of them when its state changes — neither side needs to know the other's concrete type, only the shared Observer contract.
interface StockObserver { void onPriceChange(String symbol, double newPrice); }
class StockPriceTracker { // the subject
private final List<StockObserver> observers = new ArrayList<>();
private double price;
void subscribe(StockObserver o) { observers.add(o); }
void unsubscribe(StockObserver o) { observers.remove(o); }
void updatePrice(String symbol, double newPrice) {
this.price = newPrice;
for (StockObserver o : observers) o.onPriceChange(symbol, newPrice); // notify everyone
}
}
class DisplayPanel implements StockObserver {
public void onPriceChange(String symbol, double price) { System.out.println(symbol + ": " + price); }
}
class PriceAlertService implements StockObserver {
public void onPriceChange(String symbol, double price) { if (price > 100) triggerAlert(symbol); }
private void triggerAlert(String symbol) { /* ... */ }
}
StockPriceTracker tracker = new StockPriceTracker();
tracker.subscribe(new DisplayPanel());
tracker.subscribe(new PriceAlertService());
tracker.updatePrice("AAPL", 150.0); // both subscribers react independently
Neither DisplayPanel nor PriceAlertService know about each other, and StockPriceTracker doesn't know what its observers actually do with the notification — it just broadcasts. This is the foundation almost every event/pub-sub system builds on, from GUI listeners to message queues.
The example above is push: the subject sends the full new state (symbol, newPrice) directly in the notification. The alternative is pull: the subject only sends a minimal "something changed" signal, and the observer calls back into the subject to fetch whatever specific data it needs.
interface PullObserver { void onUpdate(StockPriceTracker subject); } // subject reference, not data
class DisplayPanel implements PullObserver {
public void onUpdate(StockPriceTracker subject) {
double price = subject.getCurrentPrice(); // observer decides what it needs, and pulls it
}
}
Push is simpler and cheaper when every observer wants roughly the same data. Pull is more flexible when different observers care about different subsets of state, avoiding the subject having to know every possible thing an observer might want.
class ExpensiveService {
ExpensiveService(StockPriceTracker tracker) {
tracker.subscribe(this::onPriceChange); // subscribed, but never unsubscribed
}
private void onPriceChange(String s, double p) { /* ... */ }
}
If ExpensiveService instances are meant to be short-lived but the StockPriceTracker is long-lived, every ExpensiveService that never calls unsubscribe() stays referenced forever by the tracker's observer list — the garbage collector can't reclaim it, since the tracker still holds a live reference. This is a textbook listener/callback leak (also called out in Memory Leaks in Java): the fix is either explicit unsubscription in a lifecycle hook (close(), @PreDestroy), or using weak references for the observer list so the GC can collect observers that nothing else holds onto.
The textbook subject keeps a list and loops over it. Production code has to answer three awkward questions: what if observers are added or removed during notification, what if one observer throws, and how does an observer reliably unsubscribe?
public final class EventSource<E> {
private final List<Consumer<? super E>> listeners = new CopyOnWriteArrayList<>();
/** Returns a handle: closing it unsubscribes. No need to keep the original lambda around. */
public AutoCloseable subscribe(Consumer<? super E> listener) {
listeners.add(listener);
return () -> listeners.remove(listener);
}
public void publish(E event) {
for (Consumer<? super E> l : listeners) { // iterates a snapshot: safe under concurrent changes
try {
l.accept(event);
} catch (RuntimeException e) {
log.warn("Listener {} failed on {}", l, event, e); // one bad listener doesn't stop the rest
}
}
}
}
CopyOnWriteArrayList makes iteration work on a snapshot, so observers can subscribe or unsubscribe during a notification (even from inside a callback) without ConcurrentModificationException. It fits because listener lists are read constantly and changed rarely.AutoCloseable) solves a classic bug: remove(this::onEvent) creates a new method-reference object, which never equals the one that was added, so it silently removes nothing. Returning a handle from subscribe avoids the problem, and fits try-with-resources for scoped subscriptions.The version above notifies synchronously, on the publisher's thread:
| Synchronous | Asynchronous (executor or queue) | |
|---|---|---|
| Publisher speed | Waits for every observer | Returns immediately |
| Ordering | Observers see events in publish order | Needs care, since parallel delivery can reorder |
| Transactions | Observers run inside the publisher's transaction | Observers run later, outside it |
| Failures | Visible to the publisher, if you let them through | Must be handled and logged by the delivery mechanism |
An async variant hands each notification to an executor. To keep per-observer ordering, use one single-threaded executor or queue per observer, rather than a shared pool.
Spring's event system is the Observer pattern with the wiring done for you:
public record OrderPlaced(String orderId, String customerId) {}
@Service
class OrderService {
private final ApplicationEventPublisher events;
OrderService(ApplicationEventPublisher events) { this.events = events; }
@Transactional
public void place(Order order) {
repository.save(order);
events.publishEvent(new OrderPlaced(order.id(), order.customerId()));
}
}
@Component
class OrderEmailListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // only if the order really committed
@Async
public void on(OrderPlaced e) { emailService.sendConfirmation(e.orderId()); }
}
@TransactionalEventListener(AFTER_COMMIT) addresses a subtle problem: a plain @EventListener runs immediately, inside the transaction, so an email could be sent for an order that then rolls back. Waiting for the commit ensures observers only react to changes that actually happened. For events that must also survive a crash between the commit and the listener running, use the transactional outbox pattern.
place(). Keep event names descriptive, and document the important listeners.Q: Is Java's own PropertyChangeListener an Observer implementation?
A: Yes — java.beans.PropertyChangeSupport is essentially a built-in Observer subject, and Swing/AWT's event listener model (ActionListener, etc.) follows the same shape throughout the JDK.
Q: How does Observer relate to a message queue / event bus in a backend system? A: Same core idea at a different scale and with different delivery guarantees — a message queue decouples publisher and subscriber across processes/services (with persistence, retry, and ordering concerns Observer doesn't address in-process), but the fundamental "notify interested parties without either side knowing the other's concrete type" relationship is identical.
Q: What happens if an observer's callback throws an exception during notification?
A: In the naive loop shown above, an exception in one observer's onPriceChange would propagate up and prevent any later observers in the list from being notified at all — production Observer implementations typically wrap each observer call in its own try/catch so one misbehaving observer can't break notification for the rest.
Q: Would you use push or pull for a UI that shows a live dashboard of many different metrics? A: Pull tends to fit better there — different dashboard widgets likely care about different subsets of the subject's state, and pushing every possible field to every observer regardless of relevance wastes both bandwidth and each observer's effort filtering out data it doesn't need.
Q: Why does remove(this::handler) often fail to unsubscribe?
A: Each evaluation of a method reference or lambda can create a new object, and it's not equal to the one registered earlier, so the list removes nothing. Keep the original reference, or better, have subscribe return a handle that removes exactly what was added.
Q: Why use @TransactionalEventListener instead of @EventListener for side effects like emails?
A: A plain listener runs during the transaction, so it can act on changes that are later rolled back. @TransactionalEventListener(phase = AFTER_COMMIT) runs only once the transaction has committed, so observers react to real, durable changes.
Q: Is CopyOnWriteArrayList always the right choice for observer lists?
A: It's ideal when notifications are frequent and subscription changes are rare, because iteration needs no locking. If observers change very often (thousands per second), each change copies the array, so a concurrent set or a lock-protected list may perform better.