Today, we’re diving into one of the most classic concurrency challenges in programming — the Producer-Consumer problem.
Whether you’re preparing for a technical interview or building a multi-threaded application, mastering this concept could be the difference between smooth execution and a catastrophic system failure.
What is the Producer-Consumer Problem?
The Producer-Consumer problem is a classic example of multi-process synchronization.
Think of it like a kitchen:
– The chef (producer) makes food and places it on a counter
– The waiter (consumer) takes the food and serves it
– The counter has limited space (our buffer)
In programming terms:
– Producers create data and add it to a shared buffer
– Consumers take data from this buffer for processing
– We need to ensure producers don’t add to a full buffer
– And consumers don’t remove from an empty one
Let’s see how we can solve this in Java with multiple approaches, starting from the basics and moving to more advanced solutions.
Solution 1: Using wait() and notify()
Let’s start with the traditional approach using Java’s built-in wait()
and notify()
methods.
First, we’ll create a shared buffer class:
class SharedBuffer { private List buffer = new ArrayList<>(); private int capacity = 5; public synchronized void produce(int item) throws InterruptedException { // While buffer is full, wait while (buffer.size() == capacity) { System.out.println("Buffer is full. Producer is waiting…"); wait(); } // Add item to buffer buffer.add(item); System.out.println("Produced: " + item); // Notify consumer notify(); } public synchronized int consume() throws InterruptedException { // While buffer is empty, wait while (buffer.isEmpty()) { System.out.println("Buffer is empty. Consumer is waiting…"); wait(); } // Remove item from buffer int item = buffer.remove(0); System.out.println("Consumed: " + item); // Notify producer notify(); return item; } }
Let me walk you through what’s happening here:
- We’ve created a
SharedBuffer
class with synchronized methods.
This class represents the counter in our kitchen analogy. - The
produce()
method waits if the buffer is full. - The
consume()
method waits if the buffer is empty. - Both methods use
notify()
to wake up waiting threads
Now let’s create our producer and consumer threads:
class Producer implements Runnable { private SharedBuffer buffer; public Producer(SharedBuffer buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { try { buffer.produce(i); Thread.sleep(100); // Simulate time to produce } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } class Consumer implements Runnable { private SharedBuffer buffer; public Consumer(SharedBuffer buffer) { this.buffer = buffer; } @Override public void run() { for (int i = 0; i < 10; i++) { try { buffer.consume(); Thread.sleep(300); // Simulate time to consume } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } }
And finally, let’s put it all together:
public class ProducerConsumerExample { public static void main(String[] args) { SharedBuffer buffer = new SharedBuffer(); Thread producerThread = new Thread(new Producer(buffer)); Thread consumerThread = new Thread(new Consumer(buffer)); producerThread.start(); consumerThread.start(); } }
This approach works, but it has some limitations.
Using notify()
might wake up the wrong thread, and it’s not the most efficient solution for multiple producers and consumers.
Solution 2: Using BlockingQueue
Now, let’s look at a more elegant solution using Java’s BlockingQueue interface.
In this example, we use ArrayBlockingQueue
implementation with a capacity of 5 to store produced items.
Since BlockingQueue
handles synchronization internally, we don’t need to explicitly use synchronized
blocks.
import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; class ProducerConsumerWithBlockingQueue { public static void main(String[] args) { // Create a blocking queue with capacity 5 BlockingQueue queue = new ArrayBlockingQueue<>(5); // Producer thread Thread producer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { System.out.println("Producing: " + i); queue.put(i); // Will block if queue is full Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); // Consumer thread Thread consumer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { int value = queue.take(); // Will block if queue is empty System.out.println("Consuming: " + value); Thread.sleep(300); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } }
Isn’t this much cleaner?
The BlockingQueue
interface handles all the synchronization for us
put()
blocks when the queue is full.take()
blocks when the queue is empty
Since there is no explicit synchronization needed, many professional developers prefer this approach — it’s concise, thread-safe, and less prone to bugs.
Solution 3: Using Semaphores
For those who want more control over concurrency, Java’s Semaphore class provides another powerful solution:
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.Semaphore; class ProducerConsumerWithSemaphores { public static void main(String[] args) { Queue buffer = new LinkedList<>(); int maxSize = 5; // Semaphore for tracking empty slots Semaphore emptySlots = new Semaphore(maxSize); // Semaphore for tracking filled slots Semaphore filledSlots = new Semaphore(0); // Mutex for buffer access Semaphore mutex = new Semaphore(1); // Producer thread Thread producer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { emptySlots.acquire(); // Wait if buffer is full mutex.acquire(); // Get exclusive access to buffer // Add to buffer buffer.add(i); System.out.println("Produced: " + i); mutex.release(); // Release buffer access filledSlots.release(); // Signal item added Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); // Consumer thread Thread consumer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { filledSlots.acquire(); // Wait if buffer is empty mutex.acquire(); // Get exclusive access to buffer // Remove from buffer int value = buffer.poll(); System.out.println("Consumed: " + value); mutex.release(); // Release buffer access emptySlots.release(); // Signal slot freed Thread.sleep(300); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } }
This approach gives us fine-grained control by using below semaphores:
emptySlots
tracks available space in the buffer.
It prevents producers from adding when the buffer is full.filledSlots
tracks items available for consumption.
It prevents consumers from removing when the buffer is empty.mutex
ensures exclusive access to the buffer.
It prevents multiple threads from modifying the buffer simultaneously.
Producer thread Working
- Calls
emptySlots.acquire()
→ blocks if the buffer is full. - Calls
mutex.acquire()
→ ensures only one producer modifies the buffer at a time. - Adds an item to the buffer.
- Calls
mutex.release()
→ allows other threads to access the buffer. - Calls
filledSlots.release()
→ signals that a new item is available for consumption. - Sleeps for 100ms to simulate production time.
Consumer Thread Working
- Calls
filledSlots.acquire()
→ blocks if the buffer is empty. - Calls
mutex.acquire()
→ ensures only one consumer modifies the buffer at a time. - Removes an item from the buffer (
buffer.poll()
). - Calls
mutex.release()
→ allows other threads to access the buffer. - Calls
emptySlots.release()
→ signals that a slot is available for production. - Sleeps for 300ms to simulate consumption time.
This approach is useful for high-performance, concurrent applications like task scheduling, background processing, and real-time systems.
Solution 4: Using Lock and Condition
For our final solution, we’ll use the Lock
and Condition
interfaces from Java’s concurrency package:
import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; class ProducerConsumerWithLockCondition { public static void main(String[] args) { Queue<Integer> buffer = new LinkedList<>(); int maxSize = 5; Lock lock = new ReentrantLock(); Condition notFull = lock.newCondition(); Condition notEmpty = lock.newCondition(); // Producer thread Thread producer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { lock.lock(); try { // Wait while buffer is full while (buffer.size() == maxSize) { System.out.println("Buffer full, producer waiting"); notFull.await(); } // Add to buffer buffer.add(i); System.out.println("Produced: " + i); // Signal consumer notEmpty.signal(); } finally { lock.unlock(); } Thread.sleep(100); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); // Consumer thread Thread consumer = new Thread(() -> { try { for (int i = 0; i < 10; i++) { lock.lock(); try { // Wait while buffer is empty while (buffer.isEmpty()) { System.out.println("Buffer empty, consumer waiting"); notEmpty.await(); } // Remove from buffer int value = buffer.poll(); System.out.println("Consumed: " + value); // Signal producer notFull.signal(); } finally { lock.unlock(); } Thread.sleep(300); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } }
A LinkedList
is used as a queue with a maximum size (maxSize = 5
).
A ReentrantLock
ensures only one thread accesses the buffer at a time.
Two Condition variables manage thread communication:
🔹 notFull
→ Used by producer to wait when the buffer is full.
🔹 notEmpty
→ Used by consumer to wait when the buffer is empty.
Producer Thread Working
- Acquires the lock (
lock.lock()
). - Checks if the buffer is full (
buffer.size() == maxSize
): - If full, calls
notFull.await()
to release the lock and wait until space is available. - Adds an item to the buffer (
buffer.add(i)
). - Signals the consumer using
notEmpty.signal()
, indicating that an item is available. - Finally, releases the lock (
lock.unlock()
). - Sleeps for 100ms to simulate production time.
Consumer Thread Working
- Acquires the lock (
lock.lock()
). - Checks if the buffer is empty (
buffer.isEmpty()
): - If empty, calls
notEmpty.await()
to release the lock and wait until an item is available. - Removes an item from the buffer (
buffer.poll()
). - Signals the producer using
notFull.signal()
, indicating that space is available. - Finally, releases the lock (
lock.unlock()
). - Sleeps for 300ms to simulate consumption time.
This approach offers several advantages:
- More flexibility than synchronized methods
- Ability to create multiple conditions for the same lock
- Explicit locking and unlocking for better control
- Selective signaling with
signal()
instead of waking all threads
This is often used in high-performance concurrent systems where fine-tuned control is necessary.
Conclusion and Best Practices
The Producer-Consumer pattern appears everywhere in modern systems — from message queues to thread pools, from event processing to stream handling.
For simple cases, use BlockingQueue
— it’s clean, efficient, and less error-prone
For complex synchronization, consider Lock
and Condition
or Semaphores.
Avoid raw wait()
and notify()
in new code — they’re harder to use correctly.
Remember these best practices:
- Always check wait conditions in a loop (not if statements).
- Release locks in finally blocks to avoid deadlocks.
- Be cautious with timeout versions of blocking methods.
- Consider using higher-level concurrency utilities when possible