wait notify

In the realm of Java multi-threaded programming, the ability for threads to communicate and coordinate is fundamental to building high-performance, concurrent systems. While modern developers often reach for high-level utilities found in the java.util.concurrent package, understanding the low-level primitives of inter-thread communication is essential for mastering the Java concurrency model. At the heart of this foundation lie the wait(), notify(), and notifyAll() methods.
A common misconception among beginners is that wait() and notify() are methods of the Thread class. In reality, they are defined in java.lang.Object. Because every class in Java inherits from Object, any instance can serve as a synchronization lock and a communication gateway.

To understand how these methods work, we must first understand the concept of a Monitor. In Java, every object has an associated monitor (or intrinsic lock). When a thread enters a synchronized block, it acquires that object's monitor.

The three core methods function as follows:

  • wait(): This method causes the current thread to pause execution and, crucially, releases the lock it holds on the object. The thread then enters the object's Wait Set. It will remain in this state until another thread invokes notify() or notifyAll() on that same object.
  • notify(): This method wakes up a single, arbitrary thread from the object's Wait Set. Once awakened, the thread does not immediately resume execution; it must first compete to re-acquire the object's monitor lock.
  • notifyAll(): This method wakes up all threads currently residing in the object's Wait Set. All awakened threads will then compete to re-acquire the lock, with only one thread eventually succeeding and proceeding.

The Golden Rule: Synchronization Requirement

There is one non-negotiable requirement: wait(), notify(), and notifyAll() must be called from within a synchronized context (either a synchronized method or a synchronized block).

If a thread attempts to call these methods without owning the object's monitor, the JVM will throw an IllegalMonitorStateException. This requirement ensures that the thread has the authority to manage the state of the object and the communication between threads.

The Producer-Consumer Pattern

The most effective way to visualize these concepts is through the classic Producer-Consumer model. In this scenario, a "Producer" thread adds data to a shared buffer, and a "Consumer" thread removes data from it. Synchronization is required to ensure the producer doesn't add data to a full buffer and the consumer doesn't attempt to take data from an empty one.

import java.util.LinkedList;
import java.util.Queue;

/**
 * A demonstration of the Producer-Consumer pattern using wait and notify.
 */
public class ProducerConsumerDemo {
    private final Queue<Integer> buffer = new LinkedList<>();
    private final int CAPACITY = 5;

    public void produce(int value) throws InterruptedException {
        synchronized (buffer) {
            // Use a while loop to protect against spurious wakeups
            while (buffer.size() == CAPACITY) {
                System.out.println("Buffer is full. Producer is waiting...");
                buffer.wait(); 
            }

            buffer.offer(value);
            System.out.println("Produced: " + value);
            
            // Notify consumers that data is now available
            buffer.notifyAll();
        }
    }

    public void consume() throws InterruptedException {
        synchronized (buffer) {
            // Use a while loop to protect against spurious wakeups
            while (buffer.isEmpty()) {
                System.out.println("Buffer is empty. Consumer is waiting...");
                buffer.wait();
            }

            int value = buffer.poll();
            System.out.println("Consumed: " + value);
            
            // Notify producers that space is now available
            buffer.notifyAll();
        }
    }

    public static void main(String[] args) {
        ProducerConsumerDemo demo = new ProducerConsumerDemo();

        // Producer Thread
        Thread producerThread = new Thread(() -> {
            try {
                for (int i = 1; i <= 10; i++) {
                    demo.produce(i);
                    Thread.sleep(100); 
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // Consumer Thread
        Thread consumerThread = new Thread(() -> {
            try {
                for (int i = 1; i <= 10; i++) {
                    demo.consume();
                    Thread.sleep(150); 
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producerThread.start();
        consumerThread.start();
    }
}

Best Practices and Common Pitfalls

Directly manipulating threads using wait and notify is powerful but error-prone. To write robust concurrent code, follow these industry-standard practices:

1. Always Use while Loops, Never if Statements

One of the most critical rules is to wrap your wait() calls inside a while loop that checks the condition. This is necessary to guard against Spurious Wakeups—a phenomenon where a thread can wake up from a waiting state even without a corresponding notify() signal being sent. By using a while loop, the thread re-evaluates the condition immediately upon waking, ensuring it only proceeds if the required state is actually met.

2. Prefer notifyAll() Over notify()

While notify() is slightly more performant because it only wakes one thread, it is inherently risky. If you have multiple types of threads waiting on the same object (e.g., both producers and consumers), notify() might wake up a thread of the "wrong" type. For example, a producer might wake up another producer instead of a consumer, leading to a situation where all threads end up waiting, resulting in a deadlock or a system hang. notifyAll() is much safer because it ensures all potentially interested threads are notified.

3. Minimize Lock Contention and Avoid Deadlocks

Keep the code inside synchronized blocks as brief as possible. Holding a lock while performing heavy I/O or long-running computations increases contention and slows down the entire system. Furthermore, always be mindful of the order in which locks are acquired to prevent circular dependencies that lead to deadlocks.

Conclusion

The wait() and notify() mechanism is a cornerstone of Java's concurrency history. By leveraging the object monitor, these methods allow threads to signal each other and manage shared resources efficiently. While modern alternatives like java.util.concurrent.locks.Condition offer more granular control and flexibility, a deep understanding of the underlying wait/notify logic remains a vital skill for any professional Java engineer aiming to build reliable, high-concurrency applications.