JAVA

Producer-Consumer Problem

neal89 2025. 3. 11. 09:13

1. Overview

The Producer-Consumer problem is a classic concurrency issue in multithreaded programming. It involves multiple threads producing and consuming data while properly managing a shared resource (buffer).

Key Concepts

  • Producer: Generates data and adds it to the buffer.
  • Consumer: Retrieves and processes data from the buffer.
  • Buffer: A temporary storage space for data, with limited capacity.

Problem Scenario

  1. If the producer is too fast → The buffer becomes full, preventing further data production.
  2. If the consumer is too fast → The buffer becomes empty, preventing data consumption.

2. Solutions and Issues

1️⃣ Using synchronized

This approach utilizes Java’s synchronized keyword to protect the critical section, ensuring that only one thread can access the buffer at a time.

✅ Pros

  • Simple and easy to implement.
  • Prevents race conditions by synchronizing access.

❌ Issues

  • Busy Waiting Problem → Threads continuously check conditions, leading to unnecessary CPU usage.
  • Inefficient resource utilization → Threads may run when they don’t need to.
  • Limited to a single shared resource → Difficult to manage multiple shared resources.

📝 Code Example

public synchronized void put(String data) {
    if (queue.size() == max) {
        return;  // Cannot add if the buffer is full
    }
    queue.offer(data);
}

public synchronized String take() {
    if (queue.isEmpty()) {
        return null;  // Cannot consume if the buffer is empty
    }
    return queue.poll();
}

2️⃣ Using wait() & notify()

This method optimizes resource usage by making threads wait instead of continuously checking conditions.

✅ Pros

  • Prevents unnecessary CPU usage by suspending threads with wait().
  • Uses notify() to wake up waiting threads at the right moment.

❌ Issues

  • notify() does not target specific threads → If multiple consumers exist, they might wake up even when no data is available.
  • Deadlock risk → A thread may wait indefinitely if notify() is not called correctly.
  • More complex implementation compared to synchronized.

📝 Code Example

public synchronized void put(String data) throws InterruptedException {
    while (queue.size() == max) {
        wait(); // Wait if the buffer is full
    }
    queue.offer(data);
    notify(); // Wake up a consumer
}

public synchronized String take() throws InterruptedException {
    while (queue.isEmpty()) {
        wait(); // Wait if the buffer is empty
    }
    String data = queue.poll();
    notify(); // Wake up a producer
    return data;
}

3️⃣ Using Lock & Condition

ReentrantLock and Condition allow separate waiting conditions for producers and consumers, improving efficiency.

✅ Pros

  • Condition enables separate waiting conditions for producers and consumers.
  • await() and signal() offer more precise control than wait() & notify().
  • tryLock() can help prevent deadlocks.

❌ Issues

  • More complex and lengthy code compared to synchronized.
  • Increased bug potential → Misplacing await() or signal() may cause deadlocks.

📝 Code Example

private final Lock lock = new ReentrantLock();
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();

public void put(String data) throws InterruptedException {
    lock.lock();
    try {
        while (queue.size() == max) {
            producerCond.await(); // Producer waits
        }
        queue.offer(data);
        consumerCond.signal(); // Wake up a consumer
    } finally {
        lock.unlock();
    }
}

public String take() throws InterruptedException {
    lock.lock();
    try {
        while (queue.isEmpty()) {
            consumerCond.await(); // Consumer waits
        }
        String data = queue.poll();
        producerCond.signal(); // Wake up a producer
        return data;
    } finally {
        lock.unlock();
    }
}

4️⃣ Using BlockingQueue (The Easiest Approach)

Java’s BlockingQueue is a thread-safe queue that handles synchronization internally, eliminating the need for synchronized or Lock.

✅ Pros

  • Simplest solution → No need for manual synchronization or waiting.
  • Safe and efficient → put() and take() automatically handle waiting.
  • Optimized performance → Supports different implementations (ArrayBlockingQueue, LinkedBlockingQueue, etc.).

📝 Code Example

BlockingQueue<String> queue = new LinkedBlockingQueue<>(max);

public void put(String data) throws InterruptedException {
    queue.put(data); // Automatically waits if the buffer is full
}

public String take() throws InterruptedException {
    return queue.take(); // Automatically waits if the buffer is empty
}