Skip to content
Java synchronization 6 min read

Inter-Thread Communication

When multiple threads share data, they sometimes need to coordinate — one thread produces data while another consumes it, or one thread waits for a result before it can continue. Java’s inter-thread communication mechanism lets threads politely signal each other instead of endlessly looping and wasting CPU.

The three key methods — wait(), notify(), and notifyAll() — are defined on java.lang.Object, which means every Java object can act as a communication channel between threads.

The Problem: Busy-Waiting

Without inter-thread communication, a thread that needs to wait for a condition often resorts to a tight loop:

// BAD: burns CPU checking a flag continuously
while (!dataReady) {
    // spin...
}

This “busy-wait” wastes processor time. The wait()/notify() pattern is the proper solution — a waiting thread releases the CPU entirely until another thread wakes it.

The Three Core Methods

All three methods must be called from within a synchronized block or method; otherwise you get an IllegalMonitorStateException at runtime.

MethodWhat it does
wait()Releases the lock and pauses the calling thread until notified
wait(long millis)Same as above, but also wakes up after a timeout
notify()Wakes up one waiting thread (JVM chooses which)
notifyAll()Wakes up all waiting threads; they compete for the lock

Note: notify() vs notifyAll() — prefer notifyAll() when multiple threads might be waiting on different conditions. notify() can cause a missed wake-up if the wrong thread is chosen.

Basic Example: wait() and notify()

class SharedResource {
    private int value;
    private boolean hasValue = false;

    public synchronized void produce(int v) throws InterruptedException {
        while (hasValue) {
            wait(); // wait until consumer has taken the previous value
        }
        this.value = v;
        hasValue = true;
        System.out.println("Produced: " + v);
        notify(); // wake up the consumer
    }

    public synchronized int consume() throws InterruptedException {
        while (!hasValue) {
            wait(); // wait until a value is available
        }
        hasValue = false;
        System.out.println("Consumed: " + value);
        notify(); // wake up the producer
        return value;
    }
}

public class WaitNotifyDemo {
    public static void main(String[] args) {
        SharedResource resource = new SharedResource();

        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 3; i++) resource.produce(i);
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

        Thread consumer = new Thread(() -> {
            try {
                for (int i = 1; i <= 3; i++) resource.consume();
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });

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

Output:

Produced: 1
Consumed: 1
Produced: 2
Consumed: 2
Produced: 3
Consumed: 3

Notice the while loop around wait() — this guards against spurious wakeups, which are rare but permitted by the JVM specification. Always re-check your condition after wait() returns.

Classic Producer-Consumer with a Queue

A more realistic scenario uses a bounded buffer where producers add items and consumers remove them.

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

class BoundedBuffer {
    private final Queue<Integer> queue = new LinkedList<>();
    private final int capacity;

    BoundedBuffer(int capacity) { this.capacity = capacity; }

    public synchronized void put(int item) throws InterruptedException {
        while (queue.size() == capacity) {
            System.out.println("Buffer full — producer waiting");
            wait();
        }
        queue.add(item);
        System.out.println("Put: " + item + " | Buffer size: " + queue.size());
        notifyAll(); // wake up any waiting consumers
    }

    public synchronized int take() throws InterruptedException {
        while (queue.isEmpty()) {
            System.out.println("Buffer empty — consumer waiting");
            wait();
        }
        int item = queue.poll();
        System.out.println("Took: " + item + " | Buffer size: " + queue.size());
        notifyAll(); // wake up any waiting producers
        return item;
    }
}

Tip: Java’s java.util.concurrent package provides ready-made BlockingQueue implementations (like ArrayBlockingQueue) that handle all this synchronization for you in production code.

notifyAll() in Action

class MultiConsumer {
    private String message;
    private boolean ready = false;

    public synchronized void setMessage(String msg) {
        this.message = msg;
        ready = true;
        notifyAll(); // wake up ALL waiting consumers at once
    }

    public synchronized String getMessage() throws InterruptedException {
        while (!ready) {
            wait();
        }
        return message;
    }
}

Here multiple consumer threads wait on the same object. When setMessage() is called, notifyAll() wakes every one of them — they each re-acquire the lock in turn and read the message.

wait() with a Timeout

You can pass a millisecond timeout so the thread doesn’t wait forever:

synchronized (lock) {
    long deadline = System.currentTimeMillis() + 2000; // 2-second budget
    while (!conditionMet()) {
        long remaining = deadline - System.currentTimeMillis();
        if (remaining <= 0) {
            System.out.println("Timed out waiting for condition");
            break;
        }
        lock.wait(remaining);
    }
}

This pattern is important for resilient systems where a peer thread might crash or be slow.

Under the Hood

The Monitor Lock

Every Java object has an internal structure called a monitor (also called an intrinsic lock). When a thread enters a synchronized method or block, it acquires the monitor. wait(), notify(), and notifyAll() all operate on this monitor.

When a thread calls wait(), the JVM does three things atomically:

  1. Adds the thread to the object’s wait set.
  2. Releases the monitor lock so other threads can enter.
  3. Suspends the thread (puts it in WAITING state in the thread life cycle).

When another thread calls notify(), the JVM picks one thread from the wait set and moves it to the entry set (the queue of threads contending for the lock). That thread re-acquires the lock before wait() returns — it doesn’t run immediately.

Spurious Wakeups

The JVM (and the underlying OS) may occasionally wake a waiting thread without notify() being called. This is a documented behavior from POSIX threads. The while loop guard is the standard defense.

Bytecode Level

wait(), notify(), and notifyAll() are native methods — they invoke OS-level parking/unparking primitives (e.g., pthread_cond_wait on Linux). The JIT compiler cannot inline them the way it can user-space code, so monitor-based coordination does carry overhead compared to lock-free structures in java.util.concurrent.

Java 21 and Virtual Threads

With virtual threads (stable in Java 21), Object.wait() on a virtual thread correctly unmounts the virtual thread from its carrier platform thread, so you no longer block an OS thread. The semantics are identical, but scalability improves dramatically.

Common Mistakes

  • Calling wait() outside synchronized — throws IllegalMonitorStateException. Always synchronize on the same object you call wait()/notify() on.
  • Using if instead of while — leaves your code vulnerable to spurious wakeups and missed signals.
  • Notifying the wrong objectwait() and notify() must be called on the same object instance.
  • Forgetting InterruptedException — always handle or re-throw it; ignoring it silently suppresses thread interruptions.

Warning: Inter-thread communication with raw wait()/notify() is error-prone. For new code, strongly consider BlockingQueue, CountDownLatch, Semaphore, or Condition from java.util.concurrent — they are safer and more expressive.

Modern Alternative: Condition from ReentrantLock

The ReentrantLock API gives you explicit Condition objects, which allow multiple wait-sets per lock — something you cannot do with intrinsic monitors:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

class BetterBuffer {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private int item;
    private boolean hasItem = false;

    public void put(int v) throws InterruptedException {
        lock.lock();
        try {
            while (hasItem) notFull.await();
            item = v;
            hasItem = true;
            notEmpty.signal(); // wake only a consumer, not a producer
        } finally {
            lock.unlock();
        }
    }

    public int take() throws InterruptedException {
        lock.lock();
        try {
            while (!hasItem) notEmpty.await();
            hasItem = false;
            notFull.signal(); // wake only a producer
            return item;
        } finally {
            lock.unlock();
        }
    }
}

Using separate conditions eliminates the “wrong thread woken” problem that plagues notifyAll() on a single intrinsic monitor.

  • Synchronization — the foundation of thread safety that wait()/notify() builds on
  • Deadlock — a common pitfall when multiple threads wait for each other’s locks
  • ReentrantLock & Monitors — a more flexible alternative with explicit Condition objects
  • Thread Life Cycle — understand the WAITING and TIMED_WAITING states a thread enters during wait()
  • Callable & Future — higher-level coordination for tasks that return a result
  • Virtual Threads — how Project Loom changes the performance picture for blocking code
Last updated June 13, 2026
Was this helpful?