The waiter who kept asking
In Riya's canteen simulation, the waiter thread checks the counter in a loop: “Is the dosa ready? Is it ready now? Now?” Millions of times a second. Her laptop fan starts screaming, and the chef thread runs slower because the waiter hogs the CPU.
Sir, how can the waiter wait without checking all the time?
He goes to sleep on the counter's lock with wait(). When the chef puts a dish, the chef rings the bell: notify(). The waiter wakes only when something changed.
But the counter is locked with synchronized. If the waiter sleeps inside, how does the chef get in?
That's the clever part: wait() gives up the lock while it sleeps, and takes it back before it returns. Let's watch it inside the monitor.
What is inter-thread communication?
Inter-thread communication is the coordination of threads so that one thread waits until a condition becomes true and another thread signals when it changes. In Java it is done with the Object methods wait(), notify() and notifyAll(), which must be called while holding the object's monitor. wait() releases the monitor and suspends the thread in the object's wait set until it is notified, interrupted or times out; it then re-acquires the monitor before returning. notify() wakes one thread from the wait set, notifyAll() wakes all of them.
wait() = “wake me when something changes” (and let others use the room meanwhile). notifyAll() = “something changed, everyone check again”.
Words you'll meet in this lesson
wait() on this object (state WAITING).put/take wait for space/items.wait() vs sleep()
obj.wait() | Thread.sleep(ms) | |
|---|---|---|
| Defined in | Object | Thread (static) |
| Must hold the lock? | Yes, of obj | No |
| Releases the lock? | Yes | No: keeps any locks held |
| Wakes up when | notified, interrupted, or timeout | time is up, or interrupted |
| Purpose | Wait for a condition | Pause for a time |
Exam tip: three rules to write in every answer: (1) call wait/notify inside synchronized on the same object, else IllegalMonitorStateException; (2) wait releases the lock, sleep doesn't; (3) always call wait in a while loop that re-checks the condition.
The pattern, and the tools built on it
The one pattern to memorise
synchronized (lock) {
while (!condition) { // ALWAYS a loop, never just "if"
lock.wait(); // releases lock, waits, re-acquires before returning
}
// condition is true and we hold the lock: act on it
}
synchronized (lock) {
changeTheCondition();
lock.notifyAll(); // wake the waiters so they re-check
}Forget the lock and Java stops you
public class Main {
public static void main(String[] args) {
Object lock = new Object();
try {
lock.notify();
} catch (IllegalMonitorStateException e) {
System.out.println(e);
}
}
}Ways to make threads wait for each other
wait / notifyAll
The low-level tool every Java object supports. Know it for exams and interviews.
built inBlockingQueue
ArrayBlockingQueue, LinkedBlockingQueue: producer–consumer ready-made.
CountDownLatch
Wait until N events have happened (“all 3 services started”).
one-time gatejoin()
Wait until a thread has finished: the simplest coordination of all (Lesson 12.3).
wait for the endNotify without a condition is a lost signal. If notify() runs before the other thread calls wait(), nobody hears it and the waiter may sleep forever. Always pair the signal with a shared variable (a flag, a count, a buffer) that the waiter checks first.
One wait() and one notify(), in order
The consumer takes the lock and checks
Insidesynchronized take()the waiter seesdish == null. It can't proceed.wait(): release and sleep
The JVM releases the monitor and moves the thread to the object's wait set (WAITING). The lock is now free for others.The producer changes the state
The chef enterssynchronized put(), setsdishand callsnotifyAll(). The waiter moves from the wait set to the entry set (BLOCKED): it still needs the lock.The producer leaves
Whenput()returns, the monitor is released. The waiter acquires it andwait()finally returns.Re-check, then act
Thewhileloop checks again (another consumer might have taken the dish first). The condition is true, so the waiter takes the dish and notifies in turn.
Chef, waiter and the monitor: both views
Left: the kitchen as you'd describe it. Right: inside the counter object's monitor, with its owner, entry set and wait set.
- The waiter thread finds the counter empty. Instead of checking again and again, it calls
wait()and sleeps: state WAITING, no CPU used. - The chef thread puts a dosa on the counter and calls
notify()to wake one waiting thread. - The waiter wakes up, re-checks the condition (the counter now has food) and takes the dosa.
- Later the chef has an idli ready but the counter is still full, so now the chef waits.
- The waiter takes the idli and calls
notify(), waking the chef. - The chef re-checks: the counter is free, so it puts the vada. Each side waits only when it can't continue. This is the producer–consumer pattern.
- Every object's monitor has an owner, an entry set (threads BLOCKED trying to get the lock) and a wait set (threads that called
wait()). Callingwait()releases the lock and puts the waiter in the wait set. - Because the lock was released, the chef can enter and own it.
notify()moves one thread from the wait set to the entry set. It doesn't run yet: the chef still owns the lock. - When the chef leaves the synchronized method, the waiter re-acquires the lock and only then returns from
wait(). - Same mechanism the other way: the chef can't continue, so it waits and releases the lock.
- The waiter takes the dish and notifies; the chef moves to the entry set.
- The chef gets the lock back and, because the call sits in a
whileloop, re-checks the condition before acting. Always wait in a loop: a thread can wake up when the condition is still false.
Producers and consumers everywhere
Most back-end systems are chains of producers and consumers connected by queues.
Watch it with an example: restaurant orders and delivery riders
A food app's restaurant thread produces orders into a queue that holds at most 2. A rider thread takes them. BlockingQueue does all the waiting and notifying for you.
- The restaurant thread calls
queue.put(order1). - Order #2 goes in too. The queue's capacity is 2, so it is now full.
put(order3)can't proceed: the restaurant thread waits insideput()(no busy loop, no CPU). This back-pressure stops a fast producer from flooding memory.- The rider calls
take(), gets #1, and the queue signals the waiting producer, which adds #3. - The rider takes #2.
- After #3 the queue is empty, so the rider's next
take()waits until a new order arrives. Inside, BlockingQueue uses a lock and conditions: the same idea as wait/notify, already written and tested.
1. Order and notification pipelines
Checkout produces events; email, SMS and invoice workers consume them from queues at their own pace.
2. Logging
Request threads put log lines into an in-memory queue; one background writer thread takes them and writes to disk, so requests never wait for the disk.
3. Startup and readiness
A server waits until its database pool, caches and configs are ready (a latch) before accepting traffic.
4. Waiting for confirmations
Payment flows, OTP verification and seat holds wait for an external event, always with a timeout so nothing waits forever.
Simplified. Large systems connect services with message brokers (such as Kafka or RabbitMQ), which apply the same producer–consumer idea between machines.
Chef and waiter, four dishes
class Counter {
private String dish;
synchronized void put(String d) throws InterruptedException {
while (dish != null) wait();
dish = d;
System.out.println("chef put " + d);
notifyAll();
}
synchronized String take() throws InterruptedException {
while (dish == null) wait();
String d = dish;
dish = null;
System.out.println("waiter took " + d);
notifyAll();
return d;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread chef = new Thread(() -> {
try {
for (String d : new String[]{"dosa", "idli", "vada", "upma"}) counter.put(d);
} catch (InterruptedException e) { }
});
Thread waiter = new Thread(() -> {
try {
for (int i = 0; i < 4; i++) counter.take();
} catch (InterruptedException e) { }
});
chef.start();
waiter.start();
chef.join();
waiter.join();
}
}while (dish != null) wait();The chef waits while the counter is full, releasing the lock.while (dish == null) wait();The waiter waits while it's empty.notifyAll()After every change, wake the other side so it re-checks.- printing inside the methodsPrints happen while holding the lock, in the true order of puts and takes, so the output is the same every run.
main waits for the chef's dish
Recorded from the real JVM, following main, which plays the waiter. Watch it enter take(), find nothing, and wait; when wait() returns, the dish set by the chef thread is in the shared object.
Worked examples: real problems, solved and traced in memory
Three coordination problems from real apps: a one-slot hand-off, waiting for a confirmation, and a message queue. Notice that every wait() sits inside a while loop and a synchronized method.
Chef and waiter with a one-dish counter
Problem statement
A chef cooks 3 dishes and a waiter serves them through a counter that holds one dish. The chef must wait if the counter is full; the waiter must wait if it's empty. Print each put and take.
dishes: dosa, idli, vada
chef put dosa waiter took dosa chef put idli waiter took idli chef put vada waiter took vada all dishes served
Approach
- A
Counterclass with synchronizedputandtake. putwaits while full;takewaits while empty. Each callsnotifyAll()after changing the counter.- Print inside the synchronized methods so the order of lines is always put, take, put, take…
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Wait until the gateway confirms
Problem statement
After a customer pays, the order thread must not ship until the payment gateway confirms. The gateway runs on its own thread and confirms after a short delay. Use a lock object, a flag, wait() and notifyAll().
payment id PAY-551; gateway answers after 100 ms
order: waiting for payment gateway: PAY-551 confirmed order: shipping now
Approach
- A small
Paymentclass with aconfirmedflag. awaitConfirmation()waits in a loop while the flag is false.confirm()sets the flag and callsnotifyAll().
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
An SMS dispatcher with a BlockingQueue
Problem statement
A notification service queues SMS messages; a dispatcher thread sends them one by one. To shut down, the producer puts a special STOP message (a “poison pill”). Use ArrayBlockingQueue instead of writing wait/notify yourself.
messages: OTP 4821, Order shipped, Refund done, then STOP
sent: OTP 4821 sent: Order shipped sent: Refund done dispatcher stopped
Approach
- Create an
ArrayBlockingQueue<String>with capacity 2. - The dispatcher loops on
take()and stops when it receives STOP. - main
puts the messages and then STOP, and joins the dispatcher.
Solution: run it live
What happens in memory, step by step
Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.
Check yourself
Quick revision
Lesson 12.5 in one screen
wait/notify/notifyAllareObjectmethods; call them while holding that object's lock.- Otherwise:
IllegalMonitorStateException: current thread is not owner. wait()releases the lock, waits in the wait set, and re-acquires the lock before returning.- Always
while (!condition) wait();: neverif. - Signal with a shared condition variable, then
notifyAll(); a bare notify can be lost. wait()→ WAITING;wait(ms)→ TIMED_WAITING; both throwInterruptedException.- In real code prefer
BlockingQueue,CountDownLatchand friends.
Interview and viva questions
Why are wait() and notify() in Object and not in Thread?
Because threads wait on a condition associated with a lock, and every object can be a lock (monitor). The method is called on the object whose monitor you hold, not on a thread.
Why must wait() be called from a synchronized context?
The check of the condition and the decision to wait must be atomic with respect to the thread that changes it; otherwise a notify could slip in between and be lost. Requiring the lock enforces this, and the JVM throws IllegalMonitorStateException if you don't hold it.
Why use while instead of if around wait()?
After waking, the condition may no longer be true: another thread may have consumed the item, notifyAll may have woken threads waiting for different conditions, or a spurious wakeup may occur. The loop re-checks before acting.
Difference between notify() and notifyAll()?
notify() wakes one arbitrary thread from the wait set; notifyAll() wakes them all, and each competes for the lock and re-checks. notify is a small optimisation that is only safe when all waiters wait for the same condition and any one of them can handle it.
How would you implement producer–consumer?
With a bounded buffer whose put waits while full and take waits while empty, both synchronized, each calling notifyAll() after changing the buffer. In production, use ArrayBlockingQueue or LinkedBlockingQueue, which provide exactly this.
Practice problems
Coordinate threads with wait/notifyAll, then with the ready-made tools in java.util.concurrent. Outputs are fixed because every print happens in a guaranteed order. The last three are business tasks.
wait() outside synchronized
Call wait() on an object without holding its lock, catch the exception and print it.
(none)
error: current thread is not owner
Show solution and explanation
public class Main {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
try {
lock.wait();
} catch (IllegalMonitorStateException e) {
System.out.println("error: " + e.getMessage());
}
}
}How it works: wait, notify and notifyAll must be called by the thread that owns the object's monitor, i.e. inside synchronized (lock). Otherwise the JVM throws IllegalMonitorStateException.
Wake a waiting thread
A thread waits until ready becomes true. main sets it and calls notifyAll(). The thread then prints got the signal.
(none)
got the signal
Show solution and explanation
public class Main {
static boolean ready = false;
static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
synchronized (LOCK) {
while (!ready) {
try { LOCK.wait(); } catch (InterruptedException e) { return; }
}
System.out.println("got the signal");
}
});
t.start();
synchronized (LOCK) {
ready = true;
LOCK.notifyAll();
}
t.join();
}
}How it works: the flag makes it safe even if main signals before the thread starts waiting: the thread checks ready first and doesn't wait at all. A notify with no flag would be lost.
A bounded queue refuses extras
Create an ArrayBlockingQueue of capacity 2, offer three items, and print the results and the queue.
a, b, c
true true false [a, b]
Show solution and explanation
import java.util.concurrent.ArrayBlockingQueue;
public class Main {
public static void main(String[] args) {
ArrayBlockingQueue<String> q = new ArrayBlockingQueue<>(2);
System.out.println(q.offer("a") + " " + q.offer("b") + " " + q.offer("c"));
System.out.println(q);
}
}How it works: offer returns false at once when full; put would wait instead; add would throw. Pick the method that matches what should happen when the system is overloaded.
Ping-pong in turns
Two threads print ping and pong alternately, 3 times each, starting with ping. Use a shared turn flag with wait/notifyAll.
(none)
ping pong ping pong ping pong
Show solution and explanation
public class Main {
static boolean pingTurn = true;
static final Object LOCK = new Object();
static void play(String word, boolean mine, int times) {
for (int i = 0; i < times; i++) {
synchronized (LOCK) {
while (pingTurn != mine) {
try { LOCK.wait(); } catch (InterruptedException e) { return; }
}
System.out.println(word);
pingTurn = !pingTurn;
LOCK.notifyAll();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread ping = new Thread(() -> play("ping", true, 3));
Thread pong = new Thread(() -> play("pong", false, 3));
pong.start();
ping.start();
ping.join();
pong.join();
}
}How it works: each thread waits until it's its turn, prints, flips the flag and wakes the other. Even though pong starts first, it waits for ping.
notifyAll wakes everyone
Three threads wait for a gateOpen flag. main opens the gate with one notifyAll(). Count how many threads passed.
3 waiting threads
fans through the gate: 3
Show solution and explanation
import java.util.concurrent.atomic.AtomicInteger;
public class Main {
static boolean gateOpen = false;
static final Object GATE = new Object();
public static void main(String[] args) throws InterruptedException {
AtomicInteger passed = new AtomicInteger();
Thread[] fans = new Thread[3];
for (int i = 0; i < 3; i++) {
fans[i] = new Thread(() -> {
synchronized (GATE) {
while (!gateOpen) {
try { GATE.wait(); } catch (InterruptedException e) { return; }
}
}
passed.incrementAndGet();
});
fans[i].start();
}
synchronized (GATE) {
gateOpen = true;
GATE.notifyAll();
}
for (Thread f : fans) f.join();
System.out.println("fans through the gate: " + passed.get());
}
}How it works: notify() wakes only one waiting thread; notifyAll() wakes all, and each re-checks the condition. When several threads may be waiting, notifyAll is the safe default.
Wait for three services with CountDownLatch
An app starts only when the database, cache and payment services are ready. Each starts on its own thread and counts down a latch. main waits on the latch.
3 services
all 3 services ready, latch = 0
Show solution and explanation
import java.util.concurrent.CountDownLatch;
public class Main {
public static void main(String[] args) throws InterruptedException {
String[] services = {"database", "cache", "payments"};
CountDownLatch ready = new CountDownLatch(services.length);
for (String s : services) {
new Thread(() -> ready.countDown(), s).start();
}
ready.await();
System.out.println("all " + services.length + " services ready, latch = " + ready.getCount());
}
}How it works: await() waits until the count reaches 0. It's a ready-made “wait until N things have happened”, built on the same waiting machinery as wait/notify.
Odd and even invoice numbers
Two printers share the invoice numbers 1–10: one prints odd numbers, the other even, strictly in order. Use wait/notifyAll on a shared counter.
1..10
printer-A: INV-1 printer-B: INV-2 printer-A: INV-3 printer-B: INV-4 printer-A: INV-5 printer-B: INV-6 printer-A: INV-7 printer-B: INV-8 printer-A: INV-9 printer-B: INV-10
Show solution and explanation
public class Main {
static int next = 1;
static final Object LOCK = new Object();
static void printer(String name, int parity) {
while (true) {
synchronized (LOCK) {
while (next <= 10 && next % 2 != parity) {
try { LOCK.wait(); } catch (InterruptedException e) { return; }
}
if (next > 10) {
LOCK.notifyAll();
return;
}
System.out.println(name + ": INV-" + next);
next++;
LOCK.notifyAll();
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread odd = new Thread(() -> printer("printer-A", 1));
Thread even = new Thread(() -> printer("printer-B", 0));
odd.start();
even.start();
odd.join();
even.join();
}
}How it works: each printer waits until the next number has its parity. The next > 10 check lets both threads finish; without the final notifyAll one of them could wait forever.
Order pipeline with a BlockingQueue
A restaurant thread produces 5 orders with ids 101–105 into a queue of capacity 2. A rider thread takes exactly 5 orders and adds up their bill amounts (id × 2). Print the total.
orders 101..105
delivered 5 orders, bills total Rs 1030
Show solution and explanation
import java.util.concurrent.*;
public class Main {
public static void main(String[] args) throws InterruptedException {
BlockingQueue<Integer> orders = new ArrayBlockingQueue<>(2);
int[] total = {0};
Thread restaurant = new Thread(() -> {
try {
for (int id = 101; id <= 105; id++) orders.put(id);
} catch (InterruptedException e) { }
});
Thread rider = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) total[0] += orders.take() * 2;
} catch (InterruptedException e) { }
});
restaurant.start();
rider.start();
restaurant.join();
rider.join();
System.out.println("delivered 5 orders, bills total Rs " + total[0]);
}
}How it works: the queue's capacity limits how far the restaurant can get ahead of the rider. total is written only by the rider and read after join(), so no lock is needed for it.
Seat hold that expires
A cinema holds a seat while the customer pays, but for at most 200 ms. The payment never arrives. Use wait(timeout) in a loop with a deadline, then release the seat.
hold 200 ms, no payment
hold expired, seat released
Show solution and explanation
public class Main {
static boolean paid = false;
static final Object LOCK = new Object();
public static void main(String[] args) throws InterruptedException {
long deadline = System.currentTimeMillis() + 200;
synchronized (LOCK) {
long left;
while (!paid && (left = deadline - System.currentTimeMillis()) > 0) {
LOCK.wait(left);
}
System.out.println(paid ? "seat confirmed" : "hold expired, seat released");
}
}
}How it works: wait(ms) returns on notify, on timeout, or spuriously, so the loop recomputes the time left and re-checks the flag. This is the correct pattern for “wait for a condition, but not forever”.
Real-world scenario questions (asked in company interviews)
Coordination bugs freeze systems silently, so interviewers like to see you reason about who waits for whom.
The worker that eats a CPU core
A report worker waits for jobs with while (jobs.isEmpty()) { }. The server shows one core at 100% even when there are no jobs. How would you rewrite it?
The interviewer is checking: busy waiting vs blocking.
Show a strong answer
- The empty loop spins constantly. Replace the list with a
BlockingQueueand calltake(), which waits without using CPU. - If a plain list must stay, guard it with a lock and use
while (jobs.isEmpty()) lock.wait();withnotifyAll()when adding.
Avoid: Thread.sleep(10) in the loop as the solution: it still wastes wakeups and adds latency.
Occasionally, the consumer never wakes up
Once in a few thousand runs, a consumer thread stays WAITING forever although an item was added. The producer does buffer.add(x); synchronized (lock) { lock.notify(); }, and the consumer does synchronized (lock) { lock.wait(); } item = buffer.poll();. What's wrong?
The interviewer is checking: lost notifications and the guarded-wait pattern.
Show a strong answer
- The consumer waits without checking a condition. If the notify happens before the consumer reaches
wait(), it is lost. - Fix: update the buffer and check it under the same lock:
synchronized (lock) { while (buffer.isEmpty()) lock.wait(); item = buffer.poll(); }, and the producer adds and notifies inside the lock. - Or replace it all with a BlockingQueue.
Avoid: adding a sleep before wait to “make the timing work”.
OutOfMemoryError during a sale
Order events go into an unbounded LinkedBlockingQueue consumed by an email worker. During a sale, producers add 5,000 events/s, the worker sends 300 emails/s, and after an hour the JVM runs out of memory. Fix the design.
The interviewer is checking: back-pressure and bounded queues.
Show a strong answer
- The queue grows without limit because consumers are slower than producers.
- Use a bounded queue (
new ArrayBlockingQueue<>(10_000)) so producers slow down, or useofferwith a timeout and handle rejection (store for later, drop low-priority events). - Add more consumer threads if the email provider allows it, and monitor queue length.
Avoid: just increasing the heap; it only delays the crash.
Stopping consumers cleanly
Four consumer threads loop on queue.take(). At shutdown the app must finish processing everything already queued, then stop all four. How?
The interviewer is checking: poison pills and interruption.
Show a strong answer
- After the last real item, put one “poison pill” per consumer (4 STOP messages). Each consumer exits when it takes one, so all real items are processed first.
- Alternatively stop producers, wait until the queue is empty, then interrupt the consumers;
take()throwsInterruptedExceptionand they exit. - With an
ExecutorService, useshutdown()+awaitTermination()(Lesson 12.6).
Avoid: daemon consumers that are killed with work still in the queue.