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:

  1. We’ve created a SharedBuffer class with synchronized methods.
    This class represents the counter in our kitchen analogy.
  2. The produce() method waits if the buffer is full.
  3. The consume() method waits if the buffer is empty.
  4. 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

  1. put() blocks when the queue is full.
  2. 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:

  1. emptySlots tracks available space in the buffer. 
    It prevents producers from adding when the buffer is full.
  2. filledSlots tracks items available for consumption.
    It prevents consumers from removing when the buffer is empty.
  3. 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:

  1. More flexibility than synchronized methods
  2. Ability to create multiple conditions for the same lock
  3. Explicit locking and unlocking for better control
  4. 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:

  1. Always check wait conditions in a loop (not if statements).
  2. Release locks in finally blocks to avoid deadlocks.
  3. Be cautious with timeout versions of blocking methods.
  4. Consider using higher-level concurrency utilities when possible

    Categorized in:

    Java Concurrency,