PrepZone Logo
PrepZone

Coordinating Threads

wait and notify done correctly, blocking queues, and the latch, barrier and semaphore trio.

Read these first

Why this matters

  • wait/notify has 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.

Consumer: queue empty
Producer acquires the lock, adds an item
Consumer wakes, re-checks in a while loop
Consumer takes the item
wait() releases the lock so the other thread can make progress, which is the whole point. Always re-check the condition in a loop, because a thread can wake without the condition being true.
Java
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 synchronized block you get IllegalMonitorStateException.
  • Wait in a while loop, never an if. 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() over notify(). 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 reason wait exists.

BlockingQueue does this for you

The whole class above is one line of the standard library.

Java
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
Java
// 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.

Java
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

AspectUtilityWhat it coordinates
CountDownLatchWait for N events, onceCannot be reset
CyclicBarrierN threads meet, then all proceedReusable across rounds
SemaphoreLimit concurrent access to N permitsRate limiting, resource pools
PhaserA barrier with a changing party countThreads may join or leave
ExchangerTwo threads swap objectsA rendezvous for exactly two
  • CountDownLatch

    UtilityWait for N events, once
    What it coordinatesCannot be reset
  • CyclicBarrier

    UtilityN threads meet, then all proceed
    What it coordinatesReusable across rounds
  • Semaphore

    UtilityLimit concurrent access to N permits
    What it coordinatesRate limiting, resource pools
  • Phaser

    UtilityA barrier with a changing party count
    What it coordinatesThreads may join or leave
  • Exchanger

    UtilityTwo threads swap objects
    What it coordinatesA rendezvous for exactly two

Latch counts down once; barrier resets; semaphore counts permits up and down.

CountDownLatch

Java
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

Java
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

Java
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

Thread A
Holds lock 1
Lock 2
Thread B
Holds lock 2
Lock 1

Fix: every thread takes lock 1 before lock 2. No cycle, no deadlock.

Each thread holds what the other needs and neither will let go. Acquiring locks in one consistent global order removes the cycle entirely.
Java
// 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.

Java
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);
        }
    }
}
AspectFailureCause and fix
DeadlockCircular waiting; nothing movesOrder locks consistently, or use tryLock with timeout
LivelockThreads act but make no progressAdd randomised backoff before retrying
StarvationA thread never gets its turnUse a fair lock, or shorten critical sections
  • Deadlock

    FailureCircular waiting; nothing moves
    Cause and fixOrder locks consistently, or use tryLock with timeout
  • Livelock

    FailureThreads act but make no progress
    Cause and fixAdd randomised backoff before retrying
  • Starvation

    FailureA thread never gets its turn
    Cause 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 tryLock with 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

  • "wait in an if is fine if you call notify correctly." Spurious wakeups exist, and the condition can change between the wakeup and the lock reacquisition. Always while.
  • "notify() is more efficient than notifyAll()." It is, and it is also wrong whenever more than one condition shares the monitor. The saving is not worth the stall.
  • "wait() and sleep() are interchangeable." wait releases the monitor; sleep keeps it.
  • "A CountDownLatch can be reused." It cannot. Use a CyclicBarrier.
  • "A Semaphore is 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/notify require: holding the monitor, waiting in a while loop, preferring notifyAll(), and remembering that wait releases the lock while sleep does not.
  • Prefer a BlockingQueue for producer-consumer. put and take block; poll takes a timeout; a poison pill shuts the pipeline down cleanly.
  • ReentrantLock Condition objects give separate wait sets, making signal() safe.
  • CountDownLatch waits for N events once; CyclicBarrier resets each round; Semaphore limits concurrency to N permits — always release() in a finally.
  • Deadlock is circular waiting — fix with a consistent global lock order or tryLock with a timeout.
  • Livelock means activity without progress — add randomised backoff. Starvation means one thread never runs — use a fair lock.
  • jcmd <pid> Thread.print detects deadlocks automatically.

Test yourself

Answer these before moving on — recall is what makes it stick.