Warehouse workers should wait for the next order id, not spin on isEmpty. A ConcurrentHashMap of orders is a catalog. It is not a hand-off. A busy loop on ConcurrentLinkedQueue.poll() burns a core until work appears.
BlockingQueue is the JDK producer-consumer collection: put waits for space, take waits for an element.
The series hub owns the Queue vocabulary and iterator names. This post is the family: bounded vs unbounded, put / take vs offer / poll vs add / remove, and which implementation matches the hand-off. It is not a thread-pool tutorial. Virtual threads still want this primitive — blocking take is how a worker waits, even when the waiter is cheap to pause.
The lab is a pick line. Producers enqueue Order.id. Workers take ids and look up the Order elsewhere.
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
put / take vs offer / poll vs add / remove
BlockingQueue extends Queue. The extra methods block. The Queue methods do not wait forever.
| Method | When the queue is full / empty |
|---|---|
put(e) | blocks until there is space |
take() | blocks until there is an element |
offer(e) | returns false if full (no wait) |
offer(e, time, unit) | returns false if the timeout expires |
poll() | returns null if empty (no wait) |
poll(time, unit) | returns null if the timeout expires |
add(e) | throws IllegalStateException if full |
remove() | throws NoSuchElementException if empty |
element() | throws if empty; does not remove |
peek() | returns null if empty; does not remove |
add does not wait. On a bounded queue that is full, add throws. put is the blocking insert. offer is the non-blocking (or timed) insert.
BlockingQueue<String> picks = new ArrayBlockingQueue<>(2);
picks.put("o-100");
picks.put("o-101");
boolean extra = picks.offer("o-102"); // false — capacity 2
// picks.add("o-102"); // IllegalStateException — not a wait
String next = picks.take(); // "o-100", waits if empty
No blocking queue in this family accepts null. put(null) / offer(null) throw NullPointerException. Absence is an empty queue, not a null element.
remainingCapacity() is space left before the next put would block (or fail). Unbounded types report a huge remainder. drainTo(sink) and drainTo(sink, max) move elements in bulk to another collection — useful when a worker wants a batch without calling take in a tight loop.
List<String> batch = new ArrayList<>();
int n = picks.drainTo(batch, 8);
int space = picks.remainingCapacity();
Timed offer / poll are how a producer or worker gives up instead of waiting forever:
boolean accepted = picks.offer("o-103", 50, TimeUnit.MILLISECONDS);
String id = picks.poll(50, TimeUnit.MILLISECONDS); // null on timeout
Both throw InterruptedException. Restore the interrupt and stop; do not swallow it and retry forever on a shutdown.
The family, one grid
| Type | Capacity | Structure | Order | Notes |
|---|---|---|---|---|
ArrayBlockingQueue | bounded, fixed | array | FIFO | optional fairness |
LinkedBlockingQueue | optionally bounded | linked nodes | FIFO | default capacity Integer.MAX_VALUE |
PriorityBlockingQueue | unbounded | heap | comparator / natural | not FIFO; put never blocks |
DelayQueue | unbounded | heap of Delayed | delay then head | available only after delay |
SynchronousQueue | zero | none stored | hand-off | each put waits for a take |
LinkedTransferQueue | unbounded | linked nodes | FIFO | transfer / tryTransfer |
LinkedBlockingDeque | optionally bounded | linked nodes | deque | blocking both ends |
Pick from capacity and wait semantics first. Then pick array vs linked vs heap.
ArrayBlockingQueue: bounded array, fair optional
Fixed capacity. Backed by a ring array. Producers block on put when the buffer is full — that is backpressure.
BlockingQueue<String> picks = new ArrayBlockingQueue<>(8);
picks.put("o-100");
BlockingQueue<String> fair = new ArrayBlockingQueue<>(8, true);
The boolean is fairness of the waiting lock: FIFO among blocked threads. Fair is slower; use it when starvation of a producer or consumer matters. Default is unfair.
This is the usual bounded buffer for a pick line you can size: eight slots in flight, warehouse does not accept a ninth until a worker takes.
LinkedBlockingQueue: cap it or it is unbounded in practice
Linked nodes, FIFO, optionally bounded. The no-arg constructor sets capacity to Integer.MAX_VALUE.
BlockingQueue<String> accidental = new LinkedBlockingQueue<>(); // Integer.MAX_VALUE
BlockingQueue<String> capped = new LinkedBlockingQueue<>(256);
The default is not “a small linked queue.” put will not block until the queue holds more than two billion ids. You will run out of memory first. If you wanted backpressure, pass a capacity.
A bounded LinkedBlockingQueue still uses nodes (more allocation than the array type). It can use separate put and take locks, so one producer and one consumer overlap more than on a single-lock array queue. Size the bound from the workload, not from the default.
PriorityBlockingQueue: unbounded heap, not FIFO
Orders come out by comparator, not by arrival. Unbounded: put / offer do not wait for space. take still waits when empty.
BlockingQueue<Order> byTotal = new PriorityBlockingQueue<>(
11,
Comparator.comparing(Order::total).reversed());
byTotal.put(new Order("o-100", "ada@ex.com", List.of(), new BigDecimal("10"), true));
byTotal.put(new Order("o-101", "grace@ex.com", List.of(), new BigDecimal("50"), true));
Order first = byTotal.take(); // o-101 — larger total, not FIFO
Use it when “next best order” is the consumer’s rule. Do not use it as a drop-in FIFO work queue. There is no bound: a stuck worker plus a fast producer is an unbounded heap of Orders.
DelayQueue: available after the delay
Elements must implement Delayed. take waits until the head’s delay is <= 0. poll returns null if nothing is due yet. The queue is unbounded.
record DelayedPick(String orderId, long readyAtNanos) implements Delayed {
@Override
public long getDelay(TimeUnit unit) {
return unit.convert(readyAtNanos - System.nanoTime(), TimeUnit.NANOSECONDS);
}
@Override
public int compareTo(Delayed other) {
return Long.compare(
getDelay(TimeUnit.NANOSECONDS),
other.getDelay(TimeUnit.NANOSECONDS));
}
}
DelayQueue<DelayedPick> later = new DelayQueue<>();
later.put(new DelayedPick("o-100", System.nanoTime() + TimeUnit.SECONDS.toNanos(5)));
DelayedPick due = later.take(); // blocks until o-100 is due
DelayQueue is not a scheduler. ScheduledExecutorService runs a task at a time. DelayQueue holds elements until a consumer is allowed to take them. Use the queue when the worker should block on “this pick becomes legal.” Use a scheduler when the runtime should invoke a Runnable.
peek may return a head that is not yet expired. take / poll enforce the delay.
SynchronousQueue: no capacity, a hand-off
Nothing is stored. remainingCapacity() is 0. size() is 0. Each put waits for a take (and each take waits for a put). The element goes from producer to consumer directly.
BlockingQueue<String> handoff = new SynchronousQueue<>();
Thread producer = Thread.ofVirtual().unstarted(() -> {
try {
handoff.put("o-100"); // waits until a worker take()s
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread worker = Thread.ofVirtual().unstarted(() -> {
try {
String id = handoff.take();
System.out.println("pick " + id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
worker.start();
Fairness is optional (new SynchronousQueue<>(true)). This is the “rendezvous” queue. Cached thread pools historically used it so a submit hands the task to an idle worker, or starts a thread, without buffering. You still do not need a pool tutorial to use it: if you wanted a buffer of eight picks, you wanted ArrayBlockingQueue, not this.
LinkedTransferQueue: transfer, tryTransfer
TransferQueue extends BlockingQueue with “give this to a waiting consumer now.” LinkedTransferQueue is the JDK implementation. Unbounded. FIFO. Linked nodes.
TransferQueue<String> xfer = new LinkedTransferQueue<>();
boolean handed = xfer.tryTransfer("o-100"); // false if no worker is waiting
xfer.transfer("o-101"); // waits until a take/transfer consumer receives it
xfer.put("o-102"); // enqueue; do not wait for a consumer
tryTransfer(e) succeeds only if a consumer is already waiting. transfer(e) waits until the element is received. put / offer still enqueue like a normal unbounded blocking queue. Use transfer when the producer must know the worker has the id before continuing; use put when buffering is fine.
LinkedBlockingDeque is the blocking deque: optionally bounded (default Integer.MAX_VALUE — same cap trap), putFirst / putLast / takeFirst / takeLast. Urgent picks go to the front; ordinary ids to the back.
BlockingDeque<String> both = new LinkedBlockingDeque<>(32);
both.putFirst("o-urgent");
both.putLast("o-100");
String next = both.takeFirst(); // o-urgent
Producer-consumer without a busy wait
One producer, one worker, bounded buffer. put / take throw InterruptedException — restore the interrupt and leave.
BlockingQueue<String> picks = new ArrayBlockingQueue<>(8);
ConcurrentMap<String, Order> byId = new ConcurrentHashMap<>();
void submit(Order order) throws InterruptedException {
byId.put(order.id(), order);
picks.put(order.id());
}
void runWorker() {
try {
while (!Thread.currentThread().isInterrupted()) {
String id = picks.take();
Order order = byId.get(id);
if (order != null) {
System.out.println("pick " + order.id());
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
The queue is the hand-off. The map is the catalog. Do not busy-poll poll() in a loop with no park. Timed poll is for a worker that must also wake on a shutdown deadline.
On virtual threads, a blocked take is still the right wait: the JVM can unmount the virtual thread instead of spinning a platform thread. Blocking here is the feature, not a leftover from the thread-pool era.
Interview lens
Interviewers want blocking vs throwing, the default linked capacity, and SynchronousQueue’s zero buffer.
| Question | Honest answer |
|---|---|
| Bounded vs unbounded? | Bounded put can wait (backpressure). Unbounded put does not; you can OOM. |
SynchronousQueue capacity? | Zero. Nothing stored. put waits for take. |
LinkedBlockingQueue default? | Integer.MAX_VALUE. Treat as unbounded unless you pass a cap. |
put vs offer vs add? | put waits; offer returns false; add throws if a bounded queue is full. |
DelayQueue vs a scheduler? | Queue of Delayed elements for a consumer. A scheduler runs tasks. |
| Nulls? | None. Same as the rest of java.util.concurrent queues. |
Wrong answer: “BlockingQueue.add() waits until there is space.” add throws IllegalStateException on a full bounded queue. put waits. offer returns false.
Cheat sheet
BlockingQueue put/take block; offer/poll optional timeout; add/remove throw
Nulls forbidden
drainTo bulk take into another collection
remainingCapacity space before put would block (huge if unbounded)
ArrayBlockingQueue bounded array; fair optional
LinkedBlockingQueue cap it; default Integer.MAX_VALUE
PriorityBlockingQueue unbounded heap; not FIFO
DelayQueue Delayed; take when due; not a scheduler
SynchronousQueue capacity 0; rendezvous
LinkedTransferQueue transfer / tryTransfer
LinkedBlockingDeque blocking both ends; default also MAX_VALUE
Do put/take for hand-off; bound the queue you mean to bound
Don't add() as “wait for space”; busy-poll poll()
LinkedBlockingQueue() when you wanted backpressure
Do:
- Use
put/take(or timedoffer/poll) for producer-consumer. - Pass a capacity when you want producers to block.
- Keep the catalog in a
ConcurrentHashMapand the next id in aBlockingQueue.
Don’t:
- Call
addand expect a wait. - Treat
new LinkedBlockingQueue<>()as a small buffer. - Spin on
poll()because you wantedtake().
Wrap-up
BlockingQueue is how one thread hands work to another without a busy wait: put and take block, offer and poll can time out, add throws on a full bounded queue. Size ArrayBlockingQueue when you want a fixed buffer. Cap LinkedBlockingQueue or you asked for Integer.MAX_VALUE. SynchronousQueue stores nothing. DelayQueue waits on time, not on a scheduler. When you did not want to block at all, the next types are lock-free concurrent queues.