Concurrency

Producer-Consumer

Decouple producers from consumers using a shared, bounded queue.

3 min readDesigns · LLD & Patterns

Intent

Producers create work items at one rate. Consumers process them at another. A shared bounded queue absorbs the rate mismatch and decouples the two sides.

Use when:

  • Work generation and processing happen on different threads.
  • Speeds don't match — need backpressure.
  • You want producers and consumers to scale independently.

Structure

StructureFlowchart

The Three Concerns

ConcernWhat it does
Mutual exclusionOnly one thread mutates the queue at a time
Block when emptyConsumer waits if no items
Block when fullProducer waits if queue is at capacity (backpressure)

Doing this manually with synchronized + wait/notify is error-prone. Use BlockingQueue.


Java Implementation

import java.util.concurrent.*;

public class WorkSystem {
    private final BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);

    // Producer
    void submit(Task t) throws InterruptedException {
        queue.put(t);    // blocks if full
    }

    // Consumer
    Task take() throws InterruptedException {
        return queue.take();   // blocks if empty
    }
}

// Producer thread
new Thread(() -> {
    while (running) {
        Task t = generateTask();
        try { system.submit(t); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
    }
}).start();

// Consumer thread
new Thread(() -> {
    while (running) {
        try {
            Task t = system.take();
            process(t);
        } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
    }
}).start();

That's it. BlockingQueue handles all three concerns.


BlockingQueue Variants

ImplementationBounded?Best for
ArrayBlockingQueueYes (fixed)Predictable bounds, fast
LinkedBlockingQueueOptionalMost general purpose
SynchronousQueueCapacity 0Direct hand-off (no buffering)
PriorityBlockingQueueUnboundedItems with priority
DelayQueueUnboundedItems become available after a delay
LinkedTransferQueueUnboundedHigh-throughput, low-latency

Manual Implementation (for the interviewer who insists)

public class BoundedBuffer<T> {
    private final Object[] buf;
    private int head = 0, tail = 0, count = 0;
    private final Object lock = new Object();

    public BoundedBuffer(int capacity) { buf = new Object[capacity]; }

    public void put(T item) throws InterruptedException {
        synchronized (lock) {
            while (count == buf.length) lock.wait();   // wait if full
            buf[tail] = item;
            tail = (tail + 1) % buf.length;
            count++;
            lock.notifyAll();   // wake any consumers
        }
    }

    @SuppressWarnings("unchecked")
    public T take() throws InterruptedException {
        synchronized (lock) {
            while (count == 0) lock.wait();   // wait if empty
            T item = (T) buf[head];
            buf[head] = null;
            head = (head + 1) % buf.length;
            count--;
            lock.notifyAll();
            return item;
        }
    }
}

Why while, not if?

while (count == 0) lock.wait();

wait() can return spuriously (without notify). Re-check the condition. Always wait in a loop.

Why notifyAll, not notify?

If you only call notify, you might wake a producer when only consumers care, or vice versa. notifyAll is safe but slightly slower. Use separate Conditions for surgical wakeups:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (count == buf.length) notFull.await();
        // ... add to buffer
        notEmpty.signal();   // wake one consumer specifically
    } finally { lock.unlock(); }
}

Multiple Producers / Consumers

BlockingQueue handles this natively — internal locks make it safe under any number of producers and consumers.

// Multiple producers
for (int i = 0; i < 4; i++) executor.submit(new Producer(queue));

// Multiple consumers
for (int i = 0; i < 8; i++) executor.submit(new Consumer(queue));

Common ratio: more consumers than producers when processing is the bottleneck.


Graceful Shutdown

Two common idioms:

1. Poison pill

Producer puts a sentinel item; consumer checks for it:

private static final Task SHUTDOWN = new Task();   // sentinel

// Shutdown
queue.put(SHUTDOWN);

// In consumer
while (true) {
    Task t = queue.take();
    if (t == SHUTDOWN) {
        queue.put(SHUTDOWN);   // pass it on for other consumers
        break;
    }
    process(t);
}

2. Interrupt + drain

// Shutdown
executor.shutdownNow();   // interrupts threads
// Each consumer's catch (InterruptedException) breaks the loop

When to Reach for ExecutorService Instead

If you'd write this:

BlockingQueue<Runnable> q = new ArrayBlockingQueue<>(100);
// + 4 worker threads pulling from q

You should write this:

ExecutorService exec = new ThreadPoolExecutor(4, 4, 0, TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(100));
exec.submit(task);

ExecutorService is producer-consumer with a thread pool of consumers. Don't reinvent it.


Real-world Examples

Use caseProducer / Consumer
Web serverHTTP threads / worker pool
LoggingApplication code / async log writer
Image uploadReceiver / thumbnail generator
Message queues (in-process)Module A / Module B
Build systemsFile watchers / compilers

Trade-offs

✅ Pros:

  • Decouples production from consumption
  • Backpressure through bounded queue
  • Producers and consumers scale independently

❌ Cons:

  • Latency: items wait in queue
  • Memory: queue holds in-flight items (bounded helps)
  • Shutdown complexity: need to drain or signal

Interview Tips

  • Default to BlockingQueue — don't write wait/notify from scratch unless asked.
  • Mention bounded vs unbounded — unbounded queues hide leaks.
  • Bring up backpressure: a full bounded queue blocks producers, naturally regulating throughput.
  • Mention ExecutorService as the "real-world" producer-consumer.

Finished reading?

Mark it done to track your progress in Designs.