BlockingQueue Family in Java: LinkedBlockingQueue, ArrayBlockingQueue & SynchronousQueue

BlockingQueue is the backbone of Java's producer-consumer pattern. A thread that puts an item blocks if the queue is full; a thread that takes an item blocks if the queue is empty. Java provides six BlockingQueue implementations, each with different capacity, ordering, and performance characteristics.

Share

BlockingQueue Implementations: Comparison

ImplementationCapacityOrderingBest for
LinkedBlockingQueueOptionally bounded (default: Integer.MAX_VALUE)FIFOGeneral-purpose; high throughput (separate head/tail locks)
ArrayBlockingQueueFixed at constructionFIFOBounded buffers; predictable memory; fair option
SynchronousQueueZero (no internal storage)Direct handoffThread-pool task handoff; rendezvous pattern
PriorityBlockingQueueUnboundedPriority (natural/Comparator)Priority task queues; scheduled work
DelayQueueUnboundedDelay expiryScheduled tasks, retry with backoff, TTL eviction
LinkedTransferQueueUnboundedFIFOHighest throughput; transfer() for synchronous hand-off

BlockingQueue Core API: Four Method Groups

OperationThrowsReturns special valueBlocksTimes out
Insertadd(e)offer(e)put(e)offer(e, t, u)
Removeremove()poll()take()poll(t, u)
Examineelement()peek()--
Java
import java.util.concurrent.*;

BlockingQueue<String> queue = new LinkedBlockingQueue<>(10);

// Blocking put — waits if queue is full
queue.put("task-1");

// Blocking take — waits if queue is empty
String item = queue.take();

// Non-blocking — returns false/null immediately
boolean enqueued = queue.offer("task-2");
String  polled   = queue.poll();

// Timed — waits up to given duration
boolean ok  = queue.offer("task-3", 500, TimeUnit.MILLISECONDS);
String  got = queue.poll(1, TimeUnit.SECONDS);   // null on timeout

Classic Producer-Consumer Pattern

Java
import java.util.concurrent.*;

public class ProducerConsumer {

    private static final String POISON_PILL = "__DONE__";

    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new LinkedBlockingQueue<>(50);
        int consumerCount = 3;

        // Producer thread
        Thread producer = new Thread(() -> {
            try {
                for (int i = 1; i <= 20; i++) {
                    queue.put("task-" + i);
                    System.out.println("Produced: task-" + i);
                }
                // Send one poison pill per consumer
                for (int i = 0; i < consumerCount; i++) {
                    queue.put(POISON_PILL);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // Consumer threads
        ExecutorService consumers = Executors.newFixedThreadPool(consumerCount);
        for (int i = 0; i < consumerCount; i++) {
            final int id = i;
            consumers.submit(() -> {
                try {
                    while (true) {
                        String task = queue.take();
                        if (POISON_PILL.equals(task)) break;
                        System.out.println("Consumer " + id + " processing: " + task);
                        Thread.sleep(50);  // simulate work
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        producer.start();
        producer.join();
        consumers.shutdown();
        consumers.awaitTermination(10, TimeUnit.SECONDS);
    }
}
The poison-pill pattern is the cleanest way to signal consumer shutdown: put one sentinel value per consumer thread. Each consumer exits when it receives the pill, without any shared flag or volatile boolean.

ArrayBlockingQueue: Bounded, Fair Option

ArrayBlockingQueue uses a single lock for both head and tail operations (unlike LinkedBlockingQueue which uses two). This makes it slightly lower throughput but with an optional fair mode that prevents thread starvation.

Java
// Bounded to 100 elements — rejects (blocks) when full
ArrayBlockingQueue<Integer> bounded = new ArrayBlockingQueue<>(100);

// Fair mode — longest-waiting thread gets the slot first
ArrayBlockingQueue<Integer> fair = new ArrayBlockingQueue<>(100, true);

// Drain all elements into a list (non-blocking bulk operation)
List<Integer> batch = new ArrayList<>();
int drained = bounded.drainTo(batch, 10);   // drain up to 10 items
System.out.println("Drained: " + drained + " items");

SynchronousQueue: Zero-Capacity Handoff

SynchronousQueue has no internal buffer. A put() blocks until another thread calls take(), and vice versa. It is used internally by Executors.newCachedThreadPool() for immediate task-to-thread hand-off.

Java
import java.util.concurrent.SynchronousQueue;

SynchronousQueue<String> handoff = new SynchronousQueue<>();

// Producer — blocks until a consumer is ready
Thread producer = new Thread(() -> {
    try {
        System.out.println("Producer waiting for consumer...");
        handoff.put("Hello!");   // blocks here
        System.out.println("Handed off");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

// Consumer — blocks until producer offers
Thread consumer = new Thread(() -> {
    try {
        String msg = handoff.take();   // blocks here
        System.out.println("Received: " + msg);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

producer.start();
Thread.sleep(100);
consumer.start();

PriorityBlockingQueue: Priority-Ordered Tasks

Java
import java.util.concurrent.PriorityBlockingQueue;

record Task(int priority, String name) implements Comparable<Task> {
    @Override
    public int compareTo(Task other) {
        return Integer.compare(other.priority, this.priority); // higher priority first
    }
}

PriorityBlockingQueue<Task> pq = new PriorityBlockingQueue<>();

pq.put(new Task(1, "Low priority"));
pq.put(new Task(10, "Critical"));
pq.put(new Task(5, "Medium"));

System.out.println(pq.take().name());   // Critical (priority 10)
System.out.println(pq.take().name());   // Medium   (priority 5)
System.out.println(pq.take().name());   // Low priority (priority 1)
PriorityBlockingQueue is unbounded: it never blocks producers. A fast producer and slow consumer can cause unbounded memory growth. Consider using a bounded ArrayBlockingQueue with a custom comparator through a TreeSet-backed wrapper if you need both priority and bounding.

DelayQueue: Time-Based Element Release

DelayQueue only releases elements whose delay has expired. Elements must implement java.util.concurrent.Delayed.

Java
import java.util.concurrent.*;

class DelayedTask implements Delayed {
    private final String name;
    private final long   runAt;   // absolute nanotime

    DelayedTask(String name, long delayMs) {
        this.name  = name;
        this.runAt = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(delayMs);
    }

    @Override
    public long getDelay(TimeUnit unit) {
        return unit.convert(runAt - System.nanoTime(), TimeUnit.NANOSECONDS);
    }

    @Override
    public int compareTo(Delayed other) {
        return Long.compare(
            getDelay(TimeUnit.NANOSECONDS),
            other.getDelay(TimeUnit.NANOSECONDS));
    }

    public String getName() { return name; }
}

public class DelayQueueExample {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<DelayedTask> queue = new DelayQueue<>();
        queue.put(new DelayedTask("task-A", 2000));   // runs after 2 s
        queue.put(new DelayedTask("task-B", 500));    // runs after 0.5 s
        queue.put(new DelayedTask("task-C", 1000));   // runs after 1 s

        while (!queue.isEmpty()) {
            DelayedTask t = queue.take();   // blocks until next task is ready
            System.out.println("Running: " + t.getName());
        }
        // Output order: task-B, task-C, task-A
    }
}