Senior 10 min · March 06, 2026

Java Observer Pattern — Missing removeObserver Causes OOM

A missing removeObserver caused a 6-hour memory leak in Java Observer Pattern, leading to OOM.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
Plain-English First

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.

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.

StockTicker.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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.
    }
}
Output
--- Price update 1 ---
[Dashboard] AAPL price updated on screen: $180.50
[Audit Log] Price change recorded — AAPL: $180.50
--- Price update 2 ---
[Dashboard] AAPL price updated on screen: $186.00
[ALERT] AAPL crossed threshold! Current price: $186.00
[Audit Log] Price change recorded — AAPL: $186.00
--- Dashboard unsubscribes ---
--- Price update 3 ---
[ALERT] AAPL crossed threshold! Current price: $190.00
[Audit Log] Price change recorded — AAPL: $190.00
Pro Tip:
Notice that adding the AuditLogger required zero changes to StockMarket or any existing observer. That's the pattern paying off — the Subject is genuinely closed for modification but open for extension.
Production Insight
If you use ArrayList for observers and call addObserver from one thread while notifyAllobservers runs on another, you'll get ConcurrentModificationException.
Fix: use CopyOnWriteArrayList from the start, even if you don't need thread safety today — it's cheap insurance.
Rule: always default to CopyOnWriteArrayList for observer lists.
Key Takeaway
The Subject depends on an interface, not concrete implementations.
That's what enables extensibility without modification.
Rule: never let the Subject import an observer's concrete class.

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.

Production Insight
When drawing UML for your team, always show the observer list as a composition (filled diamond) from Subject to Observer. It reminds everyone that the Subject owns the references and is responsible for lifecycle — a responsibility often forgotten in code reviews.
Key Takeaway
The UML diagram makes the decoupling obvious: Subject only knows the interface, not any concrete implementation.

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.

UserProfileObserver.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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");
    }
}
Output
--- Changing email address ---
[Email Service] Verification sent to new address: john@newmail.com (was: john@oldmail.com)
[Audit] Field 'emailAddress' changed: 'john@oldmail.com' → 'john@newmail.com'
--- Changing username ---
[Audit] Field 'username' changed: 'jsmith' → 'johnsmith'
--- Setting same email again (no event fired) ---
Watch Out:
java.util.Observable is deprecated — never use it in new code. It's a class (not an interface), forces single inheritance, and its notifyObservers() only fires if you manually call setChanged() first, which is a silent gotcha that trips up almost everyone who touches it for the first time.
Production Insight
PropertyChangeSupport skips notifications when old and new values are equal — that's great for performance, but it can mask issues where observers need to know about 'reset' operations.
If you need to force a notification for the same value, use a workaround like setting to null then back.
Rule: don't rely on change events being fired; always design observers to be idempotent.
Key Takeaway
For JavaBean-style objects, PropertyChangeSupport gives you named properties, old/new values, and automatic same-value filtering.
For everything else, roll your own interface.
Rule: never use java.util.Observable — it's deprecated and forces class inheritance.

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.

ThreadSafeEventBus.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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.
    }
}
Output
[KitchenSensor] Temperature reading: 22.5°C
[ServerRoom] Temperature reading: 22.5°C
[KitchenSensor] Temperature reading: 23.1°C
[ServerRoom] Temperature reading: 23.1°C
[KitchenSensor] Unregistered from feed.
--- Reading after kitchen monitor unregistered ---
[ServerRoom] Temperature reading: 24.0°C
Watch Out:
Calling notifyAllObservers() while holding a lock on the subject is a classic deadlock recipe. If an observer tries to call back into the subject during its 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.
Production Insight
In a real incident, a trading platform's dashboard screen was being reopened hundreds of times per day without unregistering. The observer list grew to over 2 million entries, causing each notification to take 10+ seconds and eventually OOM.
Fix: added a WeakHashMap-backed observer list alongside CopyOnWriteArrayList for safety, and enforced cleanup in the screen's lifecycle.
Rule: profile observer list size in production; alert if it grows beyond expected bounds.
Key Takeaway
Always use CopyOnWriteArrayList for observer lists.
Always pair addObserver with a cleanup location (close, dispose).
Rule: if you can't guarantee cleanup, consider weak references — but test thoroughly.

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.

PushVsPull.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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);
}
Decision: Push vs Pull
  • 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.
Production Insight
A common mistake: pushing too much data in a single event, then later needing to add a field that some observers don't need. This forces a breaking change on all observers.
Better: push a minimal event with a source reference, and let observers pull the specifics.
Rule: design the observer interface for stability; push the minimum, let observers query the rest.
Key Takeaway
Push is convenient but brittle.
Pull is flexible but requires observers to know the subject's API.
Rule: prefer pull or hybrid for all but the simplest cases.
Push vs Pull Decision Tree
IfMost observers need the same full data payload
UseUse push model — simpler implementation
IfObservers need different subsets of data or data shape changes often
UseUse pull model — keep interface stable
IfYou're building a framework or library used by unknown clients
UseUse pull or hybrid — avoid coupling to concrete data
IfPerformance of frequent notifications is critical
UseConsider push with pre-computed data to avoid multiple getter calls

Push vs Pull Notification Model — Comparison Table

AspectPush ModelPull Model
Data deliverySubject sends all relevant data in notificationSubject sends only a reference or minimal event; observer queries needed data
Interface stabilityChanges in subject data shape require changes in all observer interfacesObserver interface remains stable; subject can add new data without breaking observers
CouplingTight: observers depend on specific data structureLoose: observers depend on subject's getter methods
PerformanceFaster for observers that need all data; no additional method callsSlightly slower due to additional getter calls; observers may fetch data they don't need
FlexibilityLow: observers must accept all data even if not neededHigh: each observer selects only what it needs
Best forStable data shapes, observers that use most of the dataEvolving data shapes, observers with varying data needs
Real-world exampleStock ticker update pushes full price objectPropertyChangeListener pulls old/new values from event
Production Insight
In microservices, push is often simulated by sending a full event payload to a message broker. However, this can lead to event schema evolution nightmares. Many teams adopt a 'tolerant reader' pattern where observers ignore unknown fields, essentially implementing a pull mindset even with pushed data.
Rule: if your event schema can change, design observers to be tolerant of extra fields.
Key Takeaway
Choose push for simplicity when data shape is frozen; choose pull for long-term flexibility. Hybrid models are common in production.

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.

SpringEventExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
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());
    }
}
Output
[Confirmation] Sending email for order ORD-123, total $199.99
[Audit] Order ORD-123 recorded in audit log
[Shipping] Preparing shipment for order ORD-123 (async)
Spring Pro Tip:
Use @TransactionalEventListener to only fire the listener if the transaction commits successfully. This prevents side-effects when a transaction rolls back.
Production Insight
In a high-throughput Spring application, synchronous listeners can become a bottleneck. Always consider using @Async for listeners that perform I/O or long-running operations. Also, monitor the event bus with a custom listener counter to catch unexpected listener registrations (e.g., prototype beans accidentally registered as singletons).
Rule: for production Spring apps, prefer @EventListener over manual listener registration — it's less error-prone.
Key Takeaway
Spring's event system is the Observer Pattern done right for enterprise applications. Use it instead of building your own event bus inside Spring.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Real-World Insight:
In GUI frameworks like Swing, the observer list is often stored in an EventListenerList which handles thread safety and listener removal automatically. Study its implementation — it's a well-tested template for your own observer lists.
Production Insight
The same production dangers apply to all these examples: missing removal in GUI listeners causes memory leaks (especially in complex screens with many listeners), stock market feeds with dozens of observers can cause notification storms if not batched, and social media feeds need to handle millions of observers efficiently (often using message queues instead of direct observer lists).
Rule: always ask 'how does an observer get removed?' in every real-world application of this pattern.
Key Takeaway
From social media to stock markets to GUI buttons, the Observer Pattern is everywhere. Recognise it, and you'll apply it more confidently in your own designs.

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.

Key things to test
  • 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).
ObserverTest.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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.
    }
}
Testing Tip:
Use Mockito's ArgumentCaptor to capture the exact arguments passed to the observer's method. This lets you verify complex data objects without tight coupling.
Production Insight
In integration tests, a common issue is verifying that observers are called in the right order or with exact timing in multi-threaded scenarios. Use CountDownLatch or Awaitility to wait for async notifications.
For thread safety tests, hammer the subject with concurrent add, remove, and notify operations, then assert no exceptions and consistent state.
Rule: always include a test that verifies removeObserver actually stops notifications.
Key Takeaway
Mock observers for unit tests; spy-based observers for integration tests.
Always test: notification received, notification stopped after removal, and concurrent safety.
Rule: a leaky observer is usually missing a test for removal.

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Where to Start:
Begin with exercise 1 (Event Bus) — it forces you to handle multiple event types, which is the most common production need. Then move to the stock ticker to practice pull vs push. The pub-sub system is a capstone exercise that ties everything together.
Production Insight
When implementing these exercises, always consider how they would behave under stress: multiple threads publishing concurrently, high-frequency events, and lifecycle management. These are the exact challenges you'll face in production observer systems.
Rule: after finishing each exercise, review it for potential memory leaks and thread safety issues — that's where the real learning happens.
Key Takeaway
Practice is the only way to internalise the Observer Pattern. These five exercises cover the full spectrum from simple to complex, preparing you for real-world implementation.
● Production incidentPOST-MORTEMseverity: high

The Silent Observer Leak That Crashed Our Trading Platform

Symptom
After deploying a new UI update, the trading platform's memory usage grew steadily over 6 hours until the JVM hit OutOfMemoryError. No other changes were made to the backend.
Assumption
The team assumed the new dashboard screen was lightweight and would be garbage-collected once closed. They didn't check if the screen's observer remained registered with the stock price subject.
Root cause
The TradingDashboard class registered itself as an observer when created but never called removeObserver when the screen was closed. Since the subject (StockMarket) held a strong reference to the observer, the entire screen object (and its associated components) remained in memory, accumulating with each new screen instance.
Fix
Added a cleanup method to the dashboard that called removeObserver and ensured it was invoked in the screen's close lifecycle. Additionally, we used a WeakHashMap-based observer list for long-lived subjects as an extra safety net.
Key lesson
  • 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.
Production debug guideCommon symptoms and immediate actions for observer-related problems4 entries
Symptom · 01
Observer not receiving notifications
Fix
Check if the observer was registered using the same subject instance. Verify the subject's observer list contains the observer (debug with breakpoint or logging). For thread safety, ensure registration and notification happen on the same thread or use CopyOnWriteArrayList.
Symptom · 02
Memory grows over time, eventual OOM
Fix
Take a heap dump and analyze the subject's observer list size. Look for observer classes that are not expected to persist. Use jmap -histo:live to count instances. Check if removeObserver is called in cleanup methods.
Symptom · 03
ConcurrentModificationException during notification
Fix
The observer list is likely an ArrayList being modified while iterating. Replace with CopyOnWriteArrayList or synchronize all access to the list. Review code for any threads adding/removing observers during notification.
Symptom · 04
Notification order is wrong or inconsistent
Fix
Observer order is not defined in most implementations. If order matters, use a LinkedHashSet or a custom ordered list. Check for async notification where order depends on thread scheduling.
★ Observer Leak & Concurrency Cheat SheetQuick commands and checks for diagnosing observer issues in production
Heap growing – possible observer leak
Immediate action
Take a heap dump and identify longest-lived observer instances
Commands
jmap -histo:live <pid> | grep -E 'Observer|Listener'
jcmd <pid> GC.class_stats | awk '{print $1, $2, $3}'
Fix now
Add removeObserver call in a finally block or lifecycle method. Consider using WeakReference-based observer list.
ConcurrentModificationException on observer iteration+
Immediate action
Identify the list type used for observers
Commands
jstack <pid> | grep -A 10 'ConcurrentModification'
Check code: grep -rn "ArrayList.*observer" src/
Fix now
Replace ArrayList with CopyOnWriteArrayList immediately. If not possible, use synchronized block on list during add/remove and iterate on a copy.
Observer called multiple times for one event+
Immediate action
Check if observer is registered more than once
Commands
Search for addObserver calls that lack duplicate check
Use a Set instead of List for observers to prevent duplicates
Fix now
Change observer list from List to Set (e.g., CopyOnWriteArraySet) to ensure uniqueness.
Observer Pattern Implementation Comparison
AspectCustom Observer (Roll Your Own)PropertyChangeSupport (java.beans)java.util.Observable (deprecated)
Setup complexityMinimal — just an interface and a listSlightly more — requires PropertyChangeEvent handlingMinimal — extend Observable and call setChanged()
Event payloadYou define exactly what data observers receiveAlways fires old value + new value + property nameOnly fires Object argument (or null) — no old/new value
Same-value filteringNo — fires even if value didn't changeYes — automatically skips notification if old == newNo — manual check required
Thread safetyNot by default — you must use CopyOnWriteArrayListNot thread-safe by default for compound operationsPartially thread-safe (notifyObservers is synchronized on the list)
Inheritance constraintNone (interface-based)None (composition with PropertyChangeSupport)Forces class inheritance (must extend Observable)
Deprecated riskNoneNoneDeprecated since Java 9 — do not use
Best use caseDomain-specific events with custom data shapesJavaBean-style domain objects with named propertiesLegacy code only — never for new development

Key takeaways

1
The Observer Pattern decouples the Subject from its observers using an interface contract
the Subject never imports or knows about concrete observer types, which is what makes it genuinely extensible.
2
Java's java.util.Observable is deprecated and dead
use PropertyChangeSupport for JavaBean objects or roll your own interface-based implementation for domain-specific events.
3
Always swap ArrayList for CopyOnWriteArrayList in any observer list that might be accessed from multiple threads
the performance trade-off is almost always worth the safety.
4
Memory leaks are the silent killer of Observer implementations
every register() call must have a paired unregister() call in a predictable lifecycle method, or you'll leak objects indefinitely.
5
Prefer the pull model or hybrid approach to keep the observer interface stable and avoid breaking changes when the subject's data shape evolves.
6
Test observers with mocks for unit tests and spies for integration tests
always verify both that notifications arrive and that they stop after removal.

Common mistakes to avoid

4 patterns
×

Forgetting to call removeObserver when a short-lived component is destroyed

Symptom
Memory usage grows steadily over time (classic memory leak); no exception is thrown so it's hard to spot. Eventually leads to OutOfMemoryError.
Fix
Always pair registration with a cleanup path — a 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

Symptom
ConcurrentModificationException thrown during iteration when another thread adds or removes an observer, or worse, silent data corruption with no exception at all.
Fix
Replace ArrayList with CopyOnWriteArrayList. It's thread-safe for iteration and requires no manual synchronisation for typical observer workloads.
×

Pushing too much data in the notification (the 'push model' overload)

Symptom
The update() method signature balloons with parameters, and observers receive data they don't need, creating tight coupling between subject and observers.
Fix
Use the 'pull model' — send a minimal notification (or just a reference to the subject) and let each observer query only the state it actually needs. This keeps the interface stable even as the subject evolves.
×

Assuming observers are called in registration order or with predictable timing

Symptom
Observers rely on order for correctness (e.g., one must log before another audits), but order is not guaranteed by the pattern.
Fix
If order matters, use an ordered collection (e.g., LinkedHashSet) or a priority-based list. Otherwise, design observers to be order-independent. Never assume synchronous notification order across threads.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between the push model and pull model in the Obser...
Q02SENIOR
How would you make an Observer implementation thread-safe in Java, and w...
Q03SENIOR
How does the Observer Pattern relate to the Event-Driven architecture an...
Q04SENIOR
What are the memory implications of the Observer Pattern, and how would ...
Q05SENIOR
How can you test that an observer correctly receives notifications and t...
Q01 of 05SENIOR

What's the difference between the push model and pull model in the Observer Pattern, and when would you choose one over the other?

ANSWER
In the push model, the subject sends detailed data to observers as part of the notification. The observer's update method receives all information. This is simple but creates tighter coupling — changing the data shape requires changing all observer interfaces. In the pull model, the subject sends only a minimal notification (or reference to itself) and the observer calls getters to retrieve what it needs. This keeps the interface stable. Choose push when the data shape is stable and observers need most of the data. Choose pull when observers need different subsets or the subject evolves frequently. Many production systems use a hybrid: push a small event object with a source reference and let observers pull details.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is java.util.Observable still usable in modern Java?
02
What is the difference between Observer Pattern and Pub/Sub Pattern?
03
Can one observer register with multiple subjects at the same time?
04
Should I use weak references for observers to avoid memory leaks?
05
How do I handle exceptions thrown by an observer during notification?
🔥

That's Advanced Java. Mark it forged?

10 min read · try the examples if you haven't

Previous
JVM GC Tuning Guide: G1, ZGC, Shenandoah Explained with Real Trade-offs
15 / 28 · Advanced Java
Next
Strategy Pattern in Java