Back to wait/notify, sleep/yield & Producer-Consumer
Q6Question 6 of 6

What is the producer-consumer pattern?

Key Concepts
  • The producer-consumer pattern decouples data production from consumption using a shared bounded buffer.
  • BlockingQueue is the simplest and best approach for production: put() blocks when full, take() blocks when empty.
  • ReentrantLock with two Conditions (notFull, notEmpty) provides targeted wake-ups for producers and consumers.
  • synchronized with wait/notifyAll is the classic approach but wastes CPU on untargeted wake-ups.
  • All three approaches require the wait condition in a while loop (not if) due to spurious wake-ups.
  • LinkedBlockingQueue uses two internal locks (putLock, takeLock) for concurrent put and take operations.

Definition

The producer-consumer pattern uses a shared bounded buffer with BlockingQueue (simplest), Lock+Conditions (targeted), or wait/notifyAll (classic) to decouple production from consumption.
  • Pattern – producers add items to a shared buffer. Consumers remove items. When the buffer is full, producers wait. When empty, consumers wait.
  • BlockingQueue – put() blocks when full, take() blocks when empty. Zero custom synchronization code. Production-ready.
  • Lock + Conditions – notFull.await() for producers, notEmpty.await() for consumers. Targeted signal() wake-ups.
  • wait/notifyAll – classic approach with one wait-set. notifyAll() wakes all threads (wasteful).
  • Bounded buffer – essential for back-pressure. When consumers fall behind, producers slow down (buffer full, put blocks).

Producers are market feed systems dropping trade orders onto a counter. Consumers are settlement tellers picking up orders. The counter has limited space (bounded buffer). Counter full: feed systems wait. Counter empty: tellers wait. BlockingQueue is a professional queue management system. Lock+Conditions creates separate waiting rooms. wait/notifyAll is one shared room where shouting wakes everyone.

Understanding the Concepts

How do you implement with BlockingQueue?

JAVA
package com.bank.service;

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class BlockingQueueDemo {
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);

    // Producer
    public void produce(String trade) throws InterruptedException {
        queue.put(trade); // blocks if queue is full
    }

    // Consumer
    public String consume() throws InterruptedException {
        return queue.take(); // blocks if queue is empty
    }

    // ZERO synchronization code needed!
    // LinkedBlockingQueue uses two internal locks (putLock + takeLock)
    // allowing concurrent put and take operations.
}

How do you implement with Lock + Conditions?

JAVA
package com.bank.service;

import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;

public class ConditionQueueDemo {
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();
    private final Queue<String> buffer = new LinkedList<>();
    private final int capacity;

    public ConditionQueueDemo(int capacity) { this.capacity = capacity; }

    public void produce(String trade) throws InterruptedException {
        lock.lock();
        try {
            while (buffer.size() >= capacity) notFull.await(); // while, not if!
            buffer.add(trade);
            notEmpty.signal(); // wake ONE consumer (targeted)
        } finally { lock.unlock(); }
    }

    public String consume() throws InterruptedException {
        lock.lock();
        try {
            while (buffer.isEmpty()) notEmpty.await();
            String trade = buffer.poll();
            notFull.signal(); // wake ONE producer (targeted)
            return trade;
        } finally { lock.unlock(); }
    }
}

How do you implement with wait/notifyAll?

JAVA
package com.bank.service;

import java.util.LinkedList;
import java.util.Queue;

public class WaitNotifyQueueDemo {
    private final Queue<String> buffer = new LinkedList<>();
    private final int capacity;

    public WaitNotifyQueueDemo(int capacity) { this.capacity = capacity; }

    public synchronized void produce(String trade) throws InterruptedException {
        while (buffer.size() >= capacity) wait();
        buffer.add(trade);
        notifyAll(); // wakes ALL (producers AND consumers) -- wasteful!
    }

    public synchronized String consume() throws InterruptedException {
        while (buffer.isEmpty()) wait();
        String trade = buffer.poll();
        notifyAll(); // wakes ALL -- wasteful!
        return trade;
    }
}

How do the three approaches compare?

| Feature | BlockingQueue | Lock + Conditions | wait/notifyAll |
|---|---|---|---|
| Complexity | Simplest | Medium | Medium |
| Targeted wake-ups | Yes (internal) | Yes (signal) | No (notifyAll) |
| Custom sync code | None | Yes | Yes |
| Production use | Best | Interview exercise | Legacy |
| Two locks | Yes (put + take) | No (one lock) | No (one lock) |

What should a senior engineer know?

  • Back-pressure: a bounded BlockingQueue provides natural back-pressure. When consumers fall behind, producers block. This prevents unbounded memory growth.
  • Disruptor pattern: for ultra-high-throughput (millions of events per second), the LMAX Disruptor uses a ring buffer with CAS-based sequencing instead of blocking queues.
  • SynchronousQueue: a BlockingQueue with zero capacity. Each put() blocks until a take() is ready, and vice versa. Used by newCachedThreadPool() for direct handoff.

Visual Overview

DIAGRAM
  Producer-Consumer with Bounded Buffer:

  Producers ──▶ ┌─────────────────────┐ ──▶ Consumers
                │ Bounded Buffer       │
  put() blocks  │ [trade1][trade2]...  │  take() blocks
  when full     │ capacity = 100       │  when empty
                └─────────────────────┘

  BlockingQueue:     put/take handle all locking internally
  Lock+Conditions:   notFull for producers, notEmpty for consumers
  wait/notifyAll:    one wait-set, wakes everyone (wasteful)

Key Takeaway

The producer-consumer pattern decouples production from consumption using a shared bounded buffer. Three implementations exist: BlockingQueue (simplest, zero custom synchronization, production-ready), ReentrantLock with two Conditions (targeted wake-ups, interview exercise), and synchronized with wait()/notifyAll() (classic but wasteful untargeted wake-ups).

In production, always use BlockingQueue. It handles all locking and signaling internally. LinkedBlockingQueue uses two separate locks for concurrent put and take. The bounded capacity provides natural back-pressure: when consumers fall behind, producers slow down.

Remember one sentence: use BlockingQueue for production producer-consumer, Lock+Conditions for targeted wake-ups, and always wrap await/wait in a while loop.

---

Share
Use ← → arrow keys to navigate