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.

MethodWhen 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

TypeCapacityStructureOrderNotes
ArrayBlockingQueuebounded, fixedarrayFIFOoptional fairness
LinkedBlockingQueueoptionally boundedlinked nodesFIFOdefault capacity Integer.MAX_VALUE
PriorityBlockingQueueunboundedheapcomparator / naturalnot FIFO; put never blocks
DelayQueueunboundedheap of Delayeddelay then headavailable only after delay
SynchronousQueuezeronone storedhand-offeach put waits for a take
LinkedTransferQueueunboundedlinked nodesFIFOtransfer / tryTransfer
LinkedBlockingDequeoptionally boundedlinked nodesdequeblocking 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.

QuestionHonest 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 timed offer / poll) for producer-consumer.
  • Pass a capacity when you want producers to block.
  • Keep the catalog in a ConcurrentHashMap and the next id in a BlockingQueue.

Don’t:

  • Call add and expect a wait.
  • Treat new LinkedBlockingQueue<>() as a small buffer.
  • Spin on poll() because you wanted take().

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.

Next optional step in the series Non-blocking ends, and a concurrent sorted map when HashMap order is not the job. Concurrent Queues and Skip Lists: Lock-Free Ends and Concurrent Order