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.
On this page
- Picture it first: the pass shelf
- What a correct solution must do
- Solution 1: synchronized, wait and notifyAll
- Why notifyAll and not notify?
- Solution 2: ReentrantLock with two Conditions
- Solution 3: the production answer is BlockingQueue
- 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
🍽️ 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
whileloop and whywait()needs the lock: wait(), notify() and notifyAll() interrupt()and handlingInterruptedException: Interrupts and cancellation- Every
BlockingQueueimplementation 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) withsynchronized,wait()in awhileloop andnotifyAll() - Explain why one wait set forces
notifyAll(), and howReentrantLockwithnotFull/notEmptyConditions makessignal()safe (theArrayBlockingQueuedesign) - Choose between
ArrayBlockingQueue,LinkedBlockingQueueandSynchronousQueue, and betweenput/take,offer/pollwith a timeout andadd/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-lockLinkedBlockingQueue, 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.
Cooks put dishes on a 5-slot pass shelf, waiters take them
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.
- Mutual exclusion. Only one thread at a time may touch the buffer's fields. Two cooks putting into the same slot lose a dish.
- Wait when full. A producer that finds no space must block, not spin and not overwrite.
- Wait when empty. A consumer that finds nothing must block, not spin and not return garbage.
- 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.
A circular buffer: head, tail and count
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:
while, neverif. 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.- Both methods are
synchronizedon the same object. That gives rule 1 (one thread in the buffer at a time), and it's also required: callingwait()without holding the lock throwsIllegalMonitorStateException. wait()releases the lock while sleeping. That's how a cook can sleep "inside"putand still let a waiter intotake.- 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.
One shared bench versus two waiting rooms
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
Further reading (15)
- BlockingQueue (Java SE 21 API): the four forms: throw, special value, block, time out; producer-consumer usage example
- ArrayBlockingQueue (Java SE 21 API)
- LinkedBlockingQueue (Java SE 21 API): capacity defaults to Integer.MAX_VALUE
- OpenJDK source: ArrayBlockingQueue.java (jdk21u): one ReentrantLock, notEmpty/notFull Conditions, takeIndex/putIndex/count
- OpenJDK source: LinkedBlockingQueue.java (jdk21u): two-lock queue: putLock/notFull, takeLock/notEmpty, AtomicInteger count, cascading signals
- Executors.newFixedThreadPool (Java SE 21 API): "operating off a shared unbounded queue"
- Object.wait / notify / notifyAll (Java SE 21 API): spurious wake-ups; always wait in a loop
- Condition (Java SE 21 API): the BoundedBuffer example with notFull/notEmpty
- Alibaba Java Coding Guidelines (p3c): ThreadPoolCreationRule: bans Executors factory methods because unbounded queues can cause OOM
- Logback manual: AsyncAppender: queueSize 256, discardingThreshold drops TRACE/DEBUG/INFO at 20% remaining, neverBlock
- Log4j 2 manual: Asynchronous loggers: Disruptor ring buffer; AsyncQueueFullPolicy
- Apache Kafka producer configs: buffer.memory and max.block.ms
- LMAX Disruptor technical paper (Thompson, Farley, Barker, Gee, Stewart): 52 ns vs 32,757 ns mean latency per hop; throughput table vs ArrayBlockingQueue
- JEP 491: Synchronize Virtual Threads without Pinning (Java 24)
- Java Concurrency in Practice (Goetz et al., 2006), 5.3 Blocking queues and the producer-consumer pattern; 14.3 explicit condition objects