DEV Community

DevCorner2
DevCorner2

Posted on

πŸ”„ Mastering `notify()` and `notifyAll()` in Java: Thread Signaling with Real-World Examples

Concurrency is fundamental to building responsive and scalable backend systems. While modern Java offers robust concurrency abstractions via the java.util.concurrent package, there are still times when low-level primitives like wait(), notify(), and notifyAll() β€” provided by java.lang.Object β€” are needed for fine-grained control.

In this post, we’ll break down how notify() and notifyAll() work, when to use them, and walk through a real-world, thread-safe producer-consumer example that demonstrates the nuances of choosing between the two.


πŸ” Thread Communication via Object Monitors

Java’s intrinsic locking is built around monitor-based synchronization. Every object has an associated monitor, and threads can only call wait(), notify(), or notifyAll() inside a synchronized block or method.

synchronized (sharedObject) {
    sharedObject.wait();      // releases the lock and waits
    sharedObject.notify();    // wakes one waiting thread
    sharedObject.notifyAll(); // wakes all waiting threads
}
Enter fullscreen mode Exit fullscreen mode

When notify() or notifyAll() is called, it doesn't immediately wake up a thread. It moves the thread from the waiting queue to the blocked state, where it must re-acquire the lock to proceed.


🧠 Choosing Between notify() and notifyAll()

Feature notify() notifyAll()
Wakes up One waiting thread All waiting threads
Performance Lower contention, better for performance Higher contention, potential overhead
Safety Risky if multiple conditions exist Safer when threads wait on different conditions
Best for Single condition waiters Multiple condition types waiters (e.g., producers and consumers)

βœ… Rule of thumb: If you're unsure, use notifyAll() β€” it's safer. Optimize later if contention becomes a problem.


πŸ›  Real-World Example: Bounded Blocking Queue (Producer-Consumer)

Let’s build a thread-safe, blocking queue where:

  • Producers wait if the queue is full.
  • Consumers wait if the queue is empty.

βœ… Requirements

  • Max size is bounded.
  • Support for concurrent producers and consumers.
  • Correct usage of wait(), notifyAll().
  • Clean, idiomatic code structure.

πŸ”§ Implementation

public class BlockingQueue<T> {
    private final Queue<T> queue = new LinkedList<>();
    private final int capacity;

    public BlockingQueue(int capacity) {
        this.capacity = capacity;
    }

    public void enqueue(T item) throws InterruptedException {
        synchronized (this) {
            while (queue.size() == capacity) {
                wait(); // wait until space is available
            }

            queue.add(item);
            notifyAll(); // notify consumers waiting for items
        }
    }

    public T dequeue() throws InterruptedException {
        synchronized (this) {
            while (queue.isEmpty()) {
                wait(); // wait until items are available
            }

            T item = queue.remove();
            notifyAll(); // notify producers waiting for space
            return item;
        }
    }

    public synchronized int size() {
        return queue.size();
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ§ͺ Usage Demo

public class ProducerConsumerDemo {
    public static void main(String[] args) {
        BlockingQueue<Integer> queue = new BlockingQueue<>(5);

        Runnable producer = () -> {
            for (int i = 0; i < 10; i++) {
                try {
                    queue.enqueue(i);
                    System.out.println("Produced: " + i);
                    Thread.sleep(100); // simulate work
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        };

        Runnable consumer = () -> {
            for (int i = 0; i < 10; i++) {
                try {
                    int item = queue.dequeue();
                    System.out.println("Consumed: " + item);
                    Thread.sleep(150); // simulate processing
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        };

        new Thread(producer).start();
        new Thread(consumer).start();
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Common Pitfalls to Avoid

  1. Not using a loop for wait()
   if (queue.isEmpty()) wait(); // ❌ Wrong!
Enter fullscreen mode Exit fullscreen mode

Use:

   while (queue.isEmpty()) wait(); // βœ… Correct
Enter fullscreen mode Exit fullscreen mode
  1. Calling wait() or notify() outside synchronized block
   queue.wait(); // ❌ IllegalMonitorStateException
Enter fullscreen mode Exit fullscreen mode
  1. Using notify() when multiple conditions exist
  • If both producers and consumers wait, always use notifyAll(), or risk deadlock if the wrong thread is woken.

πŸš€ When to Use Higher-Level Concurrency Tools

The wait()/notify() mechanism is powerful but error-prone. In production systems, prefer:

Use Case Better Alternative
Producer-consumer BlockingQueue, LinkedBlockingQueue
Multiple conditions Lock + Condition
One-time events CountDownLatch, CyclicBarrier
Coordination of threads Semaphore, Phaser, CompletableFuture

🧾 Summary

  • Use notify() to wake a single thread when only one condition exists.
  • Use notifyAll() when multiple threads might be waiting on different conditions.
  • Always guard wait() in a loop to handle spurious wakeups.
  • Avoid low-level constructs unless you need fine-grained control or educational insight.

In modern enterprise-grade Java systems, your default should be to reach for java.util.concurrent unless you have a compelling reason otherwise.


πŸ“¦ Bonus: Alternative with ReentrantLock and Condition

Would you like a version of the above example refactored using Lock and Condition? It's more flexible, production-grade, and better for systems with multiple wait conditions (e.g., bounded resource pools).

Let me know and I’ll provide that next.

Top comments (0)