Semantics of wait() and notify()
/ 6 min read
Updated:Table of Contents
The usual mental model
When a thread calls wait(), we usually say that the thread goes to sleep.
When another thread calls notify(), we usually say that the waiting thread is woken up.
That explanation is convenient, but it leaves out a few important details.
What does it actually mean for a thread to “wait”? What happens to the monitor while it is waiting? And when notify() is called, does the waiting thread immediately start running?
These questions become more interesting when multiple threads are waiting on the same object.
To understand this, let’s look at a small blocking queue implementation using Java’s wait() and notify().
The queue has a fixed capacity. When the queue is full, a producer waits. When an element is removed, the consumer calls notify().
At first, this seems straightforward.
But when two producers are waiting at the same time, something interesting happens: the thread that continues after notify() is not necessarily the thread that started waiting first.
Let’s start with a small blocking queue implemented using a circular array. The queue uses putIndex to determine where the next element should be inserted and takeIndex to determine where the next element should be removed.
public synchronized void put(E data) throws Exception { while (isQueueFull(putIndex)) { System.out.println("Queue is full, waiting for any space available"); wait(); System.out.println("Wait complete"); }
arr[putIndex] = data; putIndex = nextIndex(putIndex);}The put() method is synchronized, which means the thread executing it must first acquire the monitor of the queue object.
If the queue is full, the producer cannot insert the element, so it calls wait().
Calling wait() does two important things:
- The thread enters the
WAITINGstate. - It releases the monitor of the queue object.
Releasing the monitor is important here. Without it, another thread would not be able to enter a synchronized method such as take() and make space in the queue.
Another producer can now acquire the same monitor and enter put(). If the queue is still full, that producer also calls wait().
At this point, we can have multiple threads waiting on the same queue object.
What happens when an element is removed?
So far, both producer threads are waiting because the queue is full.
For a waiting producer to continue, the state of the queue needs to change. In our case, that happens when another thread removes an element.
The take() method removes an element from the queue and then calls notify().
public synchronized E take() { System.out.println("Taking value");
E data = (E) arr[takeIndex];
if (data == null) { System.out.println("Nothing to pull"); return null; }
arr[takeIndex] = null; takeIndex = nextIndex(takeIndex);
notify();
return data;}The order here is important.
First, an element is removed from the queue:
arr[takeIndex] = null;This changes the state of the queue. There is now space available for a producer.
Then we call:
notify();At this point, there are producers waiting and the queue has space available.
But what exactly does notify() do?
Does it immediately wake a producer?
Does that producer start executing while take() is still running?
And if two producers are waiting, which one continues?
To answer these questions, we need to look more closely at what notify() actually means.
What does notify() actually do?
At this point, we have two producer threads waiting on the same queue object.
The consumer removes an element from the queue and calls:
notify();It is tempting to think that notify() simply wakes one of the waiting threads and that thread immediately continues executing.
That is not quite what happens.
notify() selects one thread from the object’s wait set. That thread becomes eligible to reacquire the monitor, but it does not immediately resume execution.
The reason is that the consumer still holds the queue object’s monitor.
Remember that take() is a synchronized method:
public synchronized E take() { // ... notify(); // ...}When notify() is called, the consumer is still inside this method and still owns the monitor.
The waiting thread therefore has to wait until it can reacquire the same monitor.
The sequence is roughly:
Producer Consumer
wait() | | releases monitor |WAITING take() | remove element | notify() | | consumer still | owns monitor | take() completes | monitor released | waiting producer can reacquire it | wait() returns | while condition is checkedThis is an important distinction.
notify() does not mean:
“Run this thread now.”
It means that a waiting thread can proceed once it successfully reacquires the monitor.
This also explains why notify() does not guarantee which waiting producer will continue first.
Why is wait() inside a while loop?
There is another important detail in the put() method:
while (isQueueFull(putIndex)) { wait();}Why use while here instead of if?
It might initially seem that this would be enough:
if (isQueueFull(putIndex)) { wait();}The assumption would be:
- The queue is full.
- The producer calls
wait(). - Another thread removes an element.
notify()is called.- The producer wakes up.
- There must now be space available.
But step 6 is not guaranteed.
A thread returning from wait() means that it has stopped waiting and reacquired the monitor. This can happen because it was notified, interrupted, or due to a spurious wakeup.
Even after the thread returns from wait(), the condition it was waiting for still needs to be checked.
Consider two producers waiting for space:
Producer A → waitingProducer B → waitingThe consumer removes one element and calls notify().
One producer can then proceed, but the state of the queue may have changed again before another waiting thread gets a chance to continue.
This is why the condition must always be checked again after wait() returns:
while (isQueueFull(putIndex)) { wait();}If the queue is still full, the producer goes back to waiting.
If there is space, it continues and inserts the element.
There is another reason for the while loop: Java permits spurious wakeups, where a thread can return from wait() even though it was not notified.
So the general pattern when using wait() is:
while (!condition) { wait();}The important idea is that wait() is not the condition itself. It is a way for a thread to wait until the shared state may have changed.
When the thread continues, it must check the shared state again.
Putting it together
The behavior of wait() and notify() becomes easier to understand if we stop thinking of notify() as simply “waking up a thread”.
A thread calling wait() does three important things:
wait() ↓release the object's monitor ↓enter WAITING stateWhen another thread calls notify():
notify() ↓one waiting thread is selected ↓thread becomes eligible to reacquire the monitor ↓monitor is reacquired ↓wait() returns ↓condition is checked againThe thread does not immediately start executing when notify() is called. The thread that called notify() still owns the monitor until it leaves the synchronized method or block.
This is also why notify() does not provide ordering between waiting threads. If multiple threads are waiting, there is no guarantee that the thread that started waiting first will be the one that proceeds first.
The most useful mental model is this:
wait()releases the monitor and causes the thread to wait.notify()signals that the shared state may have changed. The waiting thread still has to reacquire the monitor and re-check the condition before continuing.
Once this model is clear, wait() and notify() are less about “sleeping” and “waking” threads, and more about coordinating access to shared state through an object’s monitor.