How to Use Java wait() and notify(): Simple Queue Scenario Tutorial with Examples

Concurrency is a cornerstone of modern Java applications, enabling multiple threads to work together to solve complex problems. However, coordinating threads to share resources safely is non-trivial. Without proper communication, threads may interfere with each other, leading to race conditions, inconsistent states, or even application crashes.

Java provides low-level synchronization primitives like wait(), notify(), and notifyAll() to facilitate inter-thread communication. These methods allow threads to pause, wait for a condition to be met, and wake up other threads when the condition changes. While higher-level concurrency utilities (e.g., BlockingQueue) abstract away much of this complexity, understanding wait() and notify() is critical for mastering Java concurrency fundamentals.

In this tutorial, we’ll demystify wait() and notify() by implementing a simple bounded queue (a fixed-size queue) using the producer-consumer pattern. We’ll walk through how producers and consumers coordinate using wait() and notify() to avoid race conditions and ensure thread safety.

Table of Contents#

  1. Understanding Thread Interference and Race Conditions
  2. What Are wait() and notify()?
  3. Key Concepts: How wait() and notify() Work
  4. Simple Queue Scenario: The Producer-Consumer Problem
  5. Step-by-Step Implementation: Bounded Queue
  6. Example Code Walkthrough
  7. Common Pitfalls and Best Practices
  8. When to Use wait()/notify() vs. Higher-Level Utilities
  9. Conclusion
  10. References

1. Understanding Thread Interference and Race Conditions#

Before diving into wait() and notify(), let’s clarify why inter-thread communication is necessary. Suppose two threads—Producer and Consumer—share a queue:

  • Producer adds elements to the queue.
  • Consumer removes elements from the queue.

Without coordination:

  • The Consumer might try to remove an element when the queue is empty (resulting in an error or NullPointerException).
  • The Producer might add elements to a full queue (overflowing the queue).

This is a race condition: the correctness of the program depends on the timing of thread execution. To fix this, threads need a way to "talk" to each other: "I’m done; you can proceed now." This is where wait() and notify() come in.

2. What Are wait() and notify()?#

wait(), notify(), and notifyAll() are methods inherited from java.lang.Object (every Java object has them). They enable threads to communicate about the state of a shared resource.

Core Definitions:#

  • wait(): Pauses the current thread, releases the lock on the object, and waits until another thread calls notify() or notifyAll() on the same object.
  • notify(): Wakes up one randomly selected thread waiting on the object’s monitor (lock).
  • notifyAll(): Wakes up all threads waiting on the object’s monitor.

Critical Rules:#

  • These methods must be called from within a synchronized block or method, and the current thread must own the object’s monitor (lock). Failing to do so throws IllegalMonitorStateException.
  • wait() releases the lock temporarily, allowing other threads to acquire it. When the thread wakes up, it re-acquires the lock before resuming.

3. Key Concepts: How wait() and notify() Work#

3.1 Lock Ownership#

To call wait(), notify(), or notifyAll() on an object, the current thread must hold that object’s monitor lock. This is enforced by the JVM: if a thread calls wait() without owning the lock, it throws IllegalMonitorStateException.

Locks are acquired via:

  • synchronized methods (lock on this object).
  • synchronized blocks (lock on the specified object).

3.2 Condition Checking with Loops#

Threads call wait() because a condition isn’t met (e.g., "queue is empty" or "queue is full"). When waking up, the thread must recheck the condition before proceeding. This is because of:

  • Spurious Wakeups: The JVM may wake a thread from wait() without a explicit notify() (due to low-level OS threading details).
  • Missed Notifications: A notification might be sent before the thread starts waiting, leaving it waiting indefinitely.

Solution: Always check conditions in a while loop, not an if statement.

synchronized (sharedObject) {  
    while (!condition) { // Recheck condition after waking up  
        sharedObject.wait(); // Wait if condition not met  
    }  
    // Proceed only when condition is true  
}  

3.3 Notification: notify() vs. notifyAll()#

Use notify() when only one thread can benefit from the state change (e.g., a single consumer for a queue). Use notifyAll() when multiple threads might be waiting for different conditions (e.g., producers waiting for "queue not full" and consumers waiting for "queue not empty").

Risk of notify(): If the wrong thread is notified (e.g., a producer when a consumer should be), the notified thread may recheck its condition, find it unmet, and go back to waiting—leaving other threads stuck. notifyAll() avoids this by waking all threads, ensuring the correct one(s) proceed.

4. Simple Queue Scenario: The Producer-Consumer Problem#

The producer-consumer problem is a classic concurrency scenario where:

  • Producers add (enqueue) elements to a queue.
  • Consumers remove (dequeue) elements from the queue.

We’ll implement a bounded queue (fixed maximum capacity) to enforce:

  • Producers wait if the queue is full.
  • Consumers wait if the queue is empty.
  • Producers notify consumers after enqueuing (queue is no longer empty).
  • Consumers notify producers after dequeuing (queue is no longer full).

5. Step-by-Step Implementation: Bounded Queue#

Let’s build a thread-safe bounded queue with wait() and notify().

Step 1: Define the Bounded Queue#

We’ll use a List to store elements and enforce a maximum capacity. The queue will have enqueue() (for producers) and dequeue() (for consumers) methods.

Step 2: Synchronize Access#

Both enqueue() and dequeue() will be synchronized to ensure only one thread modifies the queue at a time.

Step 3: Add Wait Conditions#

  • Enqueue: If the queue is full (size() == capacity), the producer calls wait(). After adding an element, it calls notifyAll() to wake consumers.
  • Dequeue: If the queue is empty (size() == 0), the consumer calls wait(). After removing an element, it calls notifyAll() to wake producers.

6. Example Code Walkthrough#

6.1 Bounded Queue Implementation#

import java.util.LinkedList;  
import java.util.List;  
 
public class BoundedQueue<T> {  
    private final List<T> queue;  
    private final int capacity;  
 
    public BoundedQueue(int capacity) {  
        this.capacity = capacity;  
        this.queue = new LinkedList<>();  
    }  
 
    // Producer adds an element to the queue  
    public synchronized void enqueue(T item) throws InterruptedException {  
        // Wait if queue is full (loop to handle spurious wakeups)  
        while (queue.size() == capacity) {  
            System.out.println(Thread.currentThread().getName() + " - Queue is full. Waiting...");  
            wait(); // Release lock and wait  
        }  
 
        // Add item to queue  
        queue.add(item);  
        System.out.println(Thread.currentThread().getName() + " - Enqueued: " + item + " | Queue size: " + queue.size());  
 
        // Notify all waiting threads (consumers may be waiting for items)  
        notifyAll();  
    }  
 
    // Consumer removes an element from the queue  
    public synchronized T dequeue() throws InterruptedException {  
        // Wait if queue is empty (loop to handle spurious wakeups)  
        while (queue.size() == 0) {  
            System.out.println(Thread.currentThread().getName() + " - Queue is empty. Waiting...");  
            wait(); // Release lock and wait  
        }  
 
        // Remove item from queue  
        T item = queue.remove(0);  
        System.out.println(Thread.currentThread().getName() + " - Dequeued: " + item + " | Queue size: " + queue.size());  
 
        // Notify all waiting threads (producers may be waiting for space)  
        notifyAll();  
        return item;  
    }  
}  

6.2 Producer and Consumer Threads#

Now, let’s create producers that add elements and consumers that remove them.

public class ProducerConsumerDemo {  
    public static void main(String[] args) {  
        BoundedQueue<Integer> queue = new BoundedQueue<>(5); // Capacity 5  
 
        // Producer 1: Adds numbers 1-10  
        Thread producer1 = new Thread(() -> {  
            try {  
                for (int i = 1; i <= 10; i++) {  
                    queue.enqueue(i);  
                    Thread.sleep(500); // Simulate work  
                }  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
            }  
        }, "Producer-1");  
 
        // Consumer 1: Removes 5 elements  
        Thread consumer1 = new Thread(() -> {  
            try {  
                for (int i = 0; i < 5; i++) {  
                    queue.dequeue();  
                    Thread.sleep(1000); // Simulate work  
                }  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
            }  
        }, "Consumer-1");  
 
        // Consumer 2: Removes 5 elements  
        Thread consumer2 = new Thread(() -> {  
            try {  
                for (int i = 0; i < 5; i++) {  
                    queue.dequeue();  
                    Thread.sleep(1000); // Simulate work  
                }  
            } catch (InterruptedException e) {  
                Thread.currentThread().interrupt();  
            }  
        }, "Consumer-2");  
 
        // Start threads  
        producer1.start();  
        consumer1.start();  
        consumer2.start();  
    }  
}  

6.3 Expected Output#

Producer-1 - Enqueued: 1 | Queue size: 1  
Consumer-1 - Dequeued: 1 | Queue size: 0  
Producer-1 - Enqueued: 2 | Queue size: 1  
Consumer-2 - Dequeued: 2 | Queue size: 0  
Producer-1 - Enqueued: 3 | Queue size: 1  
Producer-1 - Enqueued: 4 | Queue size: 2  
Consumer-1 - Dequeued: 3 | Queue size: 1  
Producer-1 - Enqueued: 5 | Queue size: 2  
Consumer-2 - Dequeued: 4 | Queue size: 1  
Producer-1 - Enqueued: 6 | Queue size: 2  
Producer-1 - Enqueued: 7 | Queue size: 3  
Consumer-1 - Dequeued: 5 | Queue size: 2  
Producer-1 - Enqueued: 8 | Queue size: 3  
Consumer-2 - Dequeued: 6 | Queue size: 2  
Producer-1 - Enqueued: 9 | Queue size: 3  
Producer-1 - Enqueued: 10 | Queue size: 4  
Consumer-1 - Dequeued: 7 | Queue size: 3  
Consumer-2 - Dequeued: 8 | Queue size: 2  
...  

Key Observations:

  • Producers never overflow the queue (capacity 5).
  • Consumers never dequeue from an empty queue.
  • Threads wait when the queue is full/empty and resume when notified.

7. Common Pitfalls and Best Practices#

Pitfall 1: Forgetting synchronized#

Calling wait()/notify() outside a synchronized block throws IllegalMonitorStateException.

Fix: Always wrap wait()/notify() in synchronized.

Pitfall 2: Using if Instead of while for Conditions#

A spurious wakeup could cause a thread to proceed when the condition isn’t met.

Fix: Use while loops to recheck conditions:

while (queue.size() == capacity) { wait(); } // Correct  
if (queue.size() == capacity) { wait(); }   // Incorrect  

Pitfall 3: Ignoring InterruptedException#

wait() throws InterruptedException if the thread is interrupted while waiting. Swallowing this exception (e.g., empty catch block) can leave threads in an inconsistent state.

Fix: Handle interruption gracefully (e.g., restore interrupt status):

catch (InterruptedException e) {  
    Thread.currentThread().interrupt(); // Preserve interrupt status  
    return; // Exit or handle accordingly  
}  

Pitfall 4: Overusing notify()#

notify() wakes one thread, but if the wrong thread is selected, others may wait forever.

Fix: Prefer notifyAll() unless you’re certain only one thread is waiting.

8. When to Use wait()/notify() vs. Higher-Level Utilities#

wait() and notify() are low-level primitives. For real-world applications, Java provides higher-level concurrency utilities that simplify this:

  • java.util.concurrent.BlockingQueue: Implementations like ArrayBlockingQueue and LinkedBlockingQueue handle waiting/notification internally. They are safer and more efficient than manual wait()/notify() code.

Example with ArrayBlockingQueue (replaces our BoundedQueue):

BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);  
// Producer: queue.put(item) (blocks if full)  
// Consumer: queue.take() (blocks if empty)  

When to use wait()/notify(): For learning concurrency fundamentals, or when working with legacy code. For new projects, use BlockingQueue or other java.util.concurrent classes.

9. Conclusion#

wait(), notify(), and notifyAll() are powerful tools for inter-thread communication, enabling threads to coordinate access to shared resources. By following key rules—using synchronized blocks, checking conditions in loops, and preferring notifyAll()—you can avoid race conditions and build thread-safe applications.

While higher-level utilities like BlockingQueue are preferred in practice, understanding wait() and notify() deepens your grasp of Java concurrency. Use the producer-consumer example above to experiment: tweak the queue capacity, add more producers/consumers, or introduce delays to see how threads interact.

10. References#