skip to content

Implement a bounded producer-consumer buffer using wait/notify. Why does wait() have to release the lock for this to work?

level: seniorimportance: must knowfreq 70%

answer

  1. Same lock guards both put and take
  2. Producer waits on full, consumer waits on empty
  3. wait() releases lock → counterpart can enter and notify
  4. If wait kept the lock → deadlock
  5. while-loop guard + notifyAll for mixed waiters

basics

~20 s

A producer adds items and a consumer removes them, sharing a fixed-size buffer guarded by one lock. The producer waits when the buffer is full; the consumer waits when it's empty; each notifies the other after changing the buffer. wait() must release the lock so the other thread can enter the synchronized region and make progress.

solid answer

~50 s

Both producer and consumer synchronize on the same lock object protecting a bounded buffer. The producer, inside synchronized, loops `while (buffer is full) lock.wait();`, then enqueues and calls `lock.notifyAll();`. The consumer loops `while (buffer is empty) lock.wait();`, then dequeues and calls `lock.notifyAll();`. The crucial point: when a thread calls `wait()`, it atomically releases the lock and parks. If wait() did NOT release the lock, the producer stuck on a full buffer would keep the monitor forever, the consumer could never enter its synchronized region to remove an item, and the system would deadlock. Releasing the lock lets the counterpart enter, change the buffer state, and notify. I use `while` (not `if`) to handle spurious wakeups and the case where another waiter consumed the slot, and `notifyAll` because producers and consumers wait on different conditions on the same monitor.

code

java · 23 lines
java
class BoundedBuffer<T> {
    private final java.util.Queue<T> q = new java.util.ArrayDeque<>();
    private final int capacity;
    private final Object lock = new Object();
    BoundedBuffer(int capacity) { this.capacity = capacity; }

    void put(T item) throws InterruptedException {
        synchronized (lock) {
            while (q.size() == capacity) lock.wait(); // releases lock & parks
            q.add(item);
            lock.notifyAll();                         // wake consumers
        }
    }

    T take() throws InterruptedException {
        synchronized (lock) {
            while (q.isEmpty()) lock.wait();
            T item = q.remove();
            lock.notifyAll();                         // wake producers
            return item;
        }
    }
}

go deeper

for a junior

Can describe the idea (producer adds, consumer removes, block when full/empty) but may not write correct synchronization or explain the lock release.

for a middle

Writes a working version with synchronized + while + notifyAll and knows wait releases the lock.

for a senior

Explains precisely why wait must release the lock (else deadlock), why while and notifyAll are required, and knows the library/Condition alternatives.

for a principal

Discusses fairness, throughput under contention, why two Conditions beat one notifyAll, back-pressure design, and when a lock-free or BlockingQueue-based approach is preferable.

## The scenario *Producer-consumer* is the canonical concurrency exercise: one or more **producer** threads create items and put them into a shared **bounded buffer** (fixed capacity), and one or more **consumer** threads take items out. Two conditions must be respected: a producer must **block when the buffer is full**, and a consumer must **block when the buffer is empty**. wait/notify is the textbook way to express this. ## A correct implementation ```java class BoundedBuffer<T> { private final Queue<T> queue = new ArrayDeque<>(); private final int capacity; private final Object lock = new Object(); BoundedBuffer(int capacity) { this.capacity = capacity; } void put(T item) throws InterruptedException { synchronized (lock) { while (queue.size() == capacity) { // full → wait lock.wait(); } queue.add(item); lock.notifyAll(); // wake any waiting consumers } } T take() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { // empty → wait lock.wait(); } T item = queue.remove(); lock.notifyAll(); // wake any waiting producers return item; } } } ``` ## Why wait() must release the lock Both `put` and `take` run inside `synchronized(lock)` — so only one thread is in the buffer's critical section at a time. Suppose the buffer is **full** and a producer calls `wait()`. If `wait()` simply *parked the thread while keeping the lock*, then: 1. The producer is asleep holding `lock`. 2. A consumer tries to enter `synchronized(lock)` in `take()` to remove an item — but the lock is held, so it blocks at the *entry*, never reaching `queue.remove()`. 3. No item is ever removed, so the buffer is never non-full, so the producer is never able to proceed. That is a **deadlock**. The whole mechanism only works because `wait()` **atomically releases the monitor** as it parks: the consumer can then enter `take()`, remove an item, and call `notifyAll()`, which wakes the producer; the producer re-acquires the lock inside `wait()` and re-checks its `while` condition. *Atomically* matters — the release and the park happen as one step so a `notify` cannot be lost in a gap between them. ## Why while, not if When a producer is woken, several things may be true: it may be a **spurious wakeup** (no real signal), or **another producer** woken by the same `notifyAll` may have already refilled the only freed slot. Re-checking `while (queue.size() == capacity)` ensures the thread only proceeds when the condition genuinely holds; an `if` would let it add to a full buffer. ## Why notifyAll, not notify Producers and consumers wait on the **same monitor** but for **different conditions** (full vs empty). `notify()` wakes one *arbitrary* waiter. After a `put`, you want to wake a **consumer**, but `notify()` might wake another **producer** — which re-checks 'full?', finds the buffer still has room maybe, or finds it full and goes back to sleep, leaving the consumer that should run still parked. With a single producer / single consumer you can sometimes use `notify()`, but `notifyAll()` is the safe, correct-by-default choice when waiters are not interchangeable. ## Production note In real code you would use `java.util.concurrent.ArrayBlockingQueue` / `LinkedBlockingQueue`, whose `put`/`take` already implement exactly this blocking behavior, or a `ReentrantLock` with two separate `Condition`s (`notFull`, `notEmpty`) so you can `signal` precisely the right waiter and avoid the thundering herd. Hand-rolled wait/notify is for understanding the primitive.

  • How would you rewrite this with ReentrantLock and Condition, and what improves?
    Use one ReentrantLock and two Conditions, notFull and notEmpty. Producers await notFull and signal notEmpty; consumers await notEmpty and signal notFull. You wake exactly the right group, avoiding the notifyAll thundering herd, and the code is clearer.
  • What library class makes this unnecessary in real code?
    ArrayBlockingQueue or LinkedBlockingQueue from java.util.concurrent — their put/take already block on full/empty correctly.

saying these in an interview costs you the question

  • Using separate locks for producer and consumer (notify can't reach the other)
  • Using if instead of while around wait()
  • Calling notify() when producers and consumers share one monitor
  • Believing the buffer stays consistent without synchronizing the size check and the add/remove together
  • Thinking wait() keeps the lock, then being unable to explain how the consumer ever runs

context