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
SharedBufferclass 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:
emptySlotstracks available space in the buffer.
It prevents producers from adding when the buffer is full.filledSlotstracks items available for consumption.
It prevents consumers from removing when the buffer is empty.mutexensures 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