Home/Learn/Operating Systems/Producer-Consumer Problem

Producer-Consumer Problem

Intermediate
Synchronization

The producer-consumer problem models threads that produce and consume items through a shared bounded buffer, requiring synchronization so producers wait when the buffer is full and consumers wait when it is empty.

Overview

The producer-consumer problem (also called the bounded-buffer problem) is the most common concurrency pattern. One or more producer threads generate data items and place them in a shared buffer. One or more consumer threads remove items and process them. The buffer has a fixed capacity. Producers must block when the buffer is full and consumers must block when it is empty. Without synchronization: race conditions corrupt the buffer. Without blocking: busy-waiting wastes CPU. Three Java solutions exist, in increasing abstraction: (1) synchronized + wait/notifyAll — classic, educational; (2) ReentrantLock + Condition — named conditions, better throughput; (3) BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue) — the idiomatic Java solution that handles all synchronization internally. Java's java.util.concurrent package exists largely to solve producer-consumer correctly.

Solution 1: synchronized + wait/notifyAll

The foundational approach uses Java's intrinsic monitor. Producers call put() which waits while the buffer is full; consumers call take() which waits while empty. notifyAll() wakes all blocked threads after every state change. This is correct but has thundering herd issues — every state change wakes all threads.

Java — producer-consumer with synchronized + wait/notifyAll (Solution 1)
// Producer-Consumer: synchronized + wait/notifyAll
class SharedBuffer {
    private final int[]   buffer;
    private int           count = 0, in = 0, out = 0;

    SharedBuffer(int size) { buffer = new int[size]; }

    public synchronized void produce(int item) throws InterruptedException {
        while (count == buffer.length) wait();   // full → block
        buffer[in] = item;
        in = (in + 1) % buffer.length;
        count++;
        notifyAll();  // wake sleeping consumers
    }

    public synchronized int consume() throws InterruptedException {
        while (count == 0) wait();               // empty → block
        int item = buffer[out];
        out = (out + 1) % buffer.length;
        count--;
        notifyAll();  // wake sleeping producers
        return item;
    }
}

SharedBuffer buf = new SharedBuffer(5);
Thread producer = new Thread(() -> {
    for (int i = 1; i <= 20; i++) {
        try { buf.produce(i); System.out.println("Produced: " + i); }
        catch (InterruptedException e) { break; }
    }
});
Thread consumer = new Thread(() -> {
    for (int i = 1; i <= 20; i++) {
        try { System.out.println("Consumed: " + buf.consume()); }
        catch (InterruptedException e) { break; }
    }
});
producer.start(); consumer.start();
producer.join();  consumer.join();

Solution 3: BlockingQueue (Idiomatic Java)

ArrayBlockingQueue is the production Java solution. put() blocks when full; take() blocks when empty — internally using ReentrantLock + two Conditions. No explicit synchronization needed in application code. This is the correct solution for real applications: thread-safe, performant, and handles all edge cases.

Java — producer-consumer with ArrayBlockingQueue and poison pill pattern
// Producer-Consumer: BlockingQueue — idiomatic, production-ready Java
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5);

// Producer thread
Thread producer = Thread.ofVirtual().name("producer").start(() -> {
    for (int i = 1; i <= 20; i++) {
        try {
            queue.put(i);             // blocks automatically when full
            System.out.println("Produced: " + i + "  queue=" + queue.size());
        } catch (InterruptedException e) { break; }
    }
    // Poison pill: signal consumer to stop
    try { queue.put(-1); } catch (InterruptedException ignored) {}
});

// Consumer thread
Thread consumer = Thread.ofVirtual().name("consumer").start(() -> {
    while (true) {
        try {
            int item = queue.take();  // blocks automatically when empty
            if (item == -1) break;    // poison pill received
            System.out.println("Consumed: " + item);
        } catch (InterruptedException e) { break; }
    }
});
producer.join(); consumer.join();

// Other BlockingQueue implementations:
// LinkedBlockingQueue:   optionally bounded, higher throughput (two lock design)
// PriorityBlockingQueue: unbounded, ordered by priority
// SynchronousQueue:      zero-capacity, direct handoff (used by CachedThreadPool)
// DelayQueue:            elements available only after a delay (scheduled tasks)

Key Points to Remember

  • 1Producer-consumer requires: mutual exclusion on buffer state, blocking when full (producers), blocking when empty (consumers).
  • 2synchronized + wait/notifyAll: correct but notifyAll() causes thundering herd on every state change.
  • 3ReentrantLock + two Conditions: targeted signal() — notFull wakes producers, notEmpty wakes consumers.
  • 4BlockingQueue (ArrayBlockingQueue): idiomatic Java solution — handles all synchronization internally.
  • 5Poison pill: sentinel value placed by producer to signal consumers to shut down gracefully.
  • 6SynchronousQueue: zero-capacity queue for direct handoff — each put() blocks until a take() is ready.

Interview Questions

Sign in to ask Aria
1

Implement producer-consumer in Java using synchronized and wait/notifyAll.

MediumAmazon
2

What is a thundering herd and how do named Conditions in ReentrantLock solve it?

HardGoogle
3

What is a poison pill and how is it used to gracefully shut down consumer threads?

MediumNetflix
4

What is the difference between ArrayBlockingQueue and LinkedBlockingQueue?

MediumFlipkart

Ask Aria about Producer-Consumer Problem

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…