What Is Java BlockingQueue? Methods and Implementations

A BlockingQueue is a thread-safe queue that makes waiting part of the contract. A consumer calling take() on an empty queue is suspended until an element arrives; a producer calling put() on a full one is suspended until space appears. Neither has to poll, sleep, or coordinate through wait() and notify(). This page covers the interface and its methods, the implementations Java ships with and how to choose between them, the mistakes that show up in production, and how to share one queue across several servers using Valkey or Redis.

BlockingQueue<Order> queue = new ArrayBlockingQueue<>(100);

// producer thread — waits if the queue is full
queue.put(new Order("ORD-123"));

// consumer thread — waits if the queue is empty
Order order = queue.take();

What is a blocking queue in Java?

An ordinary Java queue answers immediately whether or not it can help. Ask an empty ArrayDeque for an element and it returns null; the caller has to decide what to do about that. In a producer-consumer design the answer is almost always "wait and try again," which turns into either a busy-wait loop that burns CPU or a hand-rolled wait()/notify() block that is easy to get subtly wrong.

A blocking queue moves that waiting inside the queue itself. A blocking queue in Java is defined by the java.util.concurrent.BlockingQueue interface, which extends Queue and adds two operations, put() and take(), that suspend the calling thread until the operation can proceed. The thread consumes no CPU while suspended and is woken by the queue itself when the situation changes.

The second thing a blocking queue provides is backpressure. When the queue has a fixed capacity, a producer that outruns its consumers is forced to slow down rather than allowed to accumulate work indefinitely. That single property is the difference between a system that degrades gracefully under load and one that runs out of heap. It is also why the capacity argument matters far more than it looks.

Java BlockingQueue methods

The java.util.concurrent.BlockingQueue interface offers each of the three queue operations in up to four forms, differing only in how they handle a queue that cannot satisfy the request:

OperationThrows an exceptionReturns a special valueBlocksTimes out
Insertadd(e)offer(e)put(e)offer(e, timeout, unit)
Removeremove()poll()take()poll(timeout, unit)
Examineelement()peek()

Examining has no blocking form, because there is nothing useful to wait for: by the time a blocked peek() returned, another consumer may already have taken the element it saw.

Choosing between the four columns is a design decision, not a matter of taste:

  • put() and take() are the default for a dedicated worker thread that has nothing else to do. Both throw InterruptedException, which is how a blocked thread is told to stop.
  • offer(e, timeout, unit) and poll(timeout, unit) suit anything with a deadline. A consumer that must report health or check a shutdown flag every few seconds should use poll() with a timeout rather than take().
  • offer(e) and poll() return immediately. offer() returning false means the element was not added — ignoring that return value on a bounded queue is a silent way to drop work.
  • add() and remove() throw on failure. They exist for compatibility with the Queue interface and are rarely the right choice in concurrent code, where a full or empty queue is an ordinary condition rather than an exceptional one.

Two further methods are worth knowing. remainingCapacity() reports how many elements can be added without blocking, returning Integer.MAX_VALUE for an unbounded queue. drainTo(Collection) moves every available element into another collection in one operation, which is far more efficient than a loop of poll() calls when a consumer wants to process work in batches.

No implementation accepts null elements; adding one throws a NullPointerException. Since poll() and peek() use null to signal an empty queue, a stored null would be indistinguishable from an absent one.

BlockingQueue implementations

All of the following live in java.util.concurrent, and all are thread-safe:

ImplementationCapacityOrderingLockingUse when
ArrayBlockingQueueFixed, requiredFIFOOne lock for both endsCapacity is known and memory must be predictable
LinkedBlockingQueueOptionalFIFOSeparate put and take locksThroughput matters — but always pass a capacity
PriorityBlockingQueueUnboundedComparatorOne lockImportant work should jump the line
SynchronousQueueZeroDirect handoffNo storage at allEvery task needs a thread ready to take it now
DelayQueueUnboundedDelay expiryOne lockElements become available at a set time
LinkedTransferQueueUnboundedFIFOLock-freeProducers should wait until work is picked up
LinkedBlockingDequeOptionalBoth endsOne lockWork stealing, or blocking access at either end

ArrayBlockingQueue is backed by a fixed circular array allocated up front, so its memory footprint is known before the first element arrives. Producers and consumers contend on a single lock, which caps throughput but keeps the behaviour easy to reason about. Its constructor also accepts a fairness flag, which serves blocked threads in arrival order at a measurable cost in throughput.

LinkedBlockingQueue holds each element in its own node and uses two separate locks, one for the head and one for the tail, so a producer and a consumer can usually work at the same time. That makes it faster than ArrayBlockingQueue under contention. Its trap is the no-argument constructor, which sets the capacity to Integer.MAX_VALUE — effectively unbounded, and therefore with no backpressure at all.

PriorityBlockingQueue orders elements by their natural ordering or a supplied Comparator rather than by arrival. It is unbounded, so put() never blocks and offer() never returns false; only consumers ever wait. Iterating over it does not visit elements in priority order — only removal does.

SynchronousQueue stores nothing. Each put() waits for a matching take() and vice versa, so it is a rendezvous point rather than a buffer. size() always returns zero and peek() always returns null, which surprises people reading monitoring output.

DelayQueue holds elements implementing the Delayed interface and releases each one only once its delay has expired. A poll() against a queue full of unexpired elements returns null, which is correct but reads as a bug the first time you see it.

LinkedTransferQueue adds transfer(), which hands an element over and blocks until a consumer actually receives it. That is stronger than put(), which only guarantees the element was queued, and it is useful when the producer needs confirmation that work was picked up.

Producer-consumer with a BlockingQueue

The canonical use of a blocking queue is decoupling threads that produce work from threads that perform it. Neither side needs to know about the other, and neither needs any synchronization code:

private static final Order POISON_PILL = new Order("STOP");

void processOrders(List<Order> incomingOrders) throws InterruptedException {

    BlockingQueue<Order> queue = new LinkedBlockingQueue<>(1_000);
    ExecutorService consumers = Executors.newFixedThreadPool(3);

    for (int i = 0; i < 3; i++) {
        consumers.submit(() -> {
            try {
                while (true) {
                    Order order = queue.take();     // waits while the queue is empty
                    if (order == POISON_PILL) {
                        break;
                    }
                    process(order);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }

    for (Order order : incomingOrders) {
        queue.put(order);                           // waits once 1,000 orders are backed up
    }

    for (int i = 0; i < 3; i++) {                   // one pill per consumer
        queue.put(POISON_PILL);
    }
    consumers.shutdown();
}

Three details make this correct rather than merely working. The capacity of 1,000 means a burst of incoming orders slows the producer down instead of filling the heap. Shutdown uses a sentinel value — a poison pill — rather than a flag, because a consumer blocked in take() will never see a flag change; one pill is needed per consumer, since each pill stops exactly one of them. And the producer's put() calls throw InterruptedException, so the method declares it rather than swallowing it.

The catch block matters too. Swallowing InterruptedException without calling Thread.currentThread().interrupt() discards the only signal telling the thread to stop, and is one of the most common concurrency bugs in Java code.

BlockingQueue in thread pools

Every ExecutorService backed by a ThreadPoolExecutor holds its pending tasks in a BlockingQueue. Which queue it uses is the most consequential thing about the pool, and the Executors factory methods choose for you:

  • newFixedThreadPool() uses an unbounded LinkedBlockingQueue. Tasks arriving faster than the pool drains them accumulate until the heap runs out. There is no rejection, no warning, and no backpressure.
  • newCachedThreadPool() uses a SynchronousQueue with a maximum pool size of Integer.MAX_VALUE. Because the queue holds nothing, every task that finds no idle thread creates a new one, so a burst of slow tasks can spawn thousands of threads.

Constructing the pool directly makes every limit explicit:

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        4, 16,                                  // core and maximum threads
        60L, TimeUnit.SECONDS,                  // idle timeout above core size
        new ArrayBlockingQueue<>(500),          // bounded — the pool can now reject
        new ThreadPoolExecutor.CallerRunsPolicy());

The bounded queue means the pool is able to reject work, and the rejection policy decides what rejection means. CallerRunsPolicy runs the task on the submitting thread, which throttles the producer instead of dropping the task — crude, but an effective backpressure valve.

One behaviour catches almost everyone out: a ThreadPoolExecutor creates threads beyond its core size only once the queue is full. With the settings above the pool runs on four threads until 500 tasks are backed up, and only then grows toward sixteen. Pairing a large queue with a large maximum quietly means the extra threads may never appear.

Common BlockingQueue mistakes

  • Leaving the queue unbounded. An unbounded queue converts a throughput problem into a memory problem, and does it silently. The failure arrives as an OutOfMemoryError long after the actual mistake, in a component that looks healthy right up until it isn't.
  • Ignoring the return value of offer(). On a bounded queue, offer() returning false means the element was discarded. If the work mattered, either handle the false or use put().
  • Blocking a request thread. Calling take() from an HTTP handler ties up a thread that could be serving traffic. Use poll() with a timeout anywhere a caller is waiting on the result.
  • Trusting size() as a control value. It is accurate for the moment it was computed, and that moment has already passed by the time you act on it. Use it for metrics, not for logic — and remember that SynchronousQueue always reports zero.
  • Expecting iteration to be a snapshot. Iterators are weakly consistent: they never throw ConcurrentModificationException, but they may or may not reflect changes made after they were created.
  • Assuming bulk operations are atomic. Each individual method call is thread-safe, but addAll() and the other Collection bulk methods are not performed atomically unless the implementation says so.
  • Assuming the queue survives a restart. It lives in heap memory. A crash takes everything still queued with it — which is where a distributed queue comes in.

Sharing a BlockingQueue across servers

A BlockingQueue coordinates threads inside a single JVM, and that boundary is absolute. Run the same application on three instances and you have three unrelated queues: work submitted on one is invisible to the others, load spreads unevenly, and a restart discards whatever that instance was holding.

Valkey and Redis are in-memory data structure stores commonly used for key-value databases, caches, and message brokers. Neither has a dedicated blocking queue type, but the list type comes close: RPUSH appends, and BLPOP blocks the client until an element is available or a timeout expires. Building on those commands directly leaves you to handle serialization, reconnection, and consumer lifecycle yourself — the same work the standard library does for you in a single JVM.

Redisson closes that gap by implementing the interface you already use. RBlockingQueue extends java.util.concurrent.BlockingQueue and stores its contents in Valkey or Redis, so the calling code is unchanged:

RBlockingQueue<Order> queue = redisson.getBlockingQueue("orders");

queue.put(new Order("ORD-123"));

Order taken = queue.take();                          // waits for work
Order timed = queue.poll(10, TimeUnit.MINUTES);      // waits, with a deadline

The poll(), pollFromAny(), pollLastAndOfferFirstTo() and take() methods resubscribe automatically during reconnection to the server or failover, so a consumer blocked at the moment a primary node is replaced does not need special handling.

Each standard interface has a corresponding object:

Redisson objectJava interfaceUse when
RBlockingQueueBlockingQueueConsumers should wait for work to arrive
RBlockingDequeBlockingDequeBlocking access at both ends
RPriorityBlockingQueueBlockingQueuePriority ordering with blocking consumers
RPriorityBlockingDequeBlockingDequePriority ordering, both ends
RTransferQueueTransferQueueProducers should wait until work is picked up

Configuration details for each are covered in the Redisson queues documentation.

One property of the in-memory original does not survive the move, and it is worth being explicit about. A distributed take() removes the element before the consumer processes it, so a consumer that crashes midway takes the work with it. Within one JVM that hardly matters, because the crash usually takes the whole process anyway. Across a fleet, where any single node can fail while the rest carry on, it is the difference between a lost background job and a lost order.

The Reliable Queue in Redisson PRO addresses that with acknowledgments, visibility timeouts, delivery limits, message delay, priorities, a configurable queue size limit, and a dead-letter queue for messages that exhaust their retries:

RReliableQueue queue = redisson.getReliableQueue("orders");

// the distributed equivalent of a bounded queue's capacity
queue.setConfig(QueueConfig.defaults().maxSize(1_000));

// waits while the queue is full, returns null once the timeout expires
Message msg = queue.add(QueueAddArgs
    .messages(MessageArgs.payload(new Order("ORD-124")))
    .timeout(Duration.ofSeconds(30)));

RBlockingQueue is itself unbounded, so put() never waits for space and the backpressure discussed earlier is absent. The size limit restores it. maxSize plays the role that the capacity argument plays in an ArrayBlockingQueue, and the producer timeout plays the role of offer(e, timeout, unit) — the difference being that the limit now applies across every producer on every node rather than within one heap.

See All about Reliable Queue for Valkey and Redis for a full walkthrough, or Redis Queue for how queues are built on lists at the store level.

Frequently asked questions

Is BlockingQueue thread-safe?

Yes. Every implementation in java.util.concurrent is thread-safe, and all of the queuing methods are atomic. The bulk Collection operations such as addAll() are the exception: they are safe to call concurrently but are not necessarily performed as a single atomic action.

What is the difference between BlockingQueue and Queue?

BlockingQueue extends Queue and adds two things: operations that wait rather than fail, namely put() and take(), and a guarantee of thread safety. An ordinary Queue such as ArrayDeque returns immediately whether or not it could help, and is not safe to share between threads.

What is the difference between LinkedBlockingQueue and ArrayBlockingQueue?

ArrayBlockingQueue uses a fixed array allocated up front and a single lock shared by producers and consumers. LinkedBlockingQueue allocates a node per element and uses separate locks at each end, so producers and consumers contend less and throughput is generally higher. The important practical difference is that ArrayBlockingQueue requires a capacity, while LinkedBlockingQueue defaults to Integer.MAX_VALUE if you do not supply one.

What happens when a thread blocked on take() is interrupted?

take() throws an InterruptedException and the thread's interrupt status is cleared. The handler should either propagate the exception or call Thread.currentThread().interrupt() to restore the flag, so that code further up the stack can still see that an interrupt was requested.

Which BlockingQueue should a thread pool use?

A bounded one, in almost every case. An ArrayBlockingQueue or a LinkedBlockingQueue with an explicit capacity lets the pool reject work and apply backpressure, while the unbounded queue used by Executors.newFixedThreadPool() allows pending tasks to grow until the heap is exhausted.

Can a BlockingQueue be shared between multiple JVMs?

Not with the standard implementations, which coordinate threads within a single process. Storing the queue in Valkey or Redis gives every application instance one shared view of the pending work and keeps that work alive across restarts.

Similar terms