wait and notify in Java: How Threads Coordinate (Inter-Thread Communication)

  • Last Updated: September 21, 2026
  • By: javahandson
  • Series
img

wait and notify in Java: How Threads Coordinate (Inter-Thread Communication)

Learn wait and notify in Java the simple way. See why they live on Object, need synchronized, use a while loop, and how producer-consumer coordination works.

1. Introduction

Imagine two workers sharing one desk. One writes notes and drops them in a tray. The other picks the notes up and acts on them. What happens when the tray is empty? The second worker should not keep checking it a thousand times a second. That worker should rest, and wake up only when a note arrives. This little dance is what wait and notify in Java is all about.

In this series, you have learned that threads can run independently. You have also learned that the synchronized keyword prevents two threads from accessing shared data at the same time. This helps avoid conflicts between threads. However, it does not allow a thread to wait for a condition to be met. Remember, keeping threads separate is not the same as helping them work together.

In this guide, we will explain how threads communicate with each other. We’ll start by discussing why just using locks is not enough. Next, we will introduce three methods that allow threads to talk: wait(), notify(), and notifyAll(). Throughout this guide, we’ll answer common interview questions. We will explain why these methods are part of the Object class, why they need to be used inside a synchronized block, and why we use a while loop instead of an if statement to guard them.

2. Why Locking Alone Isn’t Enough

Let us start with what you already know. Then we will find the gap.

2.1 A Quick Recap of Mutual Exclusion

The synchronized keyword gives you mutual exclusion. Only one thread can hold a lock at a time. While that thread works, others wait their turn. This keeps shared data safe from half-finished updates.

But think about what this really does. It answers one question: who gets to touch the data right now? It says nothing about whether the data is ready to be used. Those are two different needs.

Here is a short way to hold the two apart. Mutual exclusion is about safety. It stops threads from stepping on each other. Coordination is about order. It decides when a thread should act. You need both, and the synchronized keyword only gives you the first one.

2.2 The Missing Piece: Waiting for a Condition

Go back to the two workers and the tray. The reader grabs the lock and looks in the tray. It is empty. Now what? The reader has the lock, but there is no work to do.

The reader cannot just hold the lock and stare at the empty tray. If it does, the writer can never get in to add a note. The whole thing freezes. So the reader needs a way to step back, let go of the lock, and rest until a note shows up.

That is the missing piece. A thread often needs to wait for a condition, not just for a turn. The condition here is simple: “the tray has at least one note.” Plain locking gives you no clean way to express that.

2.3 Why Busy-Waiting Is a Bad Idea

You might think of a quick fix. Just loop and keep checking. Something like this:

// Busy-waiting: please don't do this
while (tray.isEmpty()) {
    // spin and check again... and again... and again
}
Note note = tray.remove();

This works, but it wastes your CPU. The thread spins in a tight loop and burns cycles for nothing. It checks, finds nothing, and checks again right away. On a busy server, this can pin a whole core at full load while doing zero useful work.

There is a bigger problem as well. If this looping occurs inside a synchronized block, the thread keeps the lock the whole time. This prevents any other thread from entering to change the condition. As a result, the loop can never finish. You have accidentally created a deadlock.

We need something smarter. A thread should be able to say: “I give up the lock and I go to sleep. Wake me when things change.” That is exactly what wait() offers.

3. Meet wait(), notify(), and notifyAll()

Java gives you three methods for thread coordination. They are small, but they carry the whole idea of inter-thread communication. Let us take them one by one.

3.1 What wait() Does

When a thread calls the wait() method on an object, three main things happen. First, the thread gives up the lock it has on that object, allowing other threads to use it. Second, the thread goes into a waiting state, which means it stops running. Finally, the thread joins the wait set for that object, which is a special area where waiting threads are kept until they can be told to continue.

The key point is the lock release. A waiting thread does not hog the lock. It lets go, so another thread can come in and do its work. The sleeping thread stays put until someone wakes it.

💡 Interview Insight
wait() is not the same as Thread.sleep(). sleep() keeps the lock and just pauses for a set time. wait() gives up the lock and waits for a signal. Mixing these two up is a classic interview trap.

3.2 What notify() Does

The notify() method is used to awaken a single thread that is currently waiting on a specific object. In situations where multiple threads are waiting, the Java Virtual Machine (JVM) will select one thread to wake up, but it does not allow you to specify which thread will be chosen. It’s important to note that the selected thread will not begin execution immediately after being notified.

In this context, it’s important to understand a key detail: when a thread is awakened from a waiting state, it must reacquire the lock before continuing its execution. Since the notify() method is invoked within a synchronized block, the thread that calls notify() retains ownership of the lock. As a result, the awakened thread finds itself waiting again, this time for the lock to become available.

3.3 What notifyAll() Does

The notifyAll() method is designed to wake up all threads that are currently waiting on the object. When this method is called, all waiting threads exit the wait set and are then prompted to compete for the object’s lock. After this competition, one thread successfully acquires the lock and proceeds to execute, while all other threads return to a waiting state until they can gain access to the lock again.

This sounds wasteful, and sometimes it is. Yet it is far safer than notify() in many cases. We will see why in a later section. For now, just hold this thought: notifyAll() wakes everyone, notify() wakes one.

3.4 Where a Waiting Thread Actually Goes

It helps to picture the thread’s state during all this. When a thread calls wait(), it moves into the WAITING state. It leaves the running pool and sits idle. The scheduler skips over it, so it uses no CPU at all.

After a notify(), the thread does not jump straight back to running. First it moves into the BLOCKED state, where it waits for the lock. Only once it grabs the lock does it become runnable again. If you have read the Thread Life Cycle article, this path from WAITING to BLOCKED to RUNNABLE should feel familiar. The wait set and the entry set are just two waiting rooms attached to the same lock.

4. Why These Methods Live on Object, Not Thread

This one surprises a lot of people. You would expect wait() and notify() to sit on the Thread class. After all, they deal with threads. But they live on Object, the parent of every class in Java. Why?

4.1 Every Object Has a Monitor

In Java, every object carries a built-in lock. This lock is often called a monitor. When you write synchronized on an object, you are grabbing that object’s monitor. The lock belongs to the object, not to any thread.

In the context of threading, when a thread is waiting for a specific condition to be met, it does so on an object’s monitor. Meanwhile, another thread can signal this monitor to indicate that the condition has changed and that the waiting thread may proceed. Since the monitor is associated with the object itself, the methods that interact with this monitor must also be defined within the object’s class structure. This relationship ensures proper synchronization and communication between threads working on the same object.

4.2 The Lock Is the Meeting Point

Think of the object as a meeting room. The monitor is the key to that room. Threads coordinate by passing this one key around. They also leave messages tied to this same room.

In Java, placing the wait() method on the Object class provides clarity regarding which object’s lock is being released. If wait() were to be implemented as a method of the Thread class, it would be ambiguous about which lock was associated with the call. By having wait() on Object, when you invoke sharedObject.wait(), it is immediately clear which lock and wait set you are referring to. The shared object serves as a central point for coordination, meaning that it effectively owns the tools needed for synchronization.

5. Why wait() and notify() Must Run Inside synchronized

Try to call wait() outside a synchronized block, and your program throws an error at runtime. It is a hard rule of the language. Let us see why it exists.

5.1 The IllegalMonitorStateException

Here is the error you get if you break the rule:

Object lock = new Object();
lock.wait();   // no synchronized around it
 
// Runtime result:
// Exception in thread "main" java.lang.IllegalMonitorStateException

The name says it plainly. You tried to touch the monitor without owning it. To call wait() or notify() on an object, your thread must already hold that object’s lock. The synchronized block is how you get the lock in the first place.

5.2 wait() Releases the Lock, So You Must Own It First

Recall what wait() does. It releases the lock. But you cannot release something you do not hold. So the language forces you to own the lock before you wait. That is the whole logic behind the rule.

synchronized (lock) {
    // We hold the lock here.
    while (!conditionIsTrue()) {
        lock.wait();   // legal: we own the monitor
    }
    // do the work
}

The same goes for notify(). You signal on a monitor, so you must hold that monitor. Once the synchronized block ends, the lock is free, and a woken thread can grab it.

5.3 It Prevents the Lost Wakeup Problem

There is one more reason, and it is a good one. Checking the condition and calling wait() must happen as one atomic step. Suppose they did not. A thread could check the tray, find it empty, and pause for a moment. In that gap, another thread could add a note and call notify(). Then the first thread calls wait() and sleeps forever. The signal came and went while nobody listened.

This is the lost wakeup problem. The synchronized block prevents issues. Since both threads need the same lock, they cannot check and wait separately because of a sneaky notify. The lock ensures the process stays in order.

So the rule is not just red tape. It ties three things together into one safe unit: holding the lock, checking the condition, and waiting. Break that unit, and rare timing bugs creep in. These bugs hide for months and then strike under load. The language saves you from them by design.

6. The Guarded Wait: Why while, Not if

Look again at the pattern above. We wrapped wait() in a while loop, not an if. This is one of the most important habits in concurrent Java. Get it wrong, and your code fails in rare, ugly ways.

// The right way: guarded wait with a while loop
synchronized (lock) {
    while (!conditionIsTrue()) {
        lock.wait();
    }
    // Safe here: the condition is true AND we hold the lock
}

6.1 Spurious Wakeups Are Real

A thread can wake from wait() even when nobody called notify(). This is called a spurious wakeup. It is rare, but the Java spec allows it. So you can never assume that waking up means the condition is now true.

With an if, the thread would wake, skip the check, and run as if the condition held. That could be false. With a while, the thread wakes, checks the condition again, and goes back to sleep if it still is not ready. The loop protects you.

6.2 Stolen Notifications

There is a second reason, and it bites even more often. Say notifyAll() wakes three consumers. Only one note sits in the tray. The first consumer grabs the lock and takes the note. The other two also wake and line up for the lock.

By the time they get in, the tray is empty again. If they used an if, they would take a note that is not there. With a while, they re-check, see nothing, and wait once more. The condition may change between the notify and the moment a thread actually runs, so you must check it fresh.

6.3 The Rule to Remember

Keep this simple rule in your head. Always wait inside a while loop that tests the condition. Never trust a bare if around wait(). Treat the wakeup as a hint that says “maybe check again,” not a promise that says “you are good to go.”

There is an easy way to remember why. A notify() tells a thread that something may have changed. It does not tell the thread that its own condition is now true. Only the thread can confirm that, and only by checking again. So the while loop is that final check, run every single time before the thread moves on.

7. notify() vs notifyAll(): When Each Is Safe

Both methods wake waiting threads. So which one should you reach for? The honest answer is that notifyAll() is the safe default. Still, it helps to know the trade-off.

7.1 What notify() Risks

The notify() method wakes just one waiting thread, chosen by the JVM. That is cheap and fast. The danger shows up when different threads wait for different conditions on the same lock.

Picture producers and consumers waiting on one shared object. A producer calls notify() after adding an item. The JVM might wake another producer instead of a consumer. That producer checks its condition, finds nothing to do, and goes back to sleep. Meanwhile the consumer that should have run is still waiting. The signal was wasted, and your program can stall.

This kind of bug is nasty for one reason. It depends on timing, so it may not show up on your laptop. Then it appears on a busy server, at 2 a.m., under heavy traffic. By then it is hard to trace. A safe default up front spares you that pain.

7.2 Why notifyAll() Is the Safe Default

The notifyAll() method sidesteps that whole mess. It wakes every waiting thread. Each one checks its own condition in its while loop. The threads that have work to do proceed. The rest simply wait again. No signal gets lost.

Yes, this costs a little more. You wake threads that go straight back to sleep. On most apps, that cost is tiny and worth it. Correctness beats a small speed gain almost every time.

💡 Interview Insight
Rule of thumb: use notify() only when all waiting threads are truly interchangeable and wait for the exact same condition. When in doubt, use notifyAll(). A stalled program is a far worse bug than a few extra wakeups.

8. A Small Producer-Consumer Sketch

Let us pull it all together with a tiny example. This is the classic producer-consumer problem, stripped to its bones. One thread produces items. Another consumes them. They share a small buffer that holds a single item.

This sketch is here to show wait and notify in action. It is not a production-ready queue. We keep it minimal on purpose, so the coordination stands out.

8.1 The Shared Buffer

class Buffer {
    private int data;
    private boolean hasItem = false;
 
    public synchronized void put(int value) throws InterruptedException {
        while (hasItem) {          // full? wait for a consumer
            wait();
        }
        data = value;
        hasItem = true;
        notifyAll();               // tell consumers an item is ready
    }
 
    public synchronized int take() throws InterruptedException {
        while (!hasItem) {         // empty? wait for a producer
            wait();
        }
        hasItem = false;
        notifyAll();               // tell producers there is room
        return data;
    }
}

Read the two methods slowly. Each one is marked synchronized, so only a single thread runs it at a time. The waiting always sits inside a while loop. After changing the state, each method calls notifyAll() to signal the other side. So put() pauses while the buffer is full. Meanwhile take() pauses while it stays empty.

8.2 Wiring Up a Producer and a Consumer

Buffer buffer = new Buffer();
 
Thread producer = new Thread(() -> {
    for (int i = 1; i <= 5; i++) {
        try {
            buffer.put(i);
            System.out.println("Produced: " + i);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
});
 
Thread consumer = new Thread(() -> {
    for (int i = 1; i <= 5; i++) {
        try {
            int value = buffer.take();
            System.out.println("Consumed: " + value);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
});
 
producer.start();
consumer.start();

Run this, and the output stays in step. A producer never overwrites an unread item. No consumer ever reads an empty buffer. Instead, the two threads hand work back and forth cleanly. That handoff is inter-thread communication doing its job. Notice that neither thread checks the clock or spins in a loop. Each one simply waits until the other signals, then acts.

⚠️ Note
Java version note: the lambda syntax in the Runnable above needs Java 8 or later. On an older baseline, replace each lambda with an anonymous Runnable class. The wait/notify logic inside Buffer is the same on every Java version.

8.3 Where to Go From Here

In real code, you rarely write raw wait and notify like this. Java ships with ready-made tools that handle the hard parts for you. The most common one is BlockingQueue. It hides the locking, the waiting, and the signalling behind a clean interface.

We build the full, production-style producer-consumer with BlockingQueue in a dedicated Collections article. That version needs no manual wait or notify at all. For now, the sketch above shows you what those higher-level tools do under the hood. Once you understand this, the library classes stop feeling like magic.

Why learn the raw version at all, then? Because interviews still ask for it. Because bugs in old code still use it. And because knowing the low-level dance makes you trust the high-level tools for the right reasons. You reach for BlockingQueue not out of fear, but because you know exactly what it saves you from writing.

9. Common Mistakes to Avoid

These slips catch beginners and experienced developers alike. Watch for them in your own code and in reviews.

  • Calling wait() or notify() outside a synchronized block. This throws IllegalMonitorStateException every time.
  • Guarding wait() with an if instead of a while. This breaks on spurious wakeups and stolen notifications.
  • Confusing wait() with Thread.sleep(). Only wait() releases the lock; sleep() holds on to it.
  • Using notify() when threads wait on different conditions. The wrong thread may wake, and your program can stall.
  • Calling wait() and notify() on two different objects. They only coordinate when both use the very same object’s monitor.
  • Forgetting to change the shared state before calling notifyAll(). A wakeup with no state change just sends threads back to sleep.

10. Interview Questions

Q: Why are wait() and notify() defined in the Object class, not the Thread class?

A: Because coordination happens around an object’s monitor lock, and every object in Java has one. Threads wait and signal on a shared object, so the methods belong to that object. If they lived on Thread, there would be no clear way to say which lock to release.

Q: What is the difference between wait() and sleep() in Java?

A: wait() releases the object’s lock and waits for a notify() signal. sleep() keeps the lock and just pauses for a fixed time. wait() is for coordination; sleep() is only for delay. Also, wait() lives on Object while sleep() is a static method on Thread.

Q: Why must wait() and notify() be called inside a synchronized block?

A: You can only call them while holding the object’s monitor lock. wait() has to release that lock, so you must own it first. Calling them without the lock throws IllegalMonitorStateException. The synchronized block also prevents the lost-wakeup problem by keeping the condition check and the wait as one atomic step.

Q: Why should wait() be called inside a while loop instead of an if?

A: A thread can wake up spuriously, or the condition can change before it reacquires the lock (a stolen notification). A while loop re-checks the condition after every wakeup, so the thread only proceeds when the condition is truly satisfied. An if would run once and skip that re-check, leading to bugs.

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

A: notify() wakes one waiting thread, chosen by the JVM. notifyAll() wakes every waiting thread, and each re-checks its own condition. notifyAll() is the safer default because notify() can wake the wrong thread when threads wait on different conditions, which may stall the program.

Q: What is the lost wakeup problem?

A: It happens when a notify() fires in the gap between a thread checking a condition and calling wait(). The signal is missed, and the thread waits forever. Holding the same lock around both the check and the wait closes that gap and prevents it.

Q: Does notify() immediately run the woken thread?

A: No. notify() only moves a thread out of the wait set. That thread must reacquire the lock before it runs. Since notify() is called inside a synchronized block, the caller still holds the lock, so the woken thread waits until the block ends.

11. Conclusion

Let us tie the threads together. Locking keeps threads apart, but coordination lets them work together. That is the gap wait and notify in Java fills.

A thread calls wait() to release the lock and rest until a condition holds. Another thread calls notify() or notifyAll() to wake it after changing the shared state. These methods live on Object because every object carries a monitor. They run inside synchronized because you must own the lock to use them. And you always guard them with a while loop, never a bare if.

Learn this pattern well, and the higher-level tools in Java make instant sense. BlockingQueue, semaphores, and the rest all rest on this same foundation. Master the basics here, and the advanced stuff comes easy.

One last piece of advice. Reach for wait and notify only when no ready-made tool fits. For most real work, a class from java.util.concurrent will serve you better and safer. But when you do need the raw building blocks, or when an interviewer asks, you now know how they work and why each rule is there.

Further Reading

 

Leave a Comment