Definition
- 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?
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?
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?
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
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.
---