Home Java Java wait(), notify() and notifyAll() — Deep Dive with Real Examples

Java wait(), notify() and notifyAll() — Deep Dive with Real Examples

In Plain English 🔥
Imagine a coffee shop with one barista and a line of customers. When the barista runs out of coffee beans, they put up a 'Wait' sign — customers stop and sit down instead of crowding the counter. When the beans arrive, the barista either taps one specific customer (notify) or shouts 'everyone back in line!' (notifyAll). Java's wait/notify is exactly that: a polite way for threads to pause and resume without burning CPU cycles staring at a wall.
⚡ Quick Answer
Imagine a coffee shop with one barista and a line of customers. When the barista runs out of coffee beans, they put up a 'Wait' sign — customers stop and sit down instead of crowding the counter. When the beans arrive, the barista either taps one specific customer (notify) or shouts 'everyone back in line!' (notifyAll). Java's wait/notify is exactly that: a polite way for threads to pause and resume without burning CPU cycles staring at a wall.

Every non-trivial Java application eventually has threads that need to coordinate — a producer building up data that a consumer needs to process, a cache loader that other threads must wait on, or a connection pool that hands out slots only when one is free. Get this coordination wrong and you get wasted CPU from busy-waiting, deadlocks that freeze your app at 2 AM, or race conditions that corrupt data in ways that are nearly impossible to reproduce in a debugger.

wait(), notify(), and notifyAll() are the original, low-level answer to this problem baked into every Java object since JDK 1.0. They let one thread voluntarily release a lock and sleep until another thread signals that the condition it was waiting for might now be true. That 'might' is doing a lot of work in that sentence — and it's the source of most of the bugs people write with these methods.

By the end of this article you'll be able to build a correct producer-consumer pipeline using wait/notify, explain exactly what happens to a thread's lock when it calls wait(), know why notifyAll() is almost always safer than notify(), and walk into a senior Java interview with the internals sharp enough to answer the follow-up questions that trip most candidates up.

How wait() and notify() Actually Work Inside the JVM

Every Java object carries two hidden data structures inside its monitor: a lock (a mutex) and a wait-set (a queue of sleeping threads). When you call synchronized(someObject) you're competing for that object's lock. Once you hold it, calling someObject.wait() does three things atomically: it adds the calling thread to the wait-set, releases the lock, and suspends the thread. That release is crucial — without it, no other thread could ever call notify() because they'd never acquire the lock.

When another thread calls someObject.notify(), the JVM picks one thread from the wait-set and moves it to the entry-set — the queue competing for the lock. The notified thread doesn't run immediately; it re-acquires the lock first, then returns from wait(). This is why you must always re-check your condition after wait() returns using a while loop, not an if. The window between being notified and re-acquiring the lock is a real window, and another thread can slip in and invalidate the condition you were waiting for.

The JVM spec also permits spurious wakeups — a thread can return from wait() with no notify() having been called at all. This isn't just theoretical; it happens on Linux due to how POSIX condition variables are implemented under the hood. The while-loop pattern isn't defensive paranoia — it's mandatory correctness.

MonitorInternalsDemo.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
/**
 * Demonstrates the fundamental wait/notify contract.
 * Shows the mandatory while-loop pattern and the lock-release behaviour.
 *
 * Run this and watch how the producer thread parks the consumer
 * until data is actually ready — zero busy-waiting.
 */
public class MonitorInternalsDemo {

    private static final Object lock = new Object();
    private static String sharedData = null;       // guarded by 'lock'
    private static boolean dataReady = false;      // condition variable

    public static void main(String[] args) throws InterruptedException {

        // --- CONSUMER THREAD ---
        Thread consumer = new Thread(() -> {
            synchronized (lock) {                   // 1. acquire the monitor
                System.out.println("[Consumer] Lock acquired. Checking condition...");

                // CRITICAL: while — not if — because of spurious wakeups
                // and because another consumer could steal the data first.
                while (!dataReady) {
                    System.out.println("[Consumer] Data not ready. Releasing lock and sleeping...");
                    try {
                        lock.wait();               // 2. atomically: releases lock + parks thread
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt(); // preserve interrupt flag
                        return;
                    }
                    // 3. After wakeup: lock is re-acquired before we get here.
                    // We MUST re-check dataReady — it may have been consumed already.
                    System.out.println("[Consumer] Woke up. Re-checking condition...");
                }

                // Safe to read: we hold the lock AND the condition is confirmed true.
                System.out.println("[Consumer] Data received: " + sharedData);
            }
        }, "ConsumerThread");

        // --- PRODUCER THREAD ---
        Thread producer = new Thread(() -> {
            try {
                Thread.sleep(1500); // simulate work before data is ready
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }

            synchronized (lock) {                   // must hold lock before notify()
                sharedData = "Order #42 — Double Espresso";
                dataReady = true;
                System.out.println("[Producer] Data prepared. Notifying consumer...");
                lock.notify();                     // wake one waiting thread
            }                                      // lock released here
        }, "ProducerThread");

        consumer.start();
        producer.start();

        consumer.join();
        producer.join();
        System.out.println("[Main] Both threads finished cleanly.");
    }
}
▶ Output
[Consumer] Lock acquired. Checking condition...
[Consumer] Data not ready. Releasing lock and sleeping...
[Producer] Data prepared. Notifying consumer...
[Consumer] Woke up. Re-checking condition...
[Consumer] Data received: Order #42 — Double Espresso
[Main] Both threads finished cleanly.
⚠️
Watch Out: Never Use if — Always Use whileReplacing the while loop with an if statement is the single most common wait/notify bug. Spurious wakeups and competing threads can both return wait() without your condition being true. The while loop costs nothing and prevents data corruption that only shows up under load.

Building a Correct Bounded Producer-Consumer Queue

The classic application of wait/notify is a bounded blocking queue — a buffer with a fixed capacity shared between producers and consumers. Producers wait when the buffer is full; consumers wait when it's empty. This pattern appears everywhere: thread pools, message brokers, async pipelines.

The two conditions to model are: 'buffer is not full' (producers wait on this) and 'buffer is not empty' (consumers wait on this). With a single lock object, both conditions share the same wait-set, which is why notifyAll() becomes important here — a notify() might wake the wrong type of waiter.

Pay close attention to where notify/notifyAll is called in the code below: inside the synchronized block, after the state change, before the lock is released. This ordering guarantees the notified thread will see the updated state when it re-acquires the lock.

BoundedBlockingQueue.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
import java.util.ArrayDeque;
import java.util.Deque;

/**
 * A thread-safe bounded queue built with wait/notify.
 * Producers block when full. Consumers block when empty.
 * Uses notifyAll() deliberately — see comment inside offer().
 */
public class BoundedBlockingQueue<T> {

    private final Deque<T> buffer;
    private final int capacity;
    private final Object lock = new Object();

    public BoundedBlockingQueue(int capacity) {
        this.capacity = capacity;
        this.buffer = new ArrayDeque<>(capacity);
    }

    /**
     * Adds an item. Blocks if the buffer is full.
     */
    public void offer(T item) throws InterruptedException {
        synchronized (lock) {
            // Wait until there's space — re-check after every wakeup
            while (buffer.size() == capacity) {
                System.out.printf("[%s] Buffer full (%d/%d). Producer waiting...%n",
                        Thread.currentThread().getName(), buffer.size(), capacity);
                lock.wait();
            }

            buffer.addLast(item);
            System.out.printf("[%s] Produced: %-20s | Buffer size: %d/%d%n",
                    Thread.currentThread().getName(), item, buffer.size(), capacity);

            // notifyAll() instead of notify() because:
            // Both producers AND consumers share this wait-set.
            // A notify() might wake another blocked producer (wrong thread type)
            // leaving a starved consumer sleeping forever.
            lock.notifyAll();
        }
    }

    /**
     * Removes and returns an item. Blocks if the buffer is empty.
     */
    public T poll() throws InterruptedException {
        synchronized (lock) {
            // Wait until there's something to consume
            while (buffer.isEmpty()) {
                System.out.printf("[%s] Buffer empty. Consumer waiting...%n",
                        Thread.currentThread().getName());
                lock.wait();
            }

            T item = buffer.removeFirst();
            System.out.printf("[%s] Consumed: %-20s | Buffer size: %d/%d%n",
                    Thread.currentThread().getName(), item, buffer.size(), capacity);

            lock.notifyAll(); // wake blocked producers now that space exists
            return item;
        }
    }

    // ---------------------------------------------------------------
    // Demo: 2 producers, 3 consumers, capacity 3
    // ---------------------------------------------------------------
    public static void main(String[] args) throws InterruptedException {
        BoundedBlockingQueue<String> queue = new BoundedBlockingQueue<>(3);

        Runnable producerTask = () -> {
            String[] orders = {"Latte", "Espresso", "Cappuccino", "Americano"};
            for (String order : orders) {
                try {
                    queue.offer(order);
                    Thread.sleep(200); // simulate production time
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        };

        Runnable consumerTask = () -> {
            for (int i = 0; i < 3; i++) {
                try {
                    queue.poll();
                    Thread.sleep(500); // consumers are slower than producers
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        };

        Thread p1 = new Thread(producerTask, "Producer-1");
        Thread p2 = new Thread(producerTask, "Producer-2");
        Thread c1 = new Thread(consumerTask, "Consumer-1");
        Thread c2 = new Thread(consumerTask, "Consumer-2");
        Thread c3 = new Thread(consumerTask, "Consumer-3");

        p1.start(); p2.start();
        c1.start(); c2.start(); c3.start();

        p1.join(); p2.join();
        c1.join(); c2.join(); c3.join();
        System.out.println("[Main] All threads done. Queue size: " + queue.buffer.size());
    }
}
▶ Output
[Producer-1] Produced: Latte | Buffer size: 1/3
[Producer-2] Produced: Espresso | Buffer size: 2/3
[Producer-1] Produced: Cappuccino | Buffer size: 3/3
[Producer-2] Buffer full (3/3). Producer waiting...
[Consumer-1] Consumed: Latte | Buffer size: 2/3
[Consumer-2] Consumed: Espresso | Buffer size: 1/3
[Producer-2] Produced: Americano | Buffer size: 2/3
[Consumer-3] Consumed: Cappuccino | Buffer size: 1/3
[Consumer-1] Consumed: Americano | Buffer size: 0/3
... (remaining consumers wait and finish)
[Main] All threads done. Queue size: 0
⚠️
Pro Tip: Prefer notifyAll() When Multiple Condition Types Share One LockIf producers and consumers both block on the same object, notify() might wake a thread that still can't proceed (e.g., waking a producer when the buffer is still full). notifyAll() is O(n) on the number of waiting threads but prevents missed signals and is correct by default. Switch to notify() only after profiling proves the wakeup storm is a real bottleneck — typically with a single condition and many identical waiters.

notify() vs notifyAll() — When Each One Is the Right Tool

This is one of the most misunderstood distinctions in Java concurrency. The short version: notify() is an optimisation, not a default. Use it only when you can guarantee that exactly one waiting thread can make progress after the state change, and all waiting threads are waiting for the same condition.

notifyAll() wakes all threads in the wait-set. Each one re-acquires the lock in turn, re-checks the condition, and either proceeds or goes back to sleep. Yes, this produces a 'thundering herd' — every woken thread competes for the lock, and most will just go back to sleep. Under high contention with hundreds of waiting threads this overhead is measurable. But it's always safe.

notify() wakes exactly one thread — JVM-chosen, not your choice. If that thread can't proceed (wrong condition), no other thread gets woken. You now have a system that's deadlocked even though progress is possible. This is called a 'missed signal' or 'lost wakeup' and it is brutally hard to debug.

The canonical rule: you can safely use notify() if and only if both conditions hold — (1) all threads waiting on this object are waiting for the same condition, and (2) one notification is sufficient to allow exactly one thread to proceed.

NotifyVsNotifyAll.java · JAVA
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
/**
 * Demonstrates the dangerous scenario where notify() causes a missed wakeup
 * and why notifyAll() fixes it.
 *
 * Scenario: Two consumers (waiters for DIFFERENT conditions) share one lock.
 * A producer does notify() — wakes the wrong consumer — deadlock-like stall.
 */
public class NotifyVsNotifyAll {

    private static final Object lock = new Object();
    private static boolean coffeeReady = false;
    private static boolean teaReady = false;

    public static void main(String[] args) throws InterruptedException {

        // Consumer A is waiting for coffee
        Thread coffeeWaiter = new Thread(() -> {
            synchronized (lock) {
                while (!coffeeReady) {
                    System.out.println("[CoffeeWaiter] Waiting for coffee...");
                    try { lock.wait(); } catch (InterruptedException e) {
                        Thread.currentThread().interrupt(); return;
                    }
                }
                System.out.println("[CoffeeWaiter] Got coffee! ");
            }
        }, "CoffeeWaiter");

        // Consumer B is waiting for tea
        Thread teaWaiter = new Thread(() -> {
            synchronized (lock) {
                while (!teaReady) {
                    System.out.println("[TeaWaiter] Waiting for tea...");
                    try { lock.wait(); } catch (InterruptedException e) {
                        Thread.currentThread().interrupt(); return;
                    }
                }
                System.out.println("[TeaWaiter] Got tea! ");
            }
        }, "TeaWaiter");

        coffeeWaiter.start();
        teaWaiter.start();
        Thread.sleep(500); // let both threads park in wait-set

        // SAFE VERSION — notifyAll() wakes both waiters.
        // Each re-checks their own condition. The right one proceeds.
        synchronized (lock) {
            coffeeReady = true;
            System.out.println("[Barista] Coffee ready. Using notifyAll()...");
            lock.notifyAll();  // try replacing with notify() — tea waiter may never wake
        }

        // Give tea waiter something to wake for too
        Thread.sleep(200);
        synchronized (lock) {
            teaReady = true;
            System.out.println("[Barista] Tea ready. Using notifyAll()...");
            lock.notifyAll();
        }

        coffeeWaiter.join();
        teaWaiter.join();
        System.out.println("[Main] All customers served.");
    }
}
▶ Output
[CoffeeWaiter] Waiting for coffee...
[TeaWaiter] Waiting for tea...
[Barista] Coffee ready. Using notifyAll()...
[CoffeeWaiter] Got coffee! ☕
[TeaWaiter] Waiting for tea...
[Barista] Tea ready. Using notifyAll()...
[TeaWaiter] Got tea! 🍵
[Main] All customers served.
🔥
Interview Gold: The Modern Alternativejava.util.concurrent.locks.Condition (from ReentrantLock) solves the notify() problem elegantly. You create separate Condition objects — one per logical condition — so notFull.signal() wakes only producers and notEmpty.signal() wakes only consumers. This is exactly how LinkedBlockingQueue is implemented in the JDK. Know both: wait/notify for the interview, Condition for production code.

Production Gotchas — What Goes Wrong in Real Systems

Knowing the API is only half the battle. Here's what actually bites engineers in production.

Calling wait() outside a synchronized block throws IllegalMonitorStateException immediately — no data corruption, just a crash. Easy to catch. The harder bug is calling notify() on a different object than the one you called wait() on. Both compile silently and both cause missed signals.

InterruptedException handling is where a lot of production code quietly breaks. Swallowing the interrupt (catch block that does nothing) means the thread will never respond to a shutdown signal. Always either re-throw or call Thread.currentThread().interrupt() to restore the flag so the caller can react.

Holding the lock too long is a performance killer. Everything inside the synchronized block is serialised. If your condition check or state update does I/O, database calls, or heavy computation, every other thread queues up. Push heavy work outside the synchronized block; use the lock only for reading/writing the shared state and calling wait/notify.

Nested locks — never call wait() while holding two locks. If Thread A holds Lock1 and waits on Lock2's monitor, and Thread B holds Lock2 and waits on Lock1's monitor, you have a classic deadlock. Lock ordering rules exist for this reason.

ProductionSafeWaitNotify.java · JAVA
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
/**
 * Production-grade pattern showing:
 * 1. Correct InterruptedException handling
 * 2. Timeout on wait() to prevent infinite blocking
 * 3. Lock held only for state access — heavy work done outside
 */
public class ProductionSafeWaitNotify {

    private static final Object stateLock = new Object();
    private static volatile boolean orderComplete = false;

    // Maximum time a consumer should wait before giving up (ms)
    private static final long WAIT_TIMEOUT_MS = 3000;

    /**
     * Consumer: waits for order with a timeout.
     * Returns true if order arrived, false if timed out.
     */
    public static boolean waitForOrder() throws InterruptedException {
        long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MS;

        synchronized (stateLock) {
            while (!orderComplete) {
                long remaining = deadline - System.currentTimeMillis();

                if (remaining <= 0) {
                    System.out.println("[Consumer] Timed out waiting for order.");
                    return false; // did not get the order in time
                }

                System.out.printf("[Consumer] Waiting up to %dms for order...%n", remaining);
                // wait(timeout) — returns when notified OR when timeout expires
                // We re-check the condition (while loop) either way
                stateLock.wait(remaining);
            }
            return true; // condition is confirmed true, lock is held
        }
    }

    /**
     * Producer: does heavy work OUTSIDE the lock, then signals quickly.
     */
    public static void prepareAndSignalOrder() throws InterruptedException {
        // --- Heavy work happens OUTSIDE the synchronized block ---
        System.out.println("[Producer] Preparing order (heavy work)...");
        Thread.sleep(1200); // simulate DB call, I/O, etc. — NOT inside lock
        String preparedOrder = "Flat White x2";  // result computed outside lock

        // --- Minimal critical section: just update state and signal ---
        synchronized (stateLock) {
            orderComplete = true;
            System.out.println("[Producer] Order ready: " + preparedOrder + ". Notifying...");
            stateLock.notifyAll();
        } // lock released immediately after signal
    }

    public static void main(String[] args) throws InterruptedException {
        Thread consumerThread = new Thread(() -> {
            try {
                boolean received = waitForOrder();
                System.out.println("[Consumer] Order received? " + received);
            } catch (InterruptedException e) {
                // CORRECT: restore the interrupt flag, don't swallow it
                Thread.currentThread().interrupt();
                System.out.println("[Consumer] Interrupted. Shutting down.");
            }
        }, "ConsumerThread");

        Thread producerThread = new Thread(() -> {
            try {
                prepareAndSignalOrder();
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "ProducerThread");

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

        consumerThread.join();
        producerThread.join();
    }
}
▶ Output
[Consumer] Waiting up to 3000ms for order...
[Producer] Preparing order (heavy work)...
[Producer] Order ready: Flat White x2. Notifying...
[Consumer] Order received? true
⚠️
Watch Out: wait(0) is the Same as wait()Calling lock.wait(0) does NOT return immediately — it waits indefinitely, identical to lock.wait(). This surprises engineers who expect it to be a non-blocking check. If you want a non-blocking check, just read the condition variable directly (inside a synchronized block) without calling wait() at all.
Aspectnotify()notifyAll()
Threads wokenExactly one (JVM-chosen)All threads in the wait-set
Safety with multiple conditionsDangerous — can wake wrong thread typeSafe — each thread re-checks its own condition
Risk of missed signal / livelockHigh if misusedNone — all waiters re-evaluate
Performance (many waiting threads)Better — O(1) wakeup costWorse — O(n) thundering herd
Correct use caseSingle condition + all waiters identicalMultiple conditions or uncertain waiter types
JDK internal usage exampleRarely used directlyLinkedBlockingQueue, Object.wait docs
Production default recommendationOptimisation — apply after profilingYes — start here, always safe

🎯 Key Takeaways

  • wait() atomically releases the lock AND parks the thread — no lock is held while sleeping, which is exactly what makes the coordination possible.
  • The while loop around wait() is not defensive paranoia — it's mandatory because of spurious wakeups and because the condition can be invalidated between notify() and lock re-acquisition.
  • notifyAll() is the safe default when multiple thread types share one lock; notify() is an optimisation valid only when all waiting threads are identical and exactly one can always proceed.
  • Do heavy computation outside the synchronized block — hold the lock only long enough to read/write shared state and call notify. Lock contention is a silent performance killer in production systems.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using if instead of while around wait() — The thread wakes up (spuriously or after a notify), skips re-checking the condition, and proceeds on stale state — causing data corruption or NullPointerExceptions that only appear under load. Fix: always write while (!condition) { lock.wait(); } with no exceptions.
  • Mistake 2: Calling wait() or notify() without holding the object's monitor — Throws IllegalMonitorStateException at runtime. This catches people who synchronize on one object but call wait/notify on a different one (e.g., synchronize on 'this' but call list.wait()). Fix: ensure the object you synchronize on and the object you call wait/notify on are identical.
  • Mistake 3: Swallowing InterruptedException inside the wait() catch block — The thread silently ignores shutdown signals, making graceful application shutdown impossible and causing threads to hang forever during deployment. Fix: either re-throw InterruptedException or call Thread.currentThread().interrupt() to restore the flag so upstream callers can detect and handle the interruption.

Interview Questions on This Topic

  • QWhy must wait() always be called inside a while loop rather than an if statement? Can you describe two distinct scenarios where using if would produce a bug?
  • QExplain what happens to the thread's lock when it calls wait(). What is the sequence of steps from calling wait() to the thread actually resuming execution after a notify()?
  • QYou have a bounded queue with both producers and consumers sharing a single lock object. A senior engineer says 'use notify() here — it's faster'. What are the specific conditions under which this advice is safe, and what production failure mode occurs if those conditions aren't met?

Frequently Asked Questions

What is the difference between notify() and notifyAll() in Java?

notify() wakes exactly one thread from the wait-set — the JVM picks which one and you have no control. notifyAll() wakes all waiting threads, each of which then re-acquires the lock and re-checks its condition. Use notifyAll() as your default; only switch to notify() when all waiting threads share the same condition and exactly one can proceed after each notification.

Why do I get IllegalMonitorStateException when calling wait()?

wait(), notify(), and notifyAll() must be called from within a synchronized block or method on the same object. If you synchronize on 'this' but call wait() on a different object — or forget the synchronized block entirely — the JVM throws IllegalMonitorStateException. Double-check that the object you lock on and the object you call wait/notify on are the same reference.

Can a thread be woken from wait() without notify() being called?

Yes — this is called a spurious wakeup, and it's allowed by the Java specification and actually occurs on Linux because the underlying POSIX pthread_cond_wait() can return spuriously. This is the definitive reason you must always re-check your condition in a while loop after wait() returns, not just once with an if statement.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousDeadlock in JavaNext →CompletableFuture in Java
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged