LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿงช Concurrency Practice/

Producer-Consumer Pattern: The Restaurant Kitchen

Lesson 3 of 5

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

java
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
  • LinkedList is not thread-safe: concurrent add() and poll() can corrupt the internal linked list, causing NullPointerException or infinite loops

Why It Breaks

  1. No bounds enforcement. The producer does not wait when the buffer is full, and the consumer does not wait when the buffer is empty.
  2. No thread safety. LinkedList is not synchronized. Concurrent modification corrupts its internal pointers.
  3. 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)

java
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:

java
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:

java
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:

java
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:

StepChefWaiterCounter
1put("A")[A]
2put("B")[A, B]
3put("C") blocks (full!)[A, B]
4blockedtake() -> "A"[B]
5put("C") unblocks[B, C]
6donetake() -> "B"[C]
7take() -> "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 Queue instead of BlockingQueue.** Regular queues are not thread-safe and do not block. Use ArrayBlockingQueue or LinkedBlockingQueue.
  • **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. Use notifyAll() or separate Condition objects.
  • 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

  1. Multiple chefs and waiters. Start 3 chef threads and 2 waiter threads. Watch how the bounded buffer naturally balances the workload.
  2. 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.)
  3. Priority dishes. Use a PriorityBlockingQueue so 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.