Lesson 13 of 31
Blocking Queue and Producer-Consumer in Java
A producer-consumer setup decouples the code that creates work from the code that performs it. The thing in the middle is a blocking queue: push waits when the queue is full, pop waits when it is empty, and a bound on the size gives back-pressure so a fast producer cannot exhaust memory. Interviewers ask for it directly ("implement a blocking queue") or as a piece of a larger design such as a task scheduler or a logger with a background writer (design logger framework). This article builds one from ReentrantLock and Condition, decides how it shuts down, and compares it to java.util.concurrent.ArrayBlockingQueue.
The checks in this article use plain assert statements. The checker runs every program with assertions enabled.
Requirements
- Fixed capacity chosen at construction; capacity zero or less is rejected.
pushblocks while full andpopblocks while empty.tryPushandtryPopnever block.- FIFO order.
closestops accepting new items but lets consumers drain what is queued. After the queue is closed and empty,popreturns an emptyOptional.- Any number of producers and consumers, sharing one queue.
Entities
classDiagram
class BoundedBlockingQueue~T~ {
-lock: ReentrantLock
-notFull: Condition
-notEmpty: Condition
-items: ArrayDeque~T~
-capacity: int
-closed: boolean
+push(item) void
+tryPush(item) PushResult
+pop() Optional~T~
+tryPop() Optional~T~
+close() void
+size() int
}
class PushResult {
<<enum>>
ACCEPTED
FULL
CLOSED
}
class QueueClosedException {
<<RuntimeException>>
}
BoundedBlockingQueue ..> PushResult
BoundedBlockingQueue ..> QueueClosedExceptionOne lock protects all the state: the items, the capacity and the closed flag. Two conditions express two different reasons to wait. Separating them means a pop wakes a waiting producer, and a push wakes a waiting consumer, and neither wakes threads that cannot make progress.
The monitor pattern
A Condition always belongs to one ReentrantLock and is always used with a predicate. The pattern is:
- Lock.
- While the predicate ("there is room") is false, call
awaiton the condition.awaitatomically releases the lock and sleeps, then re-acquires the lock before it returns. - Do the work, signal the other side, and unlock in a
finallyblock.
Always loop on the predicate. A thread can wake without anyone signalling it (a spurious wakeup), and another thread can take the slot between the signal and the wake. Use while, never if.
The lock also provides visibility. Unlocking a lock happens-before every later lock of the same lock, so a thread that locks sees every write made by earlier critical sections. The fields below are plain, not volatile, because every access happens while holding the lock.
Design decisions
- Failure on a closed queue.
pushon a closed queue throwsQueueClosedException, an unchecked exception. The producer has no way to continue, and a lifecycle event such as shutdown is not something each call site can sensibly handle.tryPushis for callers that expect rejection, so it returns aPushResultenum instead of throwing. Optionalfor pop. An emptyOptionalmeans "closed and drained", a normal end of stream and not an error. A blockingpopnever returns empty for an open queue.- Close ends the stream. With one shared queue there is no natural "last producer finished" signal, so an explicit
closeis required. Closing wakes every waiter, on both conditions. - Interruption. Blocking methods declare
InterruptedException. This is the Java contract for a call that can wait: it lets a caller cancel a thread that is stuck inpushorpop. The caller either propagates it or restores the interrupt flag. - No null items.
ArrayDequerejectsnull, and so doespush, so an emptyOptionalcan never be confused with a stored value.
Full implementation
import java.util.ArrayDeque;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
enum PushResult { ACCEPTED, FULL, CLOSED }
final class QueueClosedException extends RuntimeException {
QueueClosedException() {
super("queue is closed");
}
}
final class BoundedBlockingQueue<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final ArrayDeque<T> items = new ArrayDeque<>();
private final int capacity;
private boolean closed; // guarded by lock
BoundedBlockingQueue(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive: " + capacity);
}
this.capacity = capacity;
}
/** Blocks while the queue is full. Throws only if the queue is closed. */
void push(T item) throws InterruptedException {
Objects.requireNonNull(item);
lock.lockInterruptibly();
try {
while (items.size() == capacity && !closed) {
notFull.await();
}
if (closed) {
throw new QueueClosedException();
}
items.addLast(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
PushResult tryPush(T item) {
Objects.requireNonNull(item);
lock.lock();
try {
if (closed) {
return PushResult.CLOSED;
}
if (items.size() == capacity) {
return PushResult.FULL;
}
items.addLast(item);
notEmpty.signal();
return PushResult.ACCEPTED;
} finally {
lock.unlock();
}
}
/** Blocks while empty. Returns empty once the queue is closed and drained. */
Optional<T> pop() throws InterruptedException {
lock.lockInterruptibly();
try {
while (items.isEmpty() && !closed) {
notEmpty.await();
}
if (items.isEmpty()) {
return Optional.empty();
}
notFull.signal();
return Optional.of(items.removeFirst());
} finally {
lock.unlock();
}
}
Optional<T> tryPop() {
lock.lock();
try {
if (items.isEmpty()) {
return Optional.empty();
}
notFull.signal();
return Optional.of(items.removeFirst());
} finally {
lock.unlock();
}
}
/** Stops new pushes; queued items can still be popped. Safe to call more than once. */
void close() {
lock.lock();
try {
closed = true;
notFull.signalAll();
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
int size() {
lock.lock();
try {
return items.size();
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
try {
new BoundedBlockingQueue<String>(0);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
var queue = new BoundedBlockingQueue<String>(2);
queue.push("a");
queue.push("b");
System.out.println("full: " + queue.tryPush("c"));
System.out.println("len: " + queue.size());
System.out.println("pop: " + queue.pop());
queue.tryPush("c");
queue.close();
try {
queue.push("d");
} catch (QueueClosedException e) {
System.out.println("closed push: " + e.getMessage());
}
queue.close(); // closing twice is harmless
// Closed queues still drain, then report the end of the stream.
System.out.println("drain: " + queue.pop() + " " + queue.pop());
System.out.println("end: " + queue.pop() + " " + queue.tryPop());
}
}capacity must be positive: 0
full: FULL
len: 2
pop: Optional[a]
closed push: queue is closed
drain: Optional[b] Optional[c]
end: Optional.empty Optional.emptyThe important lines are the two while predicates. A producer waits while full && !closed, so close releases it, and it then sees closed and throws. A consumer waits while empty && !closed, so close releases it, and if items remain it keeps popping. Only when the queue is both closed and empty does pop return an empty Optional.
signal is enough in push and pop because each operation changes the state by one slot or one item, which can satisfy exactly one waiter of the other kind. close changes the state for everybody, so it uses signalAll. Java allows signalling while holding the lock, and the signalled thread then waits for the lock to be released before it continues.
Multiple producers and consumers
Sharing the queue needs no wrapper: every thread holds a reference to the same object. The workload below has three producers, each pushing a fixed range of numbers, and two consumers that sum what they take. The final answer does not depend on which consumer gets which item, so the output is deterministic even though the interleaving is not.
The tasks run on an ExecutorService created with Executors.newVirtualThreadPerTaskExecutor(). A virtual thread is a cheap thread managed by the JVM, suited to tasks that spend their time blocked. The executor is AutoCloseable, and leaving the try block waits for every submitted task.
The shutdown order is the part to get right:
- Producers finish.
Future.getwaits for each of them. - Only then is the queue closed. Closing earlier would reject pushes that are still coming.
- Consumers see an empty
Optionalafter draining and exit. Their results are collected.
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
final class QueueClosedException extends RuntimeException {
QueueClosedException() {
super("queue is closed");
}
}
final class BoundedBlockingQueue<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final ArrayDeque<T> items = new ArrayDeque<>();
private final int capacity;
private boolean closed;
BoundedBlockingQueue(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity must be positive: " + capacity);
}
this.capacity = capacity;
}
void push(T item) throws InterruptedException {
Objects.requireNonNull(item);
lock.lockInterruptibly();
try {
while (items.size() == capacity && !closed) {
notFull.await();
}
if (closed) {
throw new QueueClosedException();
}
items.addLast(item);
notEmpty.signal();
} finally {
lock.unlock();
}
}
Optional<T> pop() throws InterruptedException {
lock.lockInterruptibly();
try {
while (items.isEmpty() && !closed) {
notEmpty.await();
}
if (items.isEmpty()) {
return Optional.empty();
}
notFull.signal();
return Optional.of(items.removeFirst());
} finally {
lock.unlock();
}
}
void close() {
lock.lock();
try {
closed = true;
notFull.signalAll();
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
}
record Tally(long sum, int count) {}
public class Main {
public static void main(String[] args) throws Exception {
var queue = new BoundedBlockingQueue<Long>(4); // small on purpose: producers must block
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> producers = new ArrayList<>();
for (int p = 0; p < 3; p++) {
long base = p * 1000L;
producers.add(executor.submit(() -> {
for (long i = 1; i <= 100; i++) {
queue.push(base + i);
}
return null;
}));
}
List<Future<Tally>> consumers = new ArrayList<>();
for (int c = 0; c < 2; c++) {
consumers.add(executor.submit(() -> {
long sum = 0;
int count = 0;
for (var item = queue.pop(); item.isPresent(); item = queue.pop()) {
sum += item.get();
count++;
}
return new Tally(sum, count);
}));
}
for (var producer : producers) {
producer.get();
}
queue.close(); // all producers are done; let consumers drain and stop
long total = 0;
int items = 0;
for (var consumer : consumers) {
Tally tally = consumer.get();
total += tally.sum();
items += tally.count();
}
System.out.println("items consumed: " + items);
System.out.println("sum: " + total);
}
}
}items consumed: 300
sum: 315150Three producers contribute the sum of 1 to 100 each plus their offsets: 3 * 5050 + 100 * (0 + 1000 + 2000) = 315150. With a capacity of 4 and 300 items, producers must block many times, which exercises notFull. This is the property to test: count and sum are preserved regardless of schedule.
Testing it
Timing-based tests are flaky, so test the invariants that hold for every schedule, use tryPush and tryPop for single-threaded state checks, and bound every wait with a timeout so a bug fails the test instead of hanging it. The program below holds three checks as assert statements.
import java.util.ArrayDeque;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
final class SimpleQueue {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notEmpty = lock.newCondition();
private final ArrayDeque<Integer> items = new ArrayDeque<>();
private boolean closed;
void push(int value) {
lock.lock();
try {
items.addLast(value);
notEmpty.signal();
} finally {
lock.unlock();
}
}
void close() {
lock.lock();
try {
closed = true;
notEmpty.signalAll();
} finally {
lock.unlock();
}
}
Optional<Integer> pop() throws InterruptedException {
lock.lockInterruptibly();
try {
while (items.isEmpty() && !closed) {
notEmpty.await();
}
return items.isEmpty() ? Optional.empty() : Optional.of(items.removeFirst());
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// close releases a blocked consumer, whichever of the two runs first
var queue = new SimpleQueue();
Future<Optional<Integer>> consumer = executor.submit(queue::pop);
queue.close();
assert consumer.get(5, TimeUnit.SECONDS).isEmpty();
// items queued before close are still delivered in order
var ordered = new SimpleQueue();
ordered.push(1);
ordered.push(2);
ordered.close();
assert ordered.pop().equals(Optional.of(1));
assert ordered.pop().equals(Optional.of(2));
assert ordered.pop().isEmpty();
// interrupting a blocked consumer cancels it
var idle = new SimpleQueue();
Future<Optional<Integer>> stuck = executor.submit(idle::pop);
stuck.cancel(true);
assert stuck.isCancelled();
}
System.out.println("all checks passed");
}
}all checks passedThe blocked-consumer check is correct under both orderings: if close runs first, pop finds the queue closed and empty without waiting. A test that orders threads with sleep passes or fails depending on timing. The timeout on get turns a missed wakeup into a TimeoutException instead of a hung build.
Comparison with ArrayBlockingQueue
The standard library already has a bounded blocking queue: java.util.concurrent.ArrayBlockingQueue. It offers put (blocks when full), take (blocks when empty), offer and poll with timeouts, and drainTo. It has no close. The usual way to end a stream is a poison pill: a special message that tells a consumer to stop. The producer side enqueues one pill per consumer after the real work is done.
A sealed interface keeps the pill in the type system, so a consumer cannot forget to handle it:
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
sealed interface Message permits Work, Stop {}
record Work(long value) implements Message {}
record Stop() implements Message {}
record Tally(long sum, int count) {}
public class Main {
public static void main(String[] args) throws Exception {
BlockingQueue<Message> queue = new ArrayBlockingQueue<>(4);
int consumerCount = 2;
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<?>> producers = new ArrayList<>();
for (int p = 0; p < 3; p++) {
long base = p * 1000L;
producers.add(executor.submit(() -> {
for (long i = 1; i <= 100; i++) {
queue.put(new Work(base + i));
}
return null;
}));
}
List<Future<Tally>> consumers = new ArrayList<>();
for (int c = 0; c < consumerCount; c++) {
consumers.add(executor.submit(() -> {
long sum = 0;
int count = 0;
while (queue.take() instanceof Work(long value)) {
sum += value;
count++;
}
return new Tally(sum, count);
}));
}
for (var producer : producers) {
producer.get();
}
for (int c = 0; c < consumerCount; c++) {
queue.put(new Stop()); // one pill per consumer
}
long total = 0;
int items = 0;
for (var consumer : consumers) {
Tally tally = consumer.get();
total += tally.sum();
items += tally.count();
}
System.out.println("items consumed: " + items);
System.out.println("sum: " + total);
}
}
}items consumed: 300
sum: 315150The results match the hand-built queue. The table lists the differences that decide which to use.
| Aspect | ArrayBlockingQueue |
Hand-built lock and condition queue |
|---|---|---|
| Producers and consumers | many of each | many of each |
| Close | none; a poison pill per consumer, sent by the producer side | explicit close() callable from any thread |
Producers blocked in put at shutdown |
stay blocked unless interrupted | released by close, which makes them throw |
| Pushes after shutdown | accepted silently | rejected |
| Length, drain, timeouts | size, drainTo, offer/poll with timeout |
whatever you implement |
| Custom policy (priority, drop oldest, dedupe) | no; PriorityBlockingQueue covers priority only |
yes, change the container |
The java.util.concurrent queues with a poison pill fit a single stream that ends once. A custom queue fits when the requirement needs something they cannot express: an explicit close that also wakes blocked producers, rejection after shutdown, or a policy such as "drop the oldest when full". The last one is what a task scheduler needs, see design task scheduler. ArrayBlockingQueue also accepts a fairness flag in its constructor, which orders waiting threads first-in, first-out at a throughput cost.
Extensions
- Timeouts.
push(item, timeout)andpop(timeout)withCondition.awaitNanos, which returns the remaining wait time. Compute the deadline once and loop on the predicate. Test with generous bounds only. - Drop-oldest policy. When full, remove the first item and add the new one instead of blocking. Never blocks a producer, at the cost of losing data.
- Graceful worker pool. A pool is N consumers plus a queue of
Runnabletasks; shutdown isclosethen waiting for the consumers.
Interview points
- Why
whileand notifaroundawait: spurious wakeups, and another thread can consume the slot between the signal and the wake. - Why two conditions: one wait set per reason, so a
popwakes only producers and apushwakes only consumers. signalfor one-slot changes,signalAllforclose.ReentrantLockneedsunlockinfinally;synchronizedwithwaitandnotifyAllis the same design with one wait set.- Shutdown order: join producers, close, let consumers drain, collect consumers.
- A poison pill puts the end of the stream into the data and needs one pill per consumer.
- Blocking methods declare
InterruptedExceptionso a caller can cancel them.
Summary
- A bounded blocking queue is one
ReentrantLockaround the state plus twoConditions:notFullandnotEmpty. - Always wait in a predicate loop, which also handles spurious wakeups.
- Signal one waiter for a single-slot change and signal all on
close. - Close semantics: reject new pushes, keep draining, return an empty
Optionalwhen closed and empty. - All fields are read and written under the lock, so the lock also provides visibility.
- Shut down in order: wait for producers, close, let consumers drain, collect consumers.
- Test invariants (count, sum, order) that hold for every schedule, and bound every wait with a timeout. Never assert on which thread got which item.
- Use
ArrayBlockingQueuewith poison pills when one stream ends. Build your own for explicit close, rejection after shutdown, or a custom policy. See also thread-safe components and event bus and observers.
