Skip to main content

Command Palette

Search for a command to run...

Java Concurrency — Inter-Thread Communication (Wait/Notify)

Updated
9 min read

"wait() and notify() are the heartbeat of the Java monitor — the mechanism by which threads cooperate instead of just competing."

This article covers how threads communicate with each other: wait()/notify(), Condition variables, and the classic printing problems that teach these fundamentals.


The Need for Communication

Locks prevent threads from interfering with each other. But sometimes threads need to cooperate: "I've produced data — you can consume it now" or "the buffer is full — wait before producing more."


wait(), notify(), notifyAll()

These methods are defined on Object (not Thread), because they operate on the object's monitor — the same monitor used by synchronized.

The Contract

synchronized (lock) {
    while (!condition) {
        lock.wait();    // Release monitor, enter wait set, sleep
    }
    // Condition is true — proceed
}

synchronized (lock) {
    // Change state that affects the condition
    condition = true;
    lock.notify();      // Wake up ONE waiting thread
    // or
    lock.notifyAll();   // Wake up ALL waiting threads
}

How wait/notify Works

Rules (MUST Follow)

  1. Must hold the monitorwait()/notify() must be called inside synchronized on the same object

  2. Always loop on condition — never use if, always use while (spurious wakeups)

  3. Prefer notifyAll()notify() wakes only one thread; with notifyAll(), all waiters re-check

// ❌ WRONG — wait() outside synchronized
lock.wait();  // 💥 IllegalMonitorStateException

// ❌ WRONG — if instead of while
synchronized (lock) {
    if (!ready) lock.wait();  // May proceed even if still not ready!
}

// ✅ CORRECT
synchronized (lock) {
    while (!ready) {
        lock.wait();
    }
    // Process...
}

The Spurious Wakeup Problem

A thread can wake from wait() without being notified. This is a real OS-level behavior (POSIX allows it). That's why the while-loop is mandatory:

// ❌ Vulnerable to spurious wakeup
synchronized (lock) {
    if (buffer.isEmpty()) {     // CHECK once
        lock.wait();            // May wake without notify!
    }
    consume(buffer.remove());   // 💥 May fail — buffer still empty!
}

// ✅ Protected against spurious wakeup
synchronized (lock) {
    while (buffer.isEmpty()) {  // RE-CHECK after every wakeup
        lock.wait();
    }
    consume(buffer.remove());   // ✅ Guaranteed non-empty
}

Condition Variables — The Modern Alternative

Condition (from java.util.concurrent.locks) is the modern replacement for wait()/notify(). Major advantage: multiple conditions per lock.

private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final Condition notFull  = lock.newCondition();

wait/notify vs Condition

Object (Monitors) Condition (Locks)
wait() await()
wait(timeout) await(timeout, unit)
notify() signal()
notifyAll() signalAll()
One wait-set per monitor Multiple conditions per lock

Why Multiple Conditions Matter


Thread.join() — Waiting for Completion

join() makes the current thread wait until the target thread finishes:

Thread worker = new Thread(() -> {
    // Long computation
    computeResult();
});
worker.start();

worker.join();  // Main thread blocks here until worker finishes
System.out.println("Worker is done!");

join() with Timeout

worker.join(5000);  // Wait at most 5 seconds
if (worker.isAlive()) {
    System.out.println("Worker still running after 5s — proceeding without it");
}

Problem: Printing Odd/Even with Two Threads

Goal: Thread-A prints odd numbers, Thread-B prints even numbers: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 — alternating.

Solution using wait/notify

public class OddEvenPrinter {
    private final int limit;
    private int current = 1;
    private final Object lock = new Object();

    public OddEvenPrinter(int limit) {
        this.limit = limit;
    }

    public void printOdd() {
        synchronized (lock) {
            while (current <= limit) {
                while (current % 2 == 0 && current <= limit) {
                    try { lock.wait(); }
                    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                }
                if (current <= limit) {
                    System.out.println(Thread.currentThread().getName() + ": " + current);
                    current++;
                    lock.notify();
                }
            }
        }
    }

    public void printEven() {
        synchronized (lock) {
            while (current <= limit) {
                while (current % 2 == 1 && current <= limit) {
                    try { lock.wait(); }
                    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                }
                if (current <= limit) {
                    System.out.println(Thread.currentThread().getName() + ": " + current);
                    current++;
                    lock.notify();
                }
            }
        }
    }

    public static void main(String[] args) {
        OddEvenPrinter printer = new OddEvenPrinter(10);

        Thread oddThread = new Thread(printer::printOdd, "Odd-Thread");
        Thread evenThread = new Thread(printer::printEven, "Even-Thread");

        oddThread.start();
        evenThread.start();
    }
}
Output:
Odd-Thread: 1
Even-Thread: 2
Odd-Thread: 3
Even-Thread: 4
...
Even-Thread: 10

Problem: Print N Words Using N Threads in Order

Goal: Given ["Hello", "World", "Foo", "Bar"], use 4 threads where Thread-0 prints "Hello", Thread-1 prints "World", etc. — cycling repeatedly.

Solution using Condition Variables

public class OrderedWordPrinter {
    private final String[] words;
    private final int numThreads;
    private int currentTurn = 0;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition[] conditions;

    public OrderedWordPrinter(String[] words) {
        this.words = words;
        this.numThreads = words.length;
        this.conditions = new Condition[numThreads];
        for (int i = 0; i < numThreads; i++) {
            conditions[i] = lock.newCondition();
        }
    }

    public void printWord(int threadId, int totalRounds) {
        for (int round = 0; round < totalRounds; round++) {
            lock.lock();
            try {
                while (currentTurn != threadId) {
                    conditions[threadId].await();
                }
                System.out.println("Thread-" + threadId + ": " + words[threadId]);
                currentTurn = (currentTurn + 1) % numThreads;
                conditions[currentTurn].signal();  // Wake the NEXT thread only
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            } finally {
                lock.unlock();
            }
        }
    }

    public static void main(String[] args) {
        String[] words = {"Hello", "World", "Foo", "Bar"};
        OrderedWordPrinter printer = new OrderedWordPrinter(words);

        for (int i = 0; i < words.length; i++) {
            final int id = i;
            new Thread(() -> printer.printWord(id, 3)).start();
        }
    }
}
Output:
Thread-0: Hello
Thread-1: World
Thread-2: Foo
Thread-3: Bar
Thread-0: Hello
Thread-1: World
...

Key insight: Using separate Condition per thread means we only wake the exact thread that should run next — no wasted wakeups.


Problem: Interleaved Series Printing

Goal: Two threads — one prints Fibonacci, one prints AP (arithmetic progression) — alternating outputs: fib_0, ap_0, fib_1, ap_1, fib_2, ap_2, ...

Solution

public class InterleavedSeriesPrinter {
    private final Object lock = new Object();
    private boolean fibTurn = true;

    public void printFibonacci(int count) {
        long a = 0, b = 1;
        for (int i = 0; i < count; i++) {
            synchronized (lock) {
                while (!fibTurn) {
                    try { lock.wait(); }
                    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                }
                System.out.println("Fib[" + i + "]: " + a);
                long temp = a + b;
                a = b;
                b = temp;
                fibTurn = false;
                lock.notify();
            }
        }
    }

    public void printAP(int count, int start, int diff) {
        int value = start;
        for (int i = 0; i < count; i++) {
            synchronized (lock) {
                while (fibTurn) {
                    try { lock.wait(); }
                    catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                }
                System.out.println("AP[" + i + "]: " + value);
                value += diff;
                fibTurn = true;
                lock.notify();
            }
        }
    }

    public static void main(String[] args) {
        InterleavedSeriesPrinter printer = new InterleavedSeriesPrinter();
        new Thread(() -> printer.printFibonacci(5)).start();
        new Thread(() -> printer.printAP(5, 2, 3)).start();
    }
}
Output:
Fib[0]: 0
AP[0]: 2
Fib[1]: 1
AP[1]: 5
Fib[2]: 1
AP[2]: 8
Fib[3]: 2
AP[3]: 11
Fib[4]: 3
AP[4]: 14

Common Pitfalls

Pitfall 1: wait() Without synchronized

// ❌ Will throw IllegalMonitorStateException
lock.wait();

// ✅ Must be inside synchronized on the same object
synchronized (lock) {
    lock.wait();
}

Pitfall 2: Using if Instead of while with wait()

// ❌ Spurious wakeup → condition may still be false
synchronized (lock) {
    if (!ready) lock.wait();
    process();  // May fail!
}

// ✅ Always re-check condition
synchronized (lock) {
    while (!ready) lock.wait();
    process();  // Guaranteed ready
}

Pitfall 3: notify() vs notifyAll()

// ❌ notify() may wake the WRONG thread
// If producers AND consumers both wait on the same lock,
// notify() might wake another producer instead of a consumer
lock.notify();

// ✅ notifyAll() wakes everyone — they re-check their conditions
lock.notifyAll();

// ✅ Or better: use separate Conditions per role
notEmpty.signal();   // Wake a consumer specifically

Pitfall 4: Forgetting to notify After State Change

synchronized (lock) {
    data = produceData();
    ready = true;
    // ❌ Forgot lock.notify() — consumers wait forever!
}

FAQs

Q1: Why must wait() be called inside a synchronized block?

A: wait() releases the monitor and puts the thread in the wait set. If the thread doesn't hold the monitor, there's nothing to release — that's why it throws IllegalMonitorStateException. The synchronized block also ensures that the condition check and the wait() call happen atomically.

Q2: Why use while instead of if when calling wait()?

A: Two reasons: (1) Spurious wakeups — the OS can wake a thread without notify() being called. (2) Condition not yet met — with notifyAll(), multiple threads wake up, but only one should proceed. The while loop makes each re-check the condition.

Q3: What is the difference between notify() and notifyAll()?

A: notify() wakes one arbitrary thread from the wait set. notifyAll() wakes all threads. In general, prefer notifyAll() because notify() can cause livelock if it wakes the wrong thread. Use notify() only when all waiting threads are equivalent.

Q4: How does Condition differ from wait()/notify()?

A: Condition supports multiple wait-sets per lock. With wait()/notify(), all threads wait on the same object's single wait-set. Condition lets producers wait on notFull and consumers on notEmpty — enabling precise signaling.

Q5: What happens if you call notify() and no thread is waiting?

A: Nothing. The signal is lost. Unlike CountDownLatch or Semaphore, there's no "memory" of notifications. If a thread calls wait() after the notify(), it will still block.