BlockingQueue and the Producer-Consumer Pattern
Objective
Understand BlockingQueue: a Queue whose put() and take() block instead of failing — put() waits for room when the queue is full, take() waits for an element when it's empty. That single property is what makes the producer-consumer pattern — code that identifies work and places it on a queue, decoupled from code that removes it and does the work — trivial to implement correctly, with no hand-written wait()/notify() anywhere.
Use Cases
- Handing work items from one or more producer threads to one or more consumer threads without either side knowing anything about the other.
- Building a bounded work queue that pushes back on producers once consumers fall behind, instead of letting memory use grow without limit.
- Processing items in priority order rather than arrival order, while still using the same blocking put/take model.
- Implementing a direct handoff — a producer's item goes straight to a waiting consumer, with no intermediate storage at all.
- Safely transferring a mutable object from one thread to exactly one other thread without adding a lock around the object itself.
Deep Dive
The producer-consumer pattern and why BlockingQueue makes it trivial
A producer identifies work; a consumer executes it. Wiring them together with a BlockingQueue means neither side calls the other directly — both only ever talk to the queue:
javaimport java.util.concurrent.*;
class ProducerConsumerDemo {
public static void main(String[] args) {
BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);
Runnable producer = () -> {
try {
for (int i = 0; i < 5; i++) {
String item = Thread.currentThread().getName() + "-item-" + i;
queue.put(item); // blocks here if the queue is already full
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
Runnable consumer = () -> {
try {
while (true) {
String item = queue.take(); // blocks here until an item exists
System.out.println("processed " + item);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
};
new Thread(producer, "producer-1").start();
new Thread(producer, "producer-2").start();
new Thread(consumer, "consumer").start();
}
}Neither producer knows the other exists, and the consumer knows nothing about either producer — all three only interact with queue. There is no shared counter, no synchronized block, and no manual wait()/notify() pair to get wrong; put() and take() already contain whatever internal coordination is needed. (The consumer's while (true) loop never exits on its own — a real program needs a way to stop it, such as an interrupt or a sentinel "poison pill" item, which is a separate concern from the queue itself.)
Choosing an implementation
BlockingQueue is an interface; which class to construct depends on ordering and capacity needs:
java// Bounded FIFO, fixed-size array backing — good when the capacity is known
// up front and shouldn't grow, e.g. a buffer between a fast producer and a
// slower consumer.
BlockingQueue<Task> fixed = new ArrayBlockingQueue<>(256);
// FIFO, optionally bounded, linked-node backing — the general-purpose default;
// omit the capacity argument for an effectively unbounded queue.
BlockingQueue<Task> linked = new LinkedBlockingQueue<>(256);
// Orders by priority (Comparable or a Comparator), not arrival order — for
// processing urgent tasks before older, lower-priority ones.
BlockingQueue<Task> byPriority = new PriorityBlockingQueue<>(256, Comparator.comparingInt(Task::urgency));
// Zero capacity: put() doesn't return until a take() is there to receive the
// item directly, and vice versa — a pure handoff with no storage at all.
BlockingQueue<Task> handoff = new SynchronousQueue<>();SynchronousQueue only makes sense when consumers are plentiful enough that a put() will nearly always find one already waiting — otherwise the producer just blocks with nothing queued to show for it.
Why bounded queues matter
An unbounded queue never rejects a put(), which sounds convenient but just delays the real problem: if producers consistently outrun consumers, the queue grows without limit until the JVM runs out of heap.
javaBlockingQueue<Task> unbounded = new LinkedBlockingQueue<>(); // no capacity argument
unbounded.put(task); // never blocks — if consumers can't keep up, this queue
// keeps growing until an OutOfMemoryError, later, elsewhereA bounded queue turns that same imbalance into backpressure instead — the producer blocks the moment there's no more room, which is a form of self-regulation, not just a memory-saving detail:
javaBlockingQueue<Task> bounded = new ArrayBlockingQueue<>(100); // capacity fixed at 100
bounded.put(task); // blocks here once 100 items are already queued, until a
// consumer take()s one and frees a slot — the producer
// literally cannot get further ahead of the consumersoffer() (with or without a timeout) is the non-blocking alternative on the same queue — it reports failure instead of waiting, useful for shedding load rather than pausing the producer.
Serial thread confinement
Passing a mutable object through a blocking queue's put()/take() pair transfers ownership from the producer thread to the consumer thread. As long as the producer never touches the object again after put() returns, only one thread ever has access to it at a time — so the object needs no lock of its own, even though it isn't itself thread-safe.
javaclass MutableTask {
private final StringBuilder log = new StringBuilder();
void appendStep(String step) { log.append(step).append('\n'); }
}
BlockingQueue<MutableTask> queue = new LinkedBlockingQueue<>();
// producer thread
MutableTask task = new MutableTask();
task.appendStep("collected");
queue.put(task); // ownership transfers to whichever thread take()s it
// the producer must not call task.appendStep(...) again after this line
// consumer thread
MutableTask received = queue.take();
received.appendStep("processed"); // safe: this thread now has exclusive accessBlockingQueue's internal synchronization guarantees the consumer sees every write the producer made before the handoff — the same visibility guarantee safe publication always requires — without either side needing synchronized on MutableTask itself. Deque has a blocking cousin too — BlockingDeque, implemented by LinkedBlockingDeque — which supports the same put/take model at both ends and underlies work-stealing designs, where each consumer owns its own deque and only reaches into another's tail when its own is empty.
Trade-offs
Unbounded queues trade blocking now for an
OutOfMemoryErrorlater — nothing aboutLinkedBlockingQueue()(no-arg) stops a runaway producer; the mismatch between production and consumption rates doesn't disappear, it just becomes invisible until the heap fills:javaBlockingQueue<Task> q = new LinkedBlockingQueue<>(); // put() always succeeds immediately — the imbalance is silently accumulatingSynchronousQueueonly works when a consumer is already waiting — with no consumers ready,put()blocks with zero items stored anywhere, which is a very different failure mode than a queue that at least holds work while it waits:javaBlockingQueue<Task> handoff = new SynchronousQueue<>(); handoff.put(task); // blocks indefinitely if no thread is currently in take()PriorityBlockingQueueis unbounded and gives up FIFO ordering — items with equal priority have no guaranteed relative order, and like any unbounded queue it can still grow without limit if producers outpace consumers.put()andtake()declareInterruptedException, so every caller must decide how to respond to it — swallowing the exception silently (an emptycatchblock) throws away the information that the thread was asked to stop; restoring the interrupt (Thread.currentThread().interrupt()) or propagating the checked exception are the two sound options, not ignoring it.A consumer loop built on
take()never exits by itself —while (true) { queue.take(); ... }has no natural termination condition, so a real shutdown needs an explicit signal (interrupting the consumer thread, or having producers enqueue a recognizable "poison pill" item) that the queue itself does not provide.