Course contents

The producer–consumer problem

Build a bounded blocking queue four ways (synchronized + notifyAll, ReentrantLock + two Conditions, BlockingQueue, semaphores), prove it with an exactly-once checksum, then stop consumers cleanly, size the queue with Little's law and avoid the unbounded-queue OOM trap.

PremiumMust know for interviewsLesson23 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: the pass shelf
  2. What a correct solution must do
  3. Solution 1: synchronized, wait and notifyAll
  4. Why notifyAll and not notify?
  5. Solution 2: ReentrantLock with two Conditions
  6. Solution 3: the production answer is BlockingQueue
  7. Solution 4 in one breath: two semaphores and a mutex
  8. Stopping consumers cleanly
  9. Backpressure: why the queue must be bounded
  10. How big should the queue be? Little's law
  11. Batching with drainTo
  12. Common bugs I see in interviews
  13. Real world
  14. Advanced
  15. How to say it in the interview

🍽️ What is the producer-consumer problem?

Some threads make work (producers), other threads handle it (consumers), and a fixed-size buffer sits between them. The puzzle is making that hand-off safe: producers must wait when the buffer is full, consumers must wait when it's empty, and nobody may corrupt the buffer or sleep through their turn. A web server's request queue, an async logger and a thread pool's task queue are all this exact problem.

For this lesson, we'll build the solution four ways, from the version you'd write on a whiteboard to the one you'd ship. We'll write a real bounded blocking queue with synchronized and wait/notifyAll, upgrade it to ReentrantLock with two Conditions, swap it for BlockingQueue in production, and then deal with the parts tutorials skip: how to stop consumers cleanly, why the queue must be bounded, and how big to make it. Interviewers love this one because "implement a bounded blocking queue" fits in 10 minutes of code and hides about five classic concurrency bugs.

Below the line (out of scope)

  • Guarded waits, the while loop and why wait() needs the lock: wait(), notify() and notifyAll()
  • interrupt() and handling InterruptedException: Interrupts and cancellation
  • Every BlockingQueue implementation in depth: the "BlockingQueue implementations" lesson
  • Sizing and tuning ThreadPoolExecutor: the "ExecutorService and thread pools" lesson

The 30-second answer

Producers and consumers share a bounded buffer. You need four things: mutual exclusion on the buffer, producers block while it's full, consumers block while it's empty, and each side wakes the other after it changes the count. With synchronized, there is one wait set for both kinds of waiter, so you loop on the condition and call notifyAll(). With ReentrantLock you use two Conditions, notFull and notEmpty, and signal() the right one; that's how ArrayBlockingQueue is written. In production I use a bounded BlockingQueue with put/take (or offer/poll with a timeout), stop consumers with one poison pill each or an interrupt, and never use an unbounded queue, because it turns overload into an OutOfMemoryError.

What you’ll learn

  • Explain the producer-consumer problem and its four requirements: mutual exclusion, wait when full, wait when empty, wake the other side
  • Implement a bounded blocking queue on a circular array (head, tail, count) with synchronized, wait() in a while loop and notifyAll()
  • Explain why one wait set forces notifyAll(), and how ReentrantLock with notFull/notEmpty Conditions makes signal() safe (the ArrayBlockingQueue design)
  • Choose between ArrayBlockingQueue, LinkedBlockingQueue and SynchronousQueue, and between put/take, offer/poll with a timeout and add/remove
  • Stop consumers cleanly with one poison pill per consumer or with interrupts, and know when to use each
  • Explain backpressure, the unbounded queue inside Executors.newFixedThreadPool, and size a queue with Little's law
  • Batch work with drainTo, and compare the two-lock LinkedBlockingQueue, the LMAX Disruptor and virtual threads

Interview radar

Asked very often: Implement a bounded blocking queue. Why notifyAll() and not notify()? Core follow-up: How would you do this in production? How do you stop the consumers? Production angle: Why did our service die with OutOfMemoryError under load? (Spoiler: Executors.newFixedThreadPool has an unbounded queue.)

01Picture it first: the pass shelf

Picture a busy restaurant kitchen. Cooks plate dishes and put them on the pass, the shelf between the kitchen and the dining room. Waiters pick dishes up and carry them out. The shelf holds five plates.

Two awkward moments keep happening. When all five slots are full, a cook holding a hot plate has nowhere to put it, so they wait. When the shelf is empty, a waiter has nothing to carry, so they wait. And the moment a waiter takes a plate, a waiting cook should hear "there's space now", and vice versa.

Fig. 1

Cooks put dishes on a 5-slot pass shelf, waiters take them

Cooks put dishes on a 5-slot pass shelf, waiters take themTwo cooks on the left put plates onto a shelf with five slots. Two waiters on the right take plates off. When all five slots are full the cooks stand and wait; when the shelf is empty the waiters stand and wait.Kitchen (producers)cook 1cook 2make a dishthe pass shelf (capacity 5)dishdishdish3 of 5 slots usedDining room (consumers)waiter 1waiter 2serve a tableputtakeshelf full? cooks waitshelf empty? waiters waitNeither side knows the other exists. The shelf is the only meeting point.
The whole problem in one picture. Cooks never talk to waiters. The 5-slot shelf is the only meeting point, and it decides who waits.

Translate it to Java and nothing changes. Cooks are producer threads, waiters are consumer threads, and the shelf is a bounded buffer, a queue with a fixed capacity. You'll find this shape everywhere:

  • Thread pools: your code produces tasks with submit(), worker threads consume them from a work queue.
  • Async logging: request threads drop log events in a queue; one background thread writes them to disk.
  • Pipelines: parse, then enrich, then save, with a queue between each stage.
  • Backpressure: when the buffer is full, the producer is slowed down instead of the system drowning. That slow-down is a feature, and we'll come back to it.

02What a correct solution must do

Before any code, here are the four rules. Every bug in this lesson breaks one of them.

  1. Mutual exclusion. Only one thread at a time may touch the buffer's fields. Two cooks putting into the same slot lose a dish.
  2. Wait when full. A producer that finds no space must block, not spin and not overwrite.
  3. Wait when empty. A consumer that finds nothing must block, not spin and not return garbage.
  4. Wake the right side. After a put, a sleeping consumer must eventually wake. After a take, a sleeping producer must. Miss this and everyone sleeps forever.

03Solution 1: synchronized, wait and notifyAll

First, the buffer itself. We don't want to shift array elements on every take, so we use a circular buffer: a plain array plus two indexes that chase each other round and round. tail is where the next put goes, head is where the next take comes from, and count says how many items are in there.

Fig. 2

A circular buffer: head, tail and count

A circular buffer: head, tail and countAn array of five slots drawn in a row. Slots 1, 2 and 3 hold items. The head arrow points at slot 1, the next item to take. The tail arrow points at slot 4, the next free slot to fill. count is 3. A curved arrow from the end back to slot 0 shows that the indexes wrap around with modulo.Object[] items = new Object[5][0]A[1]B[2]C[3][4]head = 1take heretail = 4put hereafter [4], wrap to [0]: i = (i + 1) % 5count = 3full: count == 5empty: count == 0No shifting. put moves tail, take moves head, both O(1).
A circular buffer. put writes at tail, take reads at head, and both wrap with modulo. count is what tells "full" apart from "empty", since head == tail in both cases.

Try it yourself first

Before reading on, write put and take yourself with synchronized. Then check: did you use if or while around wait()? Did you call notify() or notifyAll()? Did you null out the slot you took from?

Here's the version I'd write on a whiteboard, wrapped in a test: 2 cooks make 10,000 numbered dishes each, 2 waiters take them, and at the end we check that every dish was served exactly once using a checksum (the sum of 1 to 20,000 is 200,010,000).

public class PassShelf {

    // A bounded blocking queue: one lock, one wait set, a circular array.
    static final class BoundedBuffer<T> {
        private final Object[] items;
        private int head;    // next slot to take from
        private int tail;    // next slot to put into
        private int count;   // how many items are on the shelf

        BoundedBuffer(int capacity) {
            items = new Object[capacity];
        }

        public synchronized void put(T item) throws InterruptedException {
            while (count == items.length) {     // full: wait for space
                wait();
            }
            items[tail] = item;
            tail = (tail + 1) % items.length;
            count++;
            notifyAll();                        // wake anyone waiting for an item
        }

        @SuppressWarnings("unchecked")
        public synchronized T take() throws InterruptedException {
            while (count == 0) {                // empty: wait for an item
                wait();
            }
            T item = (T) items[head];
            items[head] = null;                 // let the GC have it
            head = (head + 1) % items.length;
            count--;
            notifyAll();                        // wake anyone waiting for space
            return item;
        }
    }

    static final int PER_COOK = 10_000;
    static final Integer STOP = -1;             // poison pill

    public static void main(String[] args) throws InterruptedException {
        BoundedBuffer<Integer> shelf = new BoundedBuffer<>(5);
        long[] served = new long[2], sums = new long[2];
        Thread[] cooks = new Thread[2], waiters = new Thread[2];
        for (int i = 0; i < 2; i++) {
            int base = i * PER_COOK, me = i;    // cook 0 makes 1..10000, cook 1 makes 10001..20000
            cooks[i] = new Thread(() -> {
                try {
                    for (int d = 1; d <= PER_COOK; d++) shelf.put(base + d);
                } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
            waiters[i] = new Thread(() -> {
                try {
                    for (Integer dish; !(dish = shelf.take()).equals(STOP); ) {
                        served[me]++;
                        sums[me] += dish;
                    }
                } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            });
        }
        for (int i = 0; i < 2; i++) { cooks[i].start(); waiters[i].start(); }
        for (Thread t : cooks) t.join();
        for (Thread t : waiters) shelf.put(STOP);   // one pill per waiter
        for (Thread t : waiters) t.join();

        long n = 2L * PER_COOK;
        System.out.printf("waiter-0 served %,d dishes, waiter-1 served %,d%n", served[0], served[1]);
        System.out.printf("total served = %,d (expected %,d)%n", served[0] + served[1], n);
        System.out.printf("checksum     = %,d (expected %,d)%n", sums[0] + sums[1], n * (n + 1) / 2);
    }
}

Two real runs of java PassShelf.java on my laptop (JDK 21 and 26 behave the same). The split between waiters changes every run; the totals never do:

waiter-0 served 10,471 dishes, waiter-1 served 9,529
total served = 20,000 (expected 20,000)
checksum     = 200,010,000 (expected 200,010,000)

waiter-0 served 6,978 dishes, waiter-1 served 13,022
total served = 20,000 (expected 20,000)
checksum     = 200,010,000 (expected 200,010,000)

Notice four things:

  1. while, never if. A thread can wake up and find the condition false again: another consumer grabbed the dish first, or it was a spurious wake-up. The loop re-checks before acting.
  2. Both methods are synchronized on the same object. That gives rule 1 (one thread in the buffer at a time), and it's also required: calling wait() without holding the lock throws IllegalMonitorStateException.
  3. wait() releases the lock while sleeping. That's how a cook can sleep "inside" put and still let a waiter into take.
  4. The checksum proves exactly-once. A lost dish or a dish served twice would change the sum. Matching counts alone wouldn't catch a duplicate plus a loss.

04Why notifyAll and not notify?

Here's the bit that trips people up. A Java object has one wait set, one bench where every waiting thread sits. In our buffer that bench holds two kinds of thread waiting for two different things: cooks waiting for "not full" and waiters waiting for "not empty".

notify() wakes one arbitrary thread from that bench. Picture a waiter that just took a dish and calls notify(), meaning to wake a cook. The JVM picks... another waiter. That waiter checks, finds the shelf still empty, and goes back to sleep without notifying anyone. The cooks never hear about the free slot. With the right timing, every thread ends up asleep, and the program hangs with no exception and no CPU use.

notifyAll() wakes everybody, so whoever can make progress will. It's correct, but it has a cost called the thundering herd: with 50 threads on the bench, one put wakes all 50, they queue for the lock one by one, and 49 of them re-check and sleep again.

Fig. 3

One shared bench versus two waiting rooms

One shared bench versus two waiting roomsLeft: synchronized with one wait set. Blocked cooks and blocked waiters sit on the same bench, so notifyAll wakes all of them and most go straight back to sleep. Right: ReentrantLock with two Conditions. Cooks wait in the notFull room, waiters in the notEmpty room, and signal() wakes one thread from the room that can now make progress.synchronized: one wait setthe one benchcookwaitercookwaiternotify() might wake the wrong kindso we use notifyAll(): everyone wakes,re-checks, most sleep againcorrect, but noisyLock + 2 ConditionsnotFullcooks wait herenotEmptywaiters wait heretake() frees a slot: notFull.signal()put() adds a dish: notEmpty.signal()wake one thread, from the right roomtargeted wake-upsSame lock either way. The difference is how many waiting rooms hang off it.
One bench versus two rooms. With one wait set, notify() can wake the wrong kind of thread, so you pay for notifyAll(). Two Conditions let you wake one thread from the room that can actually proceed.

05Solution 2: ReentrantLock with two Conditions

The fix is to give each kind of waiter its own room. A ReentrantLock can hand out any number of Condition objects, each with its own wait set. Cooks await() on notFull, waiters await() on notEmpty, and now signal() (the notify() of conditions) is safe, because everyone in a room is waiting for the same thing.

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

final class BoundedBuffer<T> {
    private final Object[] items;
    private int head, tail, count;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();   // cooks wait here
    private final Condition notEmpty = lock.newCondition();  // waiters wait here

    BoundedBuffer(int capacity) {
        items = new Object[capacity];
    }

    public void put(T item) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == items.length) notFull.await();
            items[tail] = item;
            tail = (tail + 1) % items.length;
            count++;
            notEmpty.signal();              // one waiter, from the right room
        } finally {
            lock.unlock();
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == 0) notEmpty.await();
            T item = (T) items[head];
            items[head] = null;
            head = (head + 1) % items.length;
            count--;
            notFull.signal();               // one cook, from the right room
            return item;
        } finally {
            lock.unlock();
        }
    }
}

I ran the same exactly-once harness with 4 cooks and 4 waiters against this version:

4 cooks, 4 waiters: checksum 800,020,000 (expected 800,020,000)

This isn't a toy. Open ArrayBlockingQueue.java in OpenJDK and you'll find this exact design: one final ReentrantLock lock, two conditions named notEmpty and notFull, a circular items array with takeIndex, putIndex and count, and put looping on notFull.await(). Notice the unlock() in finally: unlike synchronized, a ReentrantLock isn't released for you if an exception escapes. We cover the lock itself properly in the "ReentrantLock and Condition" lesson.

06Solution 3: the production answer is BlockingQueue

You should be able to write Solutions 1 and 2. You should almost never ship them. java.util.concurrent.BlockingQueue is the producer-consumer buffer, already tested by millions of services. The three you'll meet most:

Queue Shape Bounded? Reach for it when
ArrayBlockingQueue array ring, one lock always (capacity required) Default choice: fixed memory, predictable
LinkedBlockingQueue linked nodes, two locks optional, unbounded if you omit capacity High throughput with a capacity you pass in
SynchronousQueue zero slots: a put waits for a take n/a Direct hand-off; Executors.newCachedThreadPool uses it

The API has three flavours of each operation, and choosing the right one is half the design:

Behaviour when it can't proceed Insert Remove Use it for
Block until possible put(e) take() Workers that should wait forever (until interrupted)
Time out, return false/null offer(e, 10, MILLISECONDS) poll(1, SECONDS) Request paths with a deadline, consumers that check for shutdown
Return false/null at once offer(e) poll() "Drop it if we're busy" (metrics, sampling)
Throw an exception add(e) (IllegalStateException) remove() (NoSuchElementException) Rarely; when full means a bug

And here's the thing many people miss: you use a producer-consumer queue every time you call executor.submit(task). A ThreadPoolExecutor is a BlockingQueue<Runnable> plus N worker threads in a take() loop. Your code is the producer.

Premium lesson

Keep reading with the Java Pass

The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.

  • Solution 4 in one breath: two semaphores and a mutex
  • Stopping consumers cleanly
  • Backpressure: why the queue must be bounded
  • How big should the queue be? Little's law
  • Batching with drainTo
  • Common bugs I see in interviews
  • Real world
  • Advanced
  • How to say it in the interview

From ₹299 · one-time payment · No auto-renewal

Practise this topicSynchronization: interview questions & scenarios
Further reading (15)