Producer-Consumer Pattern: The Restaurant Kitchen
The Problem
Picture a restaurant kitchen. The chef (producer) prepares dishes and puts them on a counter. The waiter (consumer) picks up dishes from the counter and serves them to customers.
The counter has limited space -- say, 5 slots. If the counter is full, the chef must wait. If the counter is empty, the waiter must wait. They work at different speeds: sometimes the chef is faster, sometimes the waiter is.
This is the producer-consumer pattern: one or more threads produce data, one or more threads consume it, and they communicate through a shared bounded buffer.
Let us See It Break
1import java.util.LinkedList;
2import java.util.Queue;
3
4public class BrokenRestaurant {
5 private static final Queue<String> counter = new LinkedList<>();
6 private static final int COUNTER_SIZE = 5;
7
8 public static void main(String[] args) {
9 // Chef (producer)
10 Thread chef = new Thread(() -> {
11 String[] dishes = {"Pasta", "Steak", "Salad", "Soup", "Pizza",
12 "Burger", "Tacos", "Sushi", "Ramen", "Curry"};
13 for (String dish : dishes) {
14 // No check for full counter!
15 counter.add(dish);
16 System.out.println("Chef prepared: " + dish
17 + " [counter: " + counter.size() + "]");
18 }
19 }, "Chef");
20
21 // Waiter (consumer)
22 Thread waiter = new Thread(() -> {
23 for (int i = 0; i < 10; i++) {
24 // No check for empty counter!
25 String dish = counter.poll();
26 System.out.println("Waiter served: " + dish
27 + " [counter: " + counter.size() + "]");
28 try { Thread.sleep(100); } catch (InterruptedException e) { break; }
29 }
30 }, "Waiter");
31
32 chef.start();
33 waiter.start();
34 }
35}Run it. Problems you will see:
- The waiter serves
null-- polled from an empty queue - The counter overflows past 5 -- no bound enforced
- The chef dumps all 10 dishes instantly, then the waiter picks them up later
LinkedListis not thread-safe: concurrentadd()andpoll()can corrupt the internal linked list, causingNullPointerExceptionor infinite loops
Why It Breaks
- No bounds enforcement. The producer does not wait when the buffer is full, and the consumer does not wait when the buffer is empty.
- No thread safety.
LinkedListis not synchronized. Concurrent modification corrupts its internal pointers. - No coordination. The producer and consumer run completely independently. There is no "wake up the waiter when food is ready" or "wake up the chef when counter space opens up."
Fix #1: The Simple Way (synchronized + wait/notify)
1import java.util.LinkedList;
2import java.util.Queue;
3
4public class RestaurantWaitNotify {
5 private static final Queue<String> counter = new LinkedList<>();
6 private static final int COUNTER_SIZE = 5;
7 private static final Object lock = new Object();
8
9 public static void main(String[] args) {
10 Thread chef = new Thread(() -> {
11 String[] dishes = {"Pasta", "Steak", "Salad", "Soup", "Pizza",
12 "Burger", "Tacos", "Sushi", "Ramen", "Curry"};
13 for (String dish : dishes) {
14 synchronized (lock) {
15 while (counter.size() == COUNTER_SIZE) {
16 try {
17 System.out.println("Chef waiting... counter full!");
18 lock.wait();
19 } catch (InterruptedException e) {
20 Thread.currentThread().interrupt();
21 return;
22 }
23 }
24 counter.add(dish);
25 System.out.println("Chef prepared: " + dish
26 + " [counter: " + counter.size() + "/" + COUNTER_SIZE + "]");
27 lock.notifyAll();
28 }
29 // Small delay to simulate cooking
30 try { Thread.sleep(50); } catch (InterruptedException e) { break; }
31 }
32 }, "Chef");
33
34 Thread waiter = new Thread(() -> {
35 for (int i = 0; i < 10; i++) {
36 synchronized (lock) {
37 while (counter.isEmpty()) {
38 try {
39 System.out.println("Waiter waiting... counter empty!");
40 lock.wait();
41 } catch (InterruptedException e) {
42 Thread.currentThread().interrupt();
43 return;
44 }
45 }
46 String dish = counter.poll();
47 System.out.println("Waiter served: " + dish
48 + " [counter: " + counter.size() + "/" + COUNTER_SIZE + "]");
49 lock.notifyAll();
50 }
51 // Small delay to simulate serving
52 try { Thread.sleep(150); } catch (InterruptedException e) { break; }
53 }
54 }, "Waiter");
55
56 chef.start();
57 waiter.start();
58 }
59}Run it and watch the interplay:
1Chef prepared: Pasta [counter: 1/5]
2Chef prepared: Steak [counter: 2/5]
3Waiter served: Pasta [counter: 1/5]
4Chef prepared: Salad [counter: 2/5]
5Chef prepared: Soup [counter: 3/5]
6Chef prepared: Pizza [counter: 4/5]
7Chef prepared: Burger [counter: 5/5]
8Chef waiting... counter full!
9Waiter served: Steak [counter: 4/5]
10Chef prepared: Tacos [counter: 5/5]
11...The chef pauses when the counter is full. The waiter pauses when it is empty. They coordinate beautifully.
Fix #2: The Better Way (ReentrantLock + Condition)
With two Condition objects, we separate the "not full" signal from the "not empty" signal:
1import java.util.LinkedList;
2import java.util.Queue;
3import java.util.concurrent.locks.Condition;
4import java.util.concurrent.locks.ReentrantLock;
5
6public class RestaurantWithConditions {
7 private static final Queue<String> counter = new LinkedList<>();
8 private static final int COUNTER_SIZE = 5;
9 private static final ReentrantLock lock = new ReentrantLock();
10 private static final Condition notFull = lock.newCondition();
11 private static final Condition notEmpty = lock.newCondition();
12
13 public static void main(String[] args) {
14 Thread chef = new Thread(() -> {
15 String[] dishes = {"Pasta", "Steak", "Salad", "Soup", "Pizza",
16 "Burger", "Tacos", "Sushi", "Ramen", "Curry"};
17 for (String dish : dishes) {
18 lock.lock();
19 try {
20 while (counter.size() == COUNTER_SIZE) {
21 System.out.println("Chef waiting... counter full!");
22 notFull.await();
23 }
24 counter.add(dish);
25 System.out.println("Chef prepared: " + dish
26 + " [counter: " + counter.size() + "/" + COUNTER_SIZE + "]");
27 notEmpty.signal(); // Wake the waiter
28 } catch (InterruptedException e) {
29 Thread.currentThread().interrupt();
30 return;
31 } finally {
32 lock.unlock();
33 }
34 try { Thread.sleep(50); } catch (InterruptedException e) { break; }
35 }
36 }, "Chef");
37
38 Thread waiter = new Thread(() -> {
39 for (int i = 0; i < 10; i++) {
40 lock.lock();
41 try {
42 while (counter.isEmpty()) {
43 System.out.println("Waiter waiting... counter empty!");
44 notEmpty.await();
45 }
46 String dish = counter.poll();
47 System.out.println("Waiter served: " + dish
48 + " [counter: " + counter.size() + "/" + COUNTER_SIZE + "]");
49 notFull.signal(); // Wake the chef
50 } catch (InterruptedException e) {
51 Thread.currentThread().interrupt();
52 return;
53 } finally {
54 lock.unlock();
55 }
56 try { Thread.sleep(150); } catch (InterruptedException e) { break; }
57 }
58 }, "Waiter");
59
60 chef.start();
61 waiter.start();
62 }
63}The improvement: notFull.signal() wakes ONLY threads waiting because the counter was full (the chef). notEmpty.signal() wakes ONLY threads waiting because the counter was empty (the waiter). No wasted wakeups.
Fix #3: The Java Way (BlockingQueue)
Java already has this pattern built in. ArrayBlockingQueue does everything -- bounded buffer, thread safety, blocking on full/empty:
1import java.util.concurrent.ArrayBlockingQueue;
2import java.util.concurrent.BlockingQueue;
3
4public class RestaurantBlockingQueue {
5 public static void main(String[] args) {
6 BlockingQueue<String> counter = new ArrayBlockingQueue<>(5);
7
8 Thread chef = new Thread(() -> {
9 String[] dishes = {"Pasta", "Steak", "Salad", "Soup", "Pizza",
10 "Burger", "Tacos", "Sushi", "Ramen", "Curry"};
11 for (String dish : dishes) {
12 try {
13 counter.put(dish); // Blocks if full
14 System.out.println("Chef prepared: " + dish
15 + " [counter: " + counter.size() + "/5]");
16 Thread.sleep(50);
17 } catch (InterruptedException e) {
18 Thread.currentThread().interrupt();
19 return;
20 }
21 }
22 }, "Chef");
23
24 Thread waiter = new Thread(() -> {
25 for (int i = 0; i < 10; i++) {
26 try {
27 String dish = counter.take(); // Blocks if empty
28 System.out.println("Waiter served: " + dish
29 + " [counter: " + counter.size() + "/5]");
30 Thread.sleep(150);
31 } catch (InterruptedException e) {
32 Thread.currentThread().interrupt();
33 return;
34 }
35 }
36 }, "Waiter");
37
38 chef.start();
39 waiter.start();
40 }
41}Look at how clean this is. No locks, no conditions, no wait/notify. put() blocks when full, take() blocks when empty. This is what you should use in production code.
Step-by-Step Trace
Trace with counter capacity = 2 (smaller for clarity), chef making A, B, C, and waiter being slow:
| Step | Chef | Waiter | Counter |
|---|---|---|---|
| 1 | put("A") | [A] | |
| 2 | put("B") | [A, B] | |
| 3 | put("C") blocks (full!) | [A, B] | |
| 4 | blocked | take() -> "A" | [B] |
| 5 | put("C") unblocks | [B, C] | |
| 6 | done | take() -> "B" | [C] |
| 7 | take() -> "C" | [] |
The chef was blocked at step 3 until the waiter made room at step 4. This is backpressure -- the slow consumer naturally slows down the fast producer.
Common Mistakes
- **Using
Queueinstead ofBlockingQueue.** Regular queues are not thread-safe and do not block. UseArrayBlockingQueueorLinkedBlockingQueue. - **Using
notify()when there are multiple producers or consumers.** With multiple waiters and chefs,notify()might wake a chef when you meant to wake a waiter. UsenotifyAll()or separateConditionobjects. - Forgetting to signal after every put/take. If the chef adds a dish but does not signal the waiter, the waiter might sleep forever even though food is available.
- Holding the lock during slow operations. Do not hold the lock while cooking (sleeping). Lock only for the queue operation itself. Our code above correctly releases the lock before sleeping.
- Unbounded queues. If you use an unbounded queue, a fast producer can fill all available memory. Always set a bound.
Interview Tip: The producer-consumer pattern is the foundation of almost all concurrent systems: thread pools, message queues, event loops, stream processing. When you explain it, use the restaurant analogy -- interviewers remember analogies. Then implement it with BlockingQueue in 10 lines. If asked for a deeper dive, show the ReentrantLock + two Condition approach and explain why two conditions are better than one.
Try It Yourself
- Multiple chefs and waiters. Start 3 chef threads and 2 waiter threads. Watch how the bounded buffer naturally balances the workload.
- Poison pill shutdown. Add a special "DONE" dish. When the waiter receives it, it stops. How do you handle multiple waiters? (Hint: each waiter needs to see the poison pill, or the last chef re-inserts it.)
- Priority dishes. Use a
PriorityBlockingQueueso that "VIP" orders get served before regular orders. How does this change the behavior?
Summary
- Producer-consumer decouples the speed of production from consumption using a bounded buffer.
- synchronized + wait/notify works but wakes all threads indiscriminately.
- ReentrantLock + two Conditions (notFull, notEmpty) wakes only the right thread.
- BlockingQueue is the production-ready solution:
put()blocks when full,take()blocks when empty, no manual locking needed.