Why this matters
wait/notifyhas four separate requirements, and omitting any one produces a hang or a missed signal that appears only under load.- The producer-consumer problem is a standard interview exercise, and the blocking-queue answer is both shorter and better.
- Deadlock, livelock and starvation are distinct failures with distinct fixes, and the names are used interchangeably far too often.
wait and notify
These three methods live on Object, because every object has a monitor.
public class Buffer<T> {
private final Queue<T> items = new ArrayDeque<>();
private final int capacity;
public Buffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
while (items.size() == capacity) { // while, never if
wait(); // releases the monitor and waits
}
items.add(item);
notifyAll(); // a consumer may be waiting
}
public synchronized T take() throws InterruptedException {
while (items.isEmpty()) {
wait();
}
T item = items.poll();
notifyAll(); // a producer may be waiting
return item;
}
}
The four requirements
- Call them while holding the monitor. Outside a
synchronizedblock you getIllegalMonitorStateException. - Wait in a
whileloop, never anif. A thread can wake without being notified — a spurious wakeup — and even a genuine notification does not guarantee the condition still holds by the time it reacquires the lock. Re-check always. - Prefer
notifyAll()overnotify().notify()wakes one arbitrary waiter, which may be waiting for a different condition. The right thread then never wakes and the system stalls. wait()releases the monitor;sleep()does not. This is the whole reasonwaitexists.
BlockingQueue does this for you
The whole class above is one line of the standard library.
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);
// Producer — blocks when the queue is full
queue.put(task);
// Consumer — blocks when the queue is empty
Task task = queue.take();
// With a deadline, when waiting forever is unacceptable
Task maybe = queue.poll(5, TimeUnit.SECONDS); // null if nothing arrives
// A complete producer-consumer pipeline
BlockingQueue<Order> pipeline = new ArrayBlockingQueue<>(500);
ExecutorService pool = Executors.newFixedThreadPool(4);
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
while (!Thread.currentThread().isInterrupted()) {
try {
process(pipeline.take());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
});
}
No wait, no notify, no synchronized, and no opportunity to get the loop condition wrong.
Condition objects
ReentrantLock offers several independent wait sets, which solves the problem notifyAll works around.
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) notFull.await();
items.add(item);
notEmpty.signal(); // wakes exactly a consumer, not every waiter
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T item = items.poll();
notFull.signal(); // wakes exactly a producer
return item;
} finally {
lock.unlock();
}
}
Because producers and consumers wait on separate conditions, signal() is safe here — it can only wake a
thread waiting for the condition that just became true. The while loop is still required for spurious
wakeups.
The coordination utilities
| Aspect | Utility | What it coordinates |
|---|---|---|
| CountDownLatch | Wait for N events, once | Cannot be reset |
| CyclicBarrier | N threads meet, then all proceed | Reusable across rounds |
| Semaphore | Limit concurrent access to N permits | Rate limiting, resource pools |
| Phaser | A barrier with a changing party count | Threads may join or leave |
| Exchanger | Two threads swap objects | A rendezvous for exactly two |
CountDownLatch
UtilityWait for N events, onceWhat it coordinatesCannot be resetCyclicBarrier
UtilityN threads meet, then all proceedWhat it coordinatesReusable across roundsSemaphore
UtilityLimit concurrent access to N permitsWhat it coordinatesRate limiting, resource poolsPhaser
UtilityA barrier with a changing party countWhat it coordinatesThreads may join or leaveExchanger
UtilityTwo threads swap objectsWhat it coordinatesA rendezvous for exactly two
Latch counts down once; barrier resets; semaphore counts permits up and down.
CountDownLatch
CountDownLatch ready = new CountDownLatch(3);
for (String service : List.of("db", "cache", "queue")) {
pool.submit(() -> {
initialise(service);
ready.countDown(); // one fewer to wait for
});
}
ready.await(); // blocks until the count reaches zero
System.out.println("All services ready");
One-shot by design. Once the count reaches zero, await() returns immediately forever, which makes it ideal
for startup gating and for coordinating the end of a test.
CyclicBarrier
CyclicBarrier barrier = new CyclicBarrier(4, () -> System.out.println("Round complete"));
for (int i = 0; i < 4; i++) {
pool.submit(() -> {
for (int round = 0; round < 3; round++) {
computePartition();
barrier.await(); // waits for all four, then all continue
}
});
}
The optional action runs once per round, on the last thread to arrive, before any are released. Unlike a latch the barrier resets, so it suits iterative parallel algorithms.
Semaphore
private final Semaphore permits = new Semaphore(10); // at most 10 at a time
public void callExternalApi() throws InterruptedException {
permits.acquire();
try {
httpClient.send(request);
} finally {
permits.release(); // always, or permits leak away permanently
}
}
// Non-blocking variant: fail fast rather than queue
if (permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
try { call(); } finally { permits.release(); }
} else {
throw new RateLimitedException();
}
A semaphore with one permit behaves like a lock, with one difference: any thread may release it, not only the one that acquired it. That flexibility is occasionally useful and occasionally the source of a bug.
Deadlock, livelock, starvation
Fix: every thread takes lock 1 before lock 2. No cycle, no deadlock.
// Deadlock: two locks acquired in opposite orders
void transferA(Account from, Account to, BigDecimal amount) {
synchronized (from) {
synchronized (to) { move(from, to, amount); }
}
}
// Thread 1 calls transferA(x, y) and Thread 2 calls transferA(y, x).
// Thread 1 holds x and wants y; Thread 2 holds y and wants x. Neither proceeds.
The standard fix is a global lock ordering: always acquire in the same sequence, regardless of the operation.
void transfer(Account from, Account to, BigDecimal amount) {
Account first = from.id().compareTo(to.id()) < 0 ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
move(from, to, amount);
}
}
}
| Aspect | Failure | Cause and fix |
|---|---|---|
| Deadlock | Circular waiting; nothing moves | Order locks consistently, or use tryLock with timeout |
| Livelock | Threads act but make no progress | Add randomised backoff before retrying |
| Starvation | A thread never gets its turn | Use a fair lock, or shorten critical sections |
Deadlock
FailureCircular waiting; nothing movesCause and fixOrder locks consistently, or use tryLock with timeoutLivelock
FailureThreads act but make no progressCause and fixAdd randomised backoff before retryingStarvation
FailureA thread never gets its turnCause and fixUse a fair lock, or shorten critical sections
Deadlock is frozen; livelock is busy and pointless; starvation is one unlucky thread.
Preventing deadlock
- Acquire locks in a consistent global order, derived from something stable like an identifier.
- Use
tryLockwith a timeout, so a cycle resolves itself into a retry instead of a hang. - Hold one lock at a time where possible. Most deadlocks need at least two.
- Never call unknown code while holding a lock — a callback may acquire another lock you cannot see.
- Keep critical sections short, which reduces both deadlock windows and contention.
Common misreadings
- "
waitin anifis fine if you callnotifycorrectly." Spurious wakeups exist, and the condition can change between the wakeup and the lock reacquisition. Alwayswhile. - "
notify()is more efficient thannotifyAll()." It is, and it is also wrong whenever more than one condition shares the monitor. The saving is not worth the stall. - "
wait()andsleep()are interchangeable."waitreleases the monitor;sleepkeeps it. - "A
CountDownLatchcan be reused." It cannot. Use aCyclicBarrier. - "A
Semaphoreis just a lock." Any thread may release it, and it admits N holders rather than one. - "Deadlock requires two locks." Two is typical, but a single lock plus a blocking call that needs the same lock also deadlocks.
Quick recall
Everything you need if you only revisit this box.
wait/notifyrequire: holding the monitor, waiting in awhileloop, preferringnotifyAll(), and remembering thatwaitreleases the lock whilesleepdoes not.- Prefer a
BlockingQueuefor producer-consumer.putandtakeblock;polltakes a timeout; a poison pill shuts the pipeline down cleanly. ReentrantLockConditionobjects give separate wait sets, makingsignal()safe.CountDownLatchwaits for N events once;CyclicBarrierresets each round;Semaphorelimits concurrency to N permits — alwaysrelease()in afinally.- Deadlock is circular waiting — fix with a consistent global lock order or
tryLockwith a timeout. - Livelock means activity without progress — add randomised backoff. Starvation means one thread never runs — use a fair lock.
jcmd <pid> Thread.printdetects deadlocks automatically.
Test yourself
Answer these before moving on — recall is what makes it stick.