Java Observer Pattern — Missing removeObserver Causes OOM
A missing removeObserver caused a 6-hour memory leak in Java Observer Pattern, leading to OOM.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
- Observer Pattern defines one-to-many dependency between objects
- Subject maintains a list of observers and notifies them on state change
- Observers implement a common interface to stay decoupled
- Performance: notification is O(n) where n = number of observers; use CopyOnWriteArrayList to avoid ConcurrentModificationException
- Production risk: failing to unregister observers causes memory leaks; every subscribe must have paired unsubscribe
- Biggest mistake: using java.util.Observable (deprecated) instead of an interface-based design
The Observer pattern is a behavioral design pattern where a subject (publisher) maintains a list of dependents (observers/subscribers) and notifies them automatically of state changes, typically by calling a method on each observer. It solves the problem of one-to-many dependency management without tight coupling—think event listeners in a UI framework or a stock ticker pushing price updates to multiple dashboards.
The core contract is simple: observers register with the subject via addObserver(), the subject iterates its list and calls update() on each when state changes, and observers must deregister via removeObserver() when they're no longer interested. In Java, this pattern is notoriously dangerous in production because forgetting to call removeObserver() creates a strong reference from the subject to the observer, preventing garbage collection.
This is the exact mechanism behind the OutOfMemoryError (OOM) described in this article—a long-lived subject (e.g., a singleton service) holding references to short-lived observers (e.g., activity instances) that never get cleaned up. Alternatives include using WeakReference-based observer lists (like Android's LiveData or Java's WeakHashMap) or reactive streams (RxJava, Project Reactor) that handle subscription lifecycle automatically.
You should avoid raw Observer pattern implementations in long-running JVM applications unless you enforce strict lifecycle management—PropertyChangeSupport from java.beans is a built-in option but still requires manual removal. The push vs. pull model debate matters here: push sends all state to observers (simpler but wasteful), pull lets observers query what they need (more efficient but requires thread-safe access to subject state).
In practice, thread safety compounds the leak problem—concurrent modification of the observer list during iteration can throw ConcurrentModificationException, so you need CopyOnWriteArrayList or synchronized blocks, which further complicates lifecycle management.
Imagine you subscribe to a YouTube channel. You don't keep refreshing the page waiting for videos — YouTube just notifies you when something new drops. The Observer Pattern works exactly like that: one object (the channel) keeps a list of subscribers and pings all of them automatically whenever something important changes. You're the observer, YouTube is the subject, and the notification is the update.
Every non-trivial application has objects that need to react when something else changes. A stock price ticks up and three dashboards need to refresh. A user submits a form and an email service, a logging system, and an analytics tracker all need to know. Without a clean pattern for this, you end up with tightly coupled code where every component manually pokes every other component — and that becomes a maintenance nightmare fast.
The Observer Pattern solves this by inverting the dependency. Instead of Component A calling Component B, C, and D directly, A just announces 'something changed' and any component that cares can listen. The components that care register themselves as observers. A doesn't know who's listening, and the listeners don't need to know how A works internally. That separation is everything.
By the end of this article you'll be able to build a working Observer implementation from scratch in Java, understand when Java's built-in tools (like PropertyChangeSupport) are a better choice than rolling your own, spot the common mistakes that turn a clean pattern into a memory leak, and answer the Observer questions that come up in senior Java interviews.
Why Observer Pattern Without removeObserver Leaks Memory
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. In Java, this typically means a Subject maintains a list of Observer references and iterates over them on each state change. The core mechanic is a subscription list — observers register via addObserver and deregister via removeObserver.
In practice, the Subject holds strong references to observers. If an observer is no longer needed but remains registered, it becomes an unintentional strong reference root. This prevents the observer (and often its entire object graph) from being garbage collected. Over time, the observer list grows unbounded, and notification cycles become O(n) with n being stale observers. The result: heap exhaustion and OutOfMemoryError.
Use this pattern when you need loose coupling between a subject and multiple dependents that must stay in sync — UI event systems, messaging buses, or reactive streams. But in Java, the default Observable/Observer classes are notoriously dangerous because they lack a built-in weak reference mechanism. Always pair registration with guaranteed deregistration, or switch to WeakReference-based implementations.
The Core Mechanic — Subject, Observer, and the Contract Between Them
The Observer Pattern has three moving parts: the Subject (also called Observable), the Observer interface, and the concrete observers that do something with the notification.
The Subject holds a list of registered observers and is responsible for notifying them when its state changes. It doesn't care what they do with that notification — it just calls a single agreed-upon method. That agreed-upon method is the Observer interface, and it's the contract that keeps everything decoupled.
The concrete observers implement that interface. Each one registers itself with the subject and defines its own behaviour inside the update method. They're completely independent of each other.
Why an interface? Because the Subject should be able to hold observers of any type — a UI component, a logger, a third-party analytics SDK — without importing any of their classes. The interface is the only thing they share. This is the Open/Closed Principle in action: you can add a new observer type without touching the Subject at all.
import java.util.ArrayList; import java.util.List; // --- The Observer contract --- // Any class that wants to receive updates must implement this. interface StockObserver { void onPriceChanged(String tickerSymbol, double newPrice); } // --- The Subject --- // Maintains a list of observers and notifies them on state change. class StockMarket { private final List<StockObserver> observers = new ArrayList<>(); private String tickerSymbol; private double currentPrice; public StockMarket(String tickerSymbol, double initialPrice) { this.tickerSymbol = tickerSymbol; this.currentPrice = initialPrice; } // Observers call this to sign up for notifications. public void addObserver(StockObserver observer) { observers.add(observer); } // Observers call this to unsubscribe — important to avoid memory leaks. public void removeObserver(StockObserver observer) { observers.remove(observer); } // Simulates a price update arriving from the exchange. public void updatePrice(double newPrice) { this.currentPrice = newPrice; notifyAllObservers(); // Automatically alerts every registered listener. } // Internal: loops through every registered observer and calls the contract method. private void notifyAllObservers() { for (StockObserver observer : observers) { observer.onPriceChanged(tickerSymbol, currentPrice); } } } // --- Concrete Observer 1: A trading dashboard --- class TradingDashboard implements StockObserver { @Override public void onPriceChanged(String tickerSymbol, double newPrice) { // Each observer decides what to DO with the update independently. System.out.printf("[Dashboard] %s price updated on screen: $%.2f%n", tickerSymbol, newPrice); } } // --- Concrete Observer 2: An automated alert system --- class PriceAlertSystem implements StockObserver { private final double alertThreshold; public PriceAlertSystem(double alertThreshold) { this.alertThreshold = alertThreshold; } @Override public void onPriceChanged(String tickerSymbol, double newPrice) { // This observer only acts when its own condition is met. if (newPrice > alertThreshold) { System.out.printf("[ALERT] %s crossed threshold! Current price: $%.2f%n", tickerSymbol, newPrice); } } } // --- Concrete Observer 3: An audit logger --- class AuditLogger implements StockObserver { @Override public void onPriceChanged(String tickerSymbol, double newPrice) { System.out.printf("[Audit Log] Price change recorded — %s: $%.2f%n", tickerSymbol, newPrice); } } // --- Entry point --- public class StockTicker { public static void main(String[] args) { StockMarket appleStock = new StockMarket("AAPL", 170.00); TradingDashboard dashboard = new TradingDashboard(); PriceAlertSystem alertSystem = new PriceAlertSystem(185.00); // Alert fires above $185 AuditLogger logger = new AuditLogger(); // All three observers register with the same subject. appleStock.addObserver(dashboard); appleStock.addObserver(alertSystem); appleStock.addObserver(logger); System.out.println("--- Price update 1 ---"); appleStock.updatePrice(180.50); // Below threshold — alert won't fire. System.out.println("--- Price update 2 ---"); appleStock.updatePrice(186.00); // Above threshold — alert fires. System.out.println("--- Dashboard unsubscribes ---"); appleStock.removeObserver(dashboard); // Dashboard opts out. System.out.println("--- Price update 3 ---"); appleStock.updatePrice(190.00); // Dashboard no longer receives this. } }
Observer Pattern UML — Publisher, Subscriber, and Concrete Subscriber
The UML diagram below shows the static structure of the Observer Pattern. The key actors are the Subject (Publisher), the Observer interface (Subscriber), and the ConcreteObserver (ConcreteSubscriber) that implements the interface. The Subject maintains a list of observers and allows them to attach or detach. The notification method iterates over the list and calls update() on each. This design completely decouples the Subject from the ConcreteObserver; only an interface is shared. In Java, you often rename these to EventPublisher vs EventListener, but the pattern remains identical.
Java's Built-In Observer Tools — PropertyChangeSupport vs Roll Your Own
Java has had observer-style tooling baked in for decades. The original java.util.Observable class and java.util.Observer interface shipped in Java 1.0 — but they were deprecated in Java 9 and you should not use them. Observable is a class, not an interface, which means your subject must extend it and Java only allows single inheritance. That's a design dead-end.
The better built-in option is PropertyChangeSupport, which lives in java.beans. It's lightweight, thread-safe (for single property changes), and widely used in Java desktop frameworks. It fires an event that carries the property name, the old value, and the new value — which is far more informative than a generic 'something changed' ping.
For reactive, async, or stream-based scenarios, Java 9 introduced the Flow API (java.util.concurrent.Flow) which implements the Reactive Streams specification. Libraries like RxJava and Project Reactor build on this mental model.
Knowing which tool to reach for matters. Roll your own for domain-specific, lightweight needs. Use PropertyChangeSupport for Java Bean-style objects. Use Flow or Reactor when you're dealing with async streams, backpressure, or large event volumes.
import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; // --- The Subject using Java's built-in PropertyChangeSupport --- // This is ideal for JavaBean-style domain objects. class UserProfile { // PropertyChangeSupport does the heavy lifting of managing listeners. private final PropertyChangeSupport changeSupport = new PropertyChangeSupport(this); private String username; private String emailAddress; public UserProfile(String username, String emailAddress) { this.username = username; this.emailAddress = emailAddress; } // Standard registration method expected by the Java Beans convention. public void addPropertyChangeListener(PropertyChangeListener listener) { changeSupport.addPropertyChangeListener(listener); } public void removePropertyChangeListener(PropertyChangeListener listener) { changeSupport.removePropertyChangeListener(listener); } public void setEmailAddress(String newEmailAddress) { String previousEmail = this.emailAddress; this.emailAddress = newEmailAddress; // Fires the event with the property name, old value, AND new value. // Observers get context — not just 'something changed'. changeSupport.firePropertyChange("emailAddress", previousEmail, newEmailAddress); } public void setUsername(String newUsername) { String previousUsername = this.username; this.username = newUsername; changeSupport.firePropertyChange("username", previousUsername, newUsername); } public String getUsername() { return username; } public String getEmailAddress() { return emailAddress; } } // --- Observer 1: Sends a verification email when the email address changes --- class EmailVerificationService implements PropertyChangeListener { @Override public void propertyChange(PropertyChangeEvent event) { // Only react to the property we care about — ignore everything else. if ("emailAddress".equals(event.getPropertyName())) { System.out.printf("[Email Service] Verification sent to new address: %s (was: %s)%n", event.getNewValue(), event.getOldValue()); } } } // --- Observer 2: Audit log that tracks ALL profile changes --- class ProfileAuditLog implements PropertyChangeListener { @Override public void propertyChange(PropertyChangeEvent event) { // This one listens to every property — the event tells it which one changed. System.out.printf("[Audit] Field '%s' changed: '%s' → '%s'%n", event.getPropertyName(), event.getOldValue(), event.getNewValue()); } } public class UserProfileObserver { public static void main(String[] args) { UserProfile profile = new UserProfile("jsmith", "john@oldmail.com"); EmailVerificationService emailService = new EmailVerificationService(); ProfileAuditLog auditLog = new ProfileAuditLog(); profile.addPropertyChangeListener(emailService); profile.addPropertyChangeListener(auditLog); System.out.println("--- Changing email address ---"); profile.setEmailAddress("john@newmail.com"); System.out.println("--- Changing username ---"); profile.setUsername("johnsmith"); System.out.println("--- Setting same email again (no event fired) ---"); // PropertyChangeSupport is smart: it won't fire if old == new value. profile.setEmailAddress("john@newmail.com"); } }
Thread Safety and Memory Leaks — The Two Observer Traps in Production
The Observer Pattern looks clean in tutorials but has two sharp edges that bite in production: thread safety and memory leaks.
If multiple threads call addObserver, removeObserver, and notifyAllObservers concurrently on a plain ArrayList, you'll get ConcurrentModificationException or worse — silent data corruption. The fix is to use CopyOnWriteArrayList instead of ArrayList for your observer list. It's slightly slower on writes (it copies the list on every mutation) but reads and iterations — which are far more frequent in observer scenarios — are completely lock-free and safe.
Memory leaks are sneakier. If an observer registers with a long-lived subject but never explicitly removes itself, the subject holds a reference to the observer forever. The garbage collector can't reclaim it. In a UI application this means every time a screen is opened and closed, a new observer is added and the old one lingers. After enough cycles, memory fills up. The fix is always to pair addObserver with a cleanup path — a close(), dispose(), or onDestroy() method that calls removeObserver. If you're working with Java's WeakReference, you can also store observers as weak references so the GC can collect them if nothing else holds them — but this requires careful design.
import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; // A thread-safe Subject using CopyOnWriteArrayList. // Safe for concurrent registrations and notifications. class SensorDataFeed { // CopyOnWriteArrayList: reads are lock-free, writes copy the internal array. // Perfect for observer lists where reads (notifications) dominate writes (registrations). private final List<SensorObserver> observers = new CopyOnWriteArrayList<>(); private volatile double latestReading; // volatile ensures visibility across threads. public void register(SensorObserver observer) { observers.add(observer); } public void unregister(SensorObserver observer) { observers.remove(observer); // Safe to call from any thread. } public void publishReading(double sensorValue) { this.latestReading = sensorValue; // Even if another thread adds/removes an observer mid-iteration, // CopyOnWriteArrayList won't throw ConcurrentModificationException. for (SensorObserver observer : observers) { observer.onNewReading(sensorValue); } } } interface SensorObserver { void onNewReading(double value); } // A short-lived observer that properly unregisters itself when done. class TemperatureMonitor implements SensorObserver { private final SensorDataFeed feed; private final String monitorId; public TemperatureMonitor(String monitorId, SensorDataFeed feed) { this.monitorId = monitorId; this.feed = feed; feed.register(this); // Register on construction. } @Override public void onNewReading(double value) { System.out.printf("[%s] Temperature reading: %.1f°C%n", monitorId, value); } // Always provide a cleanup method — this prevents the memory leak. public void shutdown() { feed.unregister(this); System.out.printf("[%s] Unregistered from feed.%n", monitorId); } } public class ThreadSafeEventBus { public static void main(String[] args) throws InterruptedException { SensorDataFeed temperatureSensor = new SensorDataFeed(); TemperatureMonitor kitchenMonitor = new TemperatureMonitor("KitchenSensor", temperatureSensor); TemperatureMonitor serverRoomMonitor = new TemperatureMonitor("ServerRoom", temperatureSensor); // Simulate concurrent sensor readings from a background thread. ExecutorService sensorThread = Executors.newSingleThreadExecutor(); sensorThread.submit(() -> { temperatureSensor.publishReading(22.5); temperatureSensor.publishReading(23.1); }); sensorThread.shutdown(); sensorThread.awaitTermination(2, TimeUnit.SECONDS); // Simulate the kitchen monitor going offline — must unregister to avoid memory leak. kitchenMonitor.shutdown(); System.out.println("--- Reading after kitchen monitor unregistered ---"); temperatureSensor.publishReading(24.0); // Only serverRoomMonitor receives this. } }
update() method, and the subject's lock is still held, you're stuck. The CopyOnWriteArrayList approach avoids this entirely because no lock is held during iteration.Push vs Pull Model — Which One Should You Use?
When implementing the Observer Pattern, you have two models for delivering data: push or pull.
In the push model, the subject sends detailed data to observers as part of the notification. The observer's update method receives all the information it might need. This is simple and fast for observers that need all the data, but it creates tighter coupling — if the subject's data shape changes, every observer interface must change.
In the pull model, the subject sends only a minimal notification (or just a reference to itself). The observer then calls getter methods on the subject to retrieve only the data it actually needs. This is more flexible and keeps the interface stable even as the subject evolves, but it requires the observer to know the subject's interface.
Which one should you pick? If your observers need most of the subject's state and the data shape is stable, push is fine. If observers need different subsets of data or the subject's state evolves often, pull is better. Many production systems use a hybrid: push a small event object that includes a reference to the subject and maybe a change type, then let observers pull details as needed.
// Push model interface — subject sends full data interface ObserverPush { void update(String ticker, double price, long timestamp); } // Pull model interface — subject sends minimal notification interface ObserverPull { void update(StockMarket subject); } // Concrete pull observer class PriceDisplay implements ObserverPull { @Override public void update(StockMarket subject) { // Pull only what we need System.out.println("Current price: " + subject.getCurrentPrice()); } } // Hybrid: push a small event object class PriceChangeEvent { final String ticker; final double newPrice; final StockMarket source; // constructor... } interface ObserverHybrid { void onPriceChanged(PriceChangeEvent event); }
- Push is simpler for observers that need all data — but brittle when data shape changes.
- Pull keeps the interface minimal and stable — observers can evolve independently.
- Hybrid (push a small event with a reference) is the most production-friendly pattern.
- Rule: start with pull; only switch to push if profiling shows performance gain.
Push vs Pull Notification Model — Comparison Table
| Aspect | Push Model | Pull Model |
|---|---|---|
| Data delivery | Subject sends all relevant data in notification | Subject sends only a reference or minimal event; observer queries needed data |
| Interface stability | Changes in subject data shape require changes in all observer interfaces | Observer interface remains stable; subject can add new data without breaking observers |
| Coupling | Tight: observers depend on specific data structure | Loose: observers depend on subject's getter methods |
| Performance | Faster for observers that need all data; no additional method calls | Slightly slower due to additional getter calls; observers may fetch data they don't need |
| Flexibility | Low: observers must accept all data even if not needed | High: each observer selects only what it needs |
| Best for | Stable data shapes, observers that use most of the data | Evolving data shapes, observers with varying data needs |
| Real-world example | Stock ticker update pushes full price object | PropertyChangeListener pulls old/new values from event |
Spring's Event Framework — @EventListener and ApplicationEvent as Built-in Observer
Spring Framework provides a first-class event system that implements the Observer Pattern at the application level. You define events by extending ApplicationEvent (or using generic payload events), and you define listeners by annotating methods with @EventListener. The ApplicationContext acts as the subject, managing listeners and publishing events to all registered listeners.
Spring's event system is synchronous by default (listeners run in the caller's thread) but can be made asynchronous with @Async. It also supports event hierarchy, conditional listeners (e.g., only react to certain event types), and transactional events that only fire after a successful commit.
This is a production-ready implementation: thread-safe, decoupled, and integrated with Spring's lifecycle. You should prefer it over rolling your own event system inside a Spring application. Even if you're not using Spring, the pattern of a central event bus with annotated listeners is worth understanding.
import org.springframework.context.ApplicationEvent; import org.springframework.context.ApplicationListener; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; // --- Custom Event --- // Extend ApplicationEvent (or use generic PayloadApplicationEvent) public class OrderPlacedEvent extends ApplicationEvent { private final String orderId; private final double total; public OrderPlacedEvent(Object source, String orderId, double total) { super(source); this.orderId = orderId; this.total = total; } public String getOrderId() { return orderId; } public double getTotal() { return total; } } // --- Listener using @EventListener (modern way) --- @Component class OrderConfirmationService { @EventListener public void onOrderPlaced(OrderPlacedEvent event) { System.out.printf("[Confirmation] Sending email for order %s, total $%.2f%n", event.getOrderId(), event.getTotal()); } } // --- Listener using ApplicationListener (traditional way) --- @Component class OrderAuditService implements ApplicationListener<OrderPlacedEvent> { @Override public void onApplicationEvent(OrderPlacedEvent event) { System.out.printf("[Audit] Order %s recorded in audit log%n", event.getOrderId()); } } // --- Publisher --- @Component class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; } public void placeOrder(String orderId, double total) { // Business logic... publisher.publishEvent(new OrderPlacedEvent(this, orderId, total)); } } // --- Async listener (optional) --- @Component class ShippingService { @EventListener @Async // Requires @EnableAsync public void onOrderPlaced(OrderPlacedEvent event) { System.out.printf("[Shipping] Preparing shipment for order %s (async)%n", event.getOrderId()); } }
Real-Life Applications of the Observer Pattern
The Observer Pattern appears in countless real-world systems. Understanding these concrete examples helps you recognise when the pattern is the right fit and how to implement it correctly.
- Social Media Notifications: When you post a photo, your followers receive a notification. Each follower is an observer; the social network is the subject. The subject maintains a list of followers (observers) and pushes a notification when new content appears. This is a classic push-model observer in action.
- Stock Market Dashboards: A stock exchange publishes price updates. Multiple dashboards, alert systems, and analytics tools subscribe to these updates. When a stock price changes, all subscribers are notified. The subject (price feed) doesn't know what each subscriber does — it just calls a method on the observer interface. This is the exact example used throughout this article.
- GUI Event Listeners: In Java Swing or JavaFX, every button click, mouse move, or key press is handled via observer-like patterns. A Button has a list of ActionListener objects. When the button is clicked, it iterates over the list and calls actionPerformed on each listener. This is the Observer Pattern applied to UI events.
- Weather Monitoring Systems: A weather station collects data (temperature, humidity, pressure) and broadcasts updates to multiple display devices. Each display (current conditions, forecast, statistics) is an observer that pulls the data it needs from the weather station. This is a classic pull-model observer, often used in textbooks to introduce the pattern.
In each case, the key benefit is decoupling: the subject (social network, price feed, button, weather station) can evolve independently of the observers. Adding a new observer requires no changes to the subject.
Testing Observers — How to Verify Notification Contracts
Observers are often side-effect-heavy — they send emails, write logs, update UIs. That makes them tricky to test. You don't want to actually send emails in your unit tests, but you do need to verify that the observer was called with the right data.
Use mocking to verify that the observer's method was invoked. Mock the observer interface, register it with the subject, trigger a state change, and assert that the mock's method was called with expected arguments.
For integration tests, you can use a real observer that records calls (like a spy) and then check the recorded data. This is useful for verifying multi-observer scenarios or complex event flows.
- Observer is called exactly once per state change.
- Observer receives correct data (push) or can pull correct data (pull).
- Observer is NOT called after being removed.
- Thread safety: concurrent registrations and notifications don't corrupt the observer list.
- Memory leaks: after removing all observers, the subject holds no references (test with WeakReference).
import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; class ObserverTest { @Test void observerIsNotifiedOnStateChange() { // Arrange StockMarket subject = new StockMarket("AAPL", 170.0); StockObserver mockObserver = mock(StockObserver.class); subject.addObserver(mockObserver); // Act subject.updatePrice(180.0); // Assert verify(mockObserver, times(1)).onPriceChanged("AAPL", 180.0); } @Test void observerIsNotNotifiedAfterRemoval() { StockMarket subject = new StockMarket("AAPL", 170.0); StockObserver mockObserver = mock(StockObserver.class); subject.addObserver(mockObserver); subject.removeObserver(mockObserver); subject.updatePrice(180.0); verify(mockObserver, never()).onPriceChanged(any(), anyDouble()); } @Test void multipleObserversAllReceiveUpdates() { StockMarket subject = new StockMarket("AAPL", 170.0); StockObserver obs1 = mock(StockObserver.class); StockObserver obs2 = mock(StockObserver.class); subject.addObserver(obs1); subject.addObserver(obs2); subject.updatePrice(180.0); verify(obs1).onPriceChanged("AAPL", 180.0); verify(obs2).onPriceChanged("AAPL", 180.0); } @Test void noMemoryLeakAfterRemovingAllObservers() throws Exception { StockMarket subject = new StockMarket("AAPL", 170.0); StockObserver observer = new StockObserver() { @Override public void onPriceChanged(String s, double v) {} }; subject.addObserver(observer); subject.removeObserver(observer); // Weak reference check (conceptual). // In practice, verify subject.observers is empty. // If using CopyOnWriteArrayList, we can check size. // For real leak detection, use a WeakReference test after GC. // This is a simplified version. System.gc(); // If observer is not referenced elsewhere, it should be collectable. // But without WeakReference, we just verify list is empty. // That's enough for this test. } }
Practice Exercises to Master the Observer Pattern
The following exercises will solidify your understanding by applying the pattern in different contexts. Each exercise builds on the core concepts: subject, observer interface, registration, notification, and lifecycle cleanup.
- Event Bus: Build a simple in-memory event bus that allows multiple subscribers to register for specific event types (e.g., UserCreated, OrderShipped). The bus should support wildcard subscriptions (e.g., subscribe to all events). Ensure thread safety and provide a mechanism to unsubscribe. Test with concurrent publishers and subscribers.
- Stock Ticker: Implement a stock ticker system where multiple display components (price table, chart, alert) subscribe to a single stock price feed. Use the pull model: the subject only notifies that a price changed, and each display pulls the relevant data. Add a scenario where a display can unsubscribe mid-stream and verify it stops receiving updates.
- Pub-Sub System: Create a publish-subscribe system with a message broker as an intermediary. Publishers push messages to the broker, and subscribers receive messages based on topic filters. This is a more advanced exercise that adds a broker layer on top of the Observer Pattern. Implement it in-memory first, then consider adding a persistent queue.
- Weather Station: Implement the classic weather station example. The WeatherSubject holds temperature, humidity, and pressure. Multiple display observers (CurrentConditions, Statistics, Forecast) pull the data they need. Add a new observer (HeatIndex) without modifying the subject or existing observers. Ensure that removing an observer doesn't affect others.
- Java Virtual Machine Monitoring: Write a simple JVM monitor that uses the Observer Pattern to track garbage collection events. The subject polls JMX beans and notifies observers when GC metrics change. Use CopyOnWriteArrayList for thread safety and add a test that simulates GC events and verifies the observer receives the expected metrics. Implement proper cleanup so observers don't leak.
The Lapsed Listener Problem — Why Your App Is Slowly Eating Memory
You've seen it in production. The heap graph creeps up. GC cycles get longer. Then, out of nowhere, the OOM killer takes down your service. The root cause? An observer that forgot to unsubscribe.
This isn't theory. Every senior has debugged a memory leak where a listener held a reference to a dead component, keeping it alive in the old generation. The Subject still holds a strong reference to your Observer. Your Observer holds a back-reference to the owning class. Now you have a retention chain that prevents GC.
The fix isn't just adding removeObserver(). It's understanding who controls unsubscription. Patterns like using WeakReferences with ObserverList, or lifecycle callbacks that guarantee removal, are production necessities. In Spring, @EventListener automatically manages this. In raw Java, you must.
Here's the uncomfortable truth: if your Observer outlives its usefulness, and your Subject is long-lived, you are creating slow, creeping death. Fix it before your pager goes off.
// io.thecodeforge — java tutorial // Demonstrates how a forgotten observer causes a memory leak import java.util.*; class EventSource { private final List<Listener> listeners = new ArrayList<>(); void subscribe(Listener l) { listeners.add(l); } void unsubscribe(Listener l) { listeners.remove(l); } // ← This call is critical void fireEvent() { for (Listener l : listeners) l.onEvent(); } } class HeavyComponent implements Listener { private final byte[] data = new byte[100_000_000]; // 100 MB payload public void onEvent() { /* handle */ } } public class LapsedListenerDemo { public static void main(String[] args) { EventSource source = new EventSource(); for (int i = 0; i < 1000; i++) { source.subscribe(new HeavyComponent()); // Never unsubscribed — memory grows unbounded } System.out.println("Listeners subscribed. Heap filled."); } }
close())Key Concepts Unpacked — What the UML Diagrams Don't Tell You
Textbooks show you a neat UML: Subject, Observer, concrete implementations. They pretend the contract is clean. It isn't.
Here's what they skip:
- Notification order matters. If you have observers that depend on other observers' side effects, you're in a garbage fire. Never assume ordering unless your framework guarantees it (e.g., @Order in Spring).
- Synchronous vs. async. Standard Observer is synchronous — the notify loop blocks the Subject. If an Observer blocks (I/O, slow computation), your Subject stalls. Push notifications onto a thread pool, or use an event bus, or accept the latency profile.
- The Subject's state during notification. You're iterating a live list. If an observer, inside its
update(), calls removeObserver() — you've just modified the collection you're iterating. Welcome to ConcurrentModificationException. Use CopyOnWriteArrayList or iterate over a snapshot.
These aren't academic. They're the bugs that appear at 3AM on Black Friday. Know them before you ship.
// io.thecodeforge — java tutorial // Shows what happens when an observer modifies the listener list during notification import java.util.*; interface Observer { void update(); } class Subject { private final List<Observer> observers = new ArrayList<>(); void attach(Observer o) { observers.add(o); } void detach(Observer o) { observers.remove(o); } void notifyObservers() { for (Observer o : observers) { o.update(); // ← BUG: if update() calls detach() } } } public class ConcurrentModificationPitfall { public static void main(String[] args) { Subject subject = new Subject(); Observer suicidal = () -> { System.out.println("I'm out!"); subject.detach(this); // ← Modifying list during iteration }; subject.attach(suicidal); subject.attach(() -> System.out.println("Still here")); try { subject.notifyObservers(); } catch (ConcurrentModificationException e) { System.out.println("Caught: " + e); } } }
The Observer Pattern Overview — Why Most Descriptions Miss the Real Point
The Observer pattern lets one object (the subject) broadcast state changes to multiple dependents (observers) without knowing who they are. That decoupling is the entire reason it exists — not the notification itself, but the fact that the subject doesn't import, instantiate, or even name its observers.
Most tutorials start with a weather station example. Fine for teaching inheritance, terrible for production thinking. In real systems, the subject is often a shared resource — a cache, a config store, a user session — and observers are UI components, analytics pipelines, or logging services. The pattern buys you the ability to add or remove those dependents at runtime without touching the subject's code.
The real question isn't 'how do I notify observers?' It's 'how do I keep this subject from knowing who's watching it while still telling them what changed?' Answer: a list of interfaces. That list is both your power and your landmine — memory leaks live there.
// io.thecodeforge — java tutorial interface StateChangeListener { void onStateChanged(String key, Object oldVal, Object newVal); } class ConfigStore { private final List<StateChangeListener> listeners = new ArrayList<>(); void addListener(StateChangeListener l) { listeners.add(l); } void update(String key, Object value) { Object old = values.put(key, value); for (StateChangeListener l : listeners) { l.onStateChanged(key, old, value); } } } // Usage ConfigStore store = new ConfigStore(); store.addListener((k, o, n) -> System.out.println(k + " changed: " + o + " -> " + n)); store.update("timeout", 30);
Diagrammatic Representation of the Lapsed Listener Problem — See the Leak
The lapsed listener problem looks simple on paper: a listener holds a reference to the subject, the subject holds a reference to the listener, and neither ever cleans up. But the diagram tells the real story — it's not just a circle, it's a rooted GC path.
Imagine a UI panel that registers itself as an observer on a user session object. The panel is closed, but the session still holds a reference to the panel through the observer list. The panel can't be garbage collected because the session — probably held by a static context or application scope — keeps it alive. That's not a loop; that's a chain from a GC root straight into abandoned memory.
The fix is symmetric lifecycle management: every register() must be paired with an unregister(). If you're using anonymous lambdas or inner classes, you can't even remove them because you don't hold the reference. Store the listener reference explicitly, or use WeakReferences if you accept the complexity trade-off.
// io.thecodeforge — java tutorial class Session { private final List<Observer> observers = new ArrayList<>(); void attach(Observer o) { observers.add(o); } } class DashboardPanel { private final Session session; DashboardPanel(Session s) { this.session = s; // LEAK: no way to detach this inner class instance session.attach(new Observer() { public void update() { render(); } }); } void close() { // Nothing removes this panel from session.observers } void render() { /* repaint UI */ } }
close() or dispose(). Your future self (and your Ops team) will thank you.attach() with a detach(), or use a lifecycle-aware framework.The Silent Observer Leak That Crashed Our Trading Platform
- Every addObserver must have a paired removeObserver in a deterministic lifecycle method (close, dispose, onDestroy).
- For long-lived subjects, consider using weak references (e.g., WeakHashMap) to allow GC to clean up observers that are no longer reachable from other roots.
- Monitor heap usage and observer list size in production (e.g., via JMX or custom metrics) to catch leaks early.
jmap -histo:live <pid> | grep -E 'Observer|Listener'jcmd <pid> GC.class_stats | awk '{print $1, $2, $3}'jstack <pid> | grep -A 10 'ConcurrentModification'Check code: grep -rn "ArrayList.*observer" src/Search for addObserver calls that lack duplicate checkUse a Set instead of List for observers to prevent duplicates| Aspect | Custom Observer (Roll Your Own) | PropertyChangeSupport (java.beans) | java.util.Observable (deprecated) |
|---|---|---|---|
| Setup complexity | Minimal — just an interface and a list | Slightly more — requires PropertyChangeEvent handling | Minimal — extend Observable and call setChanged() |
| Event payload | You define exactly what data observers receive | Always fires old value + new value + property name | Only fires Object argument (or null) — no old/new value |
| Same-value filtering | No — fires even if value didn't change | Yes — automatically skips notification if old == new | No — manual check required |
| Thread safety | Not by default — you must use CopyOnWriteArrayList | Not thread-safe by default for compound operations | Partially thread-safe (notifyObservers is synchronized on the list) |
| Inheritance constraint | None (interface-based) | None (composition with PropertyChangeSupport) | Forces class inheritance (must extend Observable) |
| Deprecated risk | None | None | Deprecated since Java 9 — do not use |
| Best use case | Domain-specific events with custom data shapes | JavaBean-style domain objects with named properties | Legacy code only — never for new development |
Key takeaways
register() call must have a paired unregister() call in a predictable lifecycle method, or you'll leak objects indefinitely.Common mistakes to avoid
4 patternsForgetting to call removeObserver when a short-lived component is destroyed
close(), dispose(), or lifecycle callback that explicitly calls removeObserver. In Android this is onDestroy(), in Spring it's @PreDestroy. Also consider using WeakHashMap-based observers as a safety net.Using a plain ArrayList for the observer list in a multi-threaded context
Pushing too much data in the notification (the 'push model' overload)
update() method signature balloons with parameters, and observers receive data they don't need, creating tight coupling between subject and observers.Assuming observers are called in registration order or with predictable timing
Interview Questions on This Topic
What's the difference between the push model and pull model in the Observer Pattern, and when would you choose one over the other?
How would you make an Observer implementation thread-safe in Java, and why is using ArrayList for the observer list dangerous in a concurrent environment?
How does the Observer Pattern relate to the Event-Driven architecture and the Publish-Subscribe pattern — are they the same thing?
What are the memory implications of the Observer Pattern, and how would you prevent memory leaks in production?
How can you test that an observer correctly receives notifications and that it stops receiving after removal?
never()). For integration tests, use a spy that records calls in a list, then assert on the list. Also test concurrent scenarios by having multiple threads add/remove and notify, asserting no exceptions and eventually consistent state. Always include a test that the observer list is empty after all observers are removed (checking the list size if accessible).Frequently Asked Questions
It was deprecated in Java 9 and should not be used in new code. The core problem is that Observable is a class, not an interface, so your subject must extend it — and since Java doesn't support multiple inheritance, that's often impossible. Use PropertyChangeSupport or roll your own interface-based solution instead.
In the Observer Pattern, observers register directly with the subject — they know about each other through the interface. In Pub/Sub, a message broker sits between publishers and subscribers, meaning publishers and subscribers have zero direct knowledge of each other. Pub/Sub scales better across systems; Observer is simpler and more appropriate for in-process, single-application use cases.
Yes, absolutely — and this is actually a common real-world scenario. A single audit logger, for example, might register with a UserService, an OrderService, and a PaymentService simultaneously. Each subject holds its own list of observers independently. The observer just needs to handle the onPriceChanged or propertyChange call correctly regardless of which subject triggered it, which is why the event payload (like PropertyChangeEvent's getSource()) often includes a reference to the originating subject.
Weak references can help as a safety net, but they are not a substitute for explicit cleanup. If the observer is only held by the subject (and no other strong references), it will be garbage-collected. However, this can cause unexpected unsubscriptions — the observer might disappear in the middle of a notification cycle. It's better to enforce explicit registration/removal via lifecycle methods and use weak references only in specific scenarios like caches or long-lived subjects in frameworks.
If an observer throws an exception, it will propagate to the subject's notification method and prevent other observers from being notified. To avoid this, wrap each observer call in a try-catch block inside the notifyAllObservers method. Log the exception and continue to the next observer. This ensures one misbehaving observer doesn't break notifications for others. Also consider using an error handler or callback to report failures.
20+ years shipping production Java in banking & fintech. Written from production experience, not tutorials.
That's Advanced Java. Mark it forged?
14 min read · try the examples if you haven't