BlockingQueue<E> is the backbone of producer-consumer designs in Java. It is a thread-safe queue whose defining feature is that it blocks: take() waits when the queue is empty, and put() waits when a bounded queue is full. That built-in back-pressure — producers automatically slowing when consumers fall behind — is what makes it so much safer than hand-rolling wait()/notify().
The four method families
Every operation comes in four flavours differing in how they handle a full or empty queue. Choosing the wrong family is the usual bug:
| Operation | Throws | Returns special | Blocks | Times out |
|---|---|---|---|---|
| Insert | add(e) | offer(e) | put(e) | offer(e, t) |
| Remove | remove() | poll() | take() | poll(t) |
For producer-consumer you almost always want the blocking pair put/take — they deliver the back-pressure. offer/poll (non-blocking) are for 'try, and do something else if you can't'. add/remove (throwing) rarely fit concurrent flow.
The producer-consumer pattern
With a BlockingQueue the whole pattern collapses to a few lines, no explicit locks or condition variables:
BlockingQueue<Task> queue = new ArrayBlockingQueue<>(1000);
// producer
queue.put(task); // blocks if the queue is full -> back-pressure
// consumer
while (running) {
Task t = queue.take(); // blocks if the queue is empty
process(t);
}A bounded queue is the key to stability: if producers outrun consumers, put() blocks and the producers throttle themselves. An unbounded queue removes that safety valve — producers race ahead and the backlog grows until you run out of heap.
ArrayBlockingQueue vs LinkedBlockingQueue
The two general-purpose implementations differ in structure and locking:
| ArrayBlockingQueue | LinkedBlockingQueue | |
|---|---|---|
| Backing | fixed array, bounded | linked nodes, optionally bounded |
| Locks | one lock (put and take contend) | two locks (put and take independent) |
| Memory | pre-allocated, no per-item node | a node object per item |
LinkedBlockingQueue's split put/take locks give higher throughput when producers and consumers run concurrently, at the cost of node allocation and GC pressure. ArrayBlockingQueue has a tighter memory footprint and a hard bound. Always bound a LinkedBlockingQueue explicitly — its default capacity is Integer.MAX_VALUE, effectively unbounded, the classic memory-leak-under-load footgun in thread-pool configs.
SynchronousQueue and the specialised variants
SynchronousQueue has zero capacity: a put() blocks until another thread take()s, and vice versa — a direct hand-off, no buffering. It is what Executors.newCachedThreadPool() uses so that a submitted task either finds an idle thread instantly or spawns a new one. Other variants: PriorityBlockingQueue (unbounded, orders by comparator instead of FIFO), DelayQueue (elements become available only after a delay — the basis of scheduled execution), and LinkedTransferQueue (a superset with a transfer() that waits for a consumer to receive the specific element).
Draining and shutdown
drainTo(collection) transfers all currently available elements in one atomic sweep — more efficient than repeated poll() when batching. For shutdown, the common idiom is a poison pill: enqueue a sentinel object that each consumer recognises as 'stop', so consumers drain gracefully rather than being interrupted mid-task. Interrupting a thread blocked in take() works too — it throws InterruptedException — but the poison pill guarantees in-flight work finishes first.
Weakly consistent iteration and size
Two properties surprise people inspecting a live queue. First, iterators are weakly consistent: they traverse elements as they existed at some point and never throw ConcurrentModificationException, but they may or may not reflect insertions and removals that happen during iteration. So iterating a BlockingQueue gives you a fuzzy snapshot, not a transactional view — fine for monitoring, wrong for logic that assumes exactness.
Second, size() is a momentary reading that can be stale before the call even returns, and on LinkedBlockingQueue it is an O(1) counter while on some structures it is O(n). Never gate control flow on size() under concurrency — check the boolean result of offer()/poll() instead, which reflects the true state at the instant of the operation. And remainingCapacity() on an unbounded queue always returns Integer.MAX_VALUE, another reason to bound explicitly.
BlockingQueue makes producer-consumer trivial and safe: put/take block and deliver back-pressure automatically. Always bound the queue — especially LinkedBlockingQueue, whose default capacity is effectively infinite — or a backlog will exhaust the heap. Pick ArrayBlockingQueue for a tight bounded buffer, LinkedBlockingQueue for higher concurrent throughput, and SynchronousQueue for direct hand-off.