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#
- Understanding Thread Interference and Race Conditions
- What Are
wait()andnotify()? - Key Concepts: How
wait()andnotify()Work - Simple Queue Scenario: The Producer-Consumer Problem
- Step-by-Step Implementation: Bounded Queue
- Example Code Walkthrough
- Common Pitfalls and Best Practices
- When to Use
wait()/notify()vs. Higher-Level Utilities - Conclusion
- 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 callsnotify()ornotifyAll()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
synchronizedblock or method, and the current thread must own the object’s monitor (lock). Failing to do so throwsIllegalMonitorStateException. 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:
synchronizedmethods (lock onthisobject).synchronizedblocks (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 explicitnotify()(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 callswait(). After adding an element, it callsnotifyAll()to wake consumers. - Dequeue: If the queue is empty (
size() == 0), the consumer callswait(). After removing an element, it callsnotifyAll()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 likeArrayBlockingQueueandLinkedBlockingQueuehandle waiting/notification internally. They are safer and more efficient than manualwait()/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.