Adetayo Akinsanya unkletayo.dev
Engineering / Java Collections From Scratch • Part 22 of 26 Published

Java BlockingQueue Performance: ArrayBlockingQueue vs LinkedBlockingQueue

Producer-consumer synchronization, ReentrantLock Condition pairs, and dual-lock splitting.

Part 22 in Series — Catch up on the previous article: Java IdentityHashMap Internals: Reference Equality & Open Addressing Probing (Part 21) before diving into this post.

Suppose you are building an video transcoding backend.

User requests arrive continuously. A producer thread ingests uploaded videos and enqueues encoding tasks. Meanwhile, a pool of 8 background worker threads continuously dequeues tasks to render video thumbnails.

If producer threads enqueue tasks faster than worker threads can process them, an unbounded queue grows without limit until your server runs out of RAM.

Conversely, if worker threads finish all tasks, they should sleep peacefully without burning CPU in while(true) polling loops until a new task arrives.

To manage concurrent producer-consumer thread coordination safely with memory limits, Java provides BlockingQueue.


Why You Need This in Real Life

BlockingQueue implementations form the core of Java concurrent architectures:

  • Thread Pool Task Queues: ThreadPoolExecutor uses blocking queues to hold pending Runnable tasks.
  • Backpressure Protection: Setting a maximum capacity limit forces producer threads to block (wait) when the queue is full, preventing memory exhaustion.
  • Efficient Thread Parking: When the queue is empty, consumer threads suspend execution via OS thread parking, consuming zero CPU cycles until producers signal new items.

The BlockingQueue API Contract

Operation ModeThrows ExceptionReturns Special ValueBlocks ThreadTimes Out
Insertadd(e)offer(e)put(e)offer(e, timeout, unit)
Removeremove()poll()take()poll(timeout, unit)
Examineelement()peek()Not ApplicableNot Applicable

When building producer-consumer worker loops, use put() and take().


ArrayBlockingQueue: Single Lock Architecture

ArrayBlockingQueue backs its elements with a fixed-size circular array ring buffer.

To synchronize access across threads, it uses a single ReentrantLock paired with two Condition variables: notEmpty and notFull.

ArrayBlockingQueue Lock Architecture:
                        +----------------------------+
                        |  Single ReentrantLock      |
                        +----------------------------+
                               /              \
                              v                v
                  Condition notEmpty     Condition notFull
                  (Consumers wait here)  (Producers wait here)
// Simplified ArrayBlockingQueue mechanics
public void put(E e) throws InterruptedException {
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == items.length) {
            notFull.await(); // Queue full! Producer thread sleeps here.
        }
        enqueue(e);
    } finally {
        lock.unlock();
    }
}

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0) {
            notEmpty.await(); // Queue empty! Consumer thread sleeps here.
        }
        return dequeue();
    } finally {
        lock.unlock();
    }
}

The Bottleneck: Contention on Single Lock

Because ArrayBlockingQueue uses a single lock for both enqueue and dequeue operations, a producer thread calling put() blocks a consumer thread trying to call take().

In high-concurrency systems with dozens of producer and consumer threads, single lock contention becomes a major bottleneck.


LinkedBlockingQueue: Dual-Lock Splitting

LinkedBlockingQueue solves single-lock contention by using two separate locks:

  1. ReentrantLock putLock (with Condition notFull): Guarded exclusively for producer threads enqueuing items at the tail.
  2. ReentrantLock takeLock (with Condition notEmpty): Guarded exclusively for consumer threads dequeuing items from the head.
LinkedBlockingQueue Dual-Lock Architecture:

[ PRODUCERS ] ---> Acquire putLock  ---> Tail Node
                                            |
                                            v (Atomic AtomicInteger count tracks size)
                                            |
[ CONSUMERS ] <--- Acquire takeLock <--- Head Node

Because putLock and takeLock operate on separate node pointers, a producer thread can enqueue an item at the tail while a consumer thread simultaneously dequeues an item from the head!

An AtomicInteger count tracks element size safely across both locks.


Architectural Comparison

MetricArrayBlockingQueueLinkedBlockingQueue
Backing StorageFixed Array Ring BufferDoubly/Singly Linked Nodes
Locking StrategySingle Lock (lock)Dual Locks (putLock & takeLock)
Concurrent ThroughputLower (producers block consumers)Higher (producers and consumers run in parallel)
Memory AllocationZero allocations after initializationAllocates a Node object per enqueued item
Bounded CapacityMust specify fixed capacity upfrontCan be bounded or unbounded (defaults to Integer.MAX_VALUE)

Quick Summary

  • BlockingQueue coordinates multi-threaded producer-consumer workloads using put() and take().
  • ArrayBlockingQueue uses a fixed array and single lock, creating lock contention between producers and consumers.
  • LinkedBlockingQueue splits lock operations into putLock and takeLock, allowing concurrent enqueues and dequeues.

References & Further Reading

  1. OpenJDK. JEP 269: Convenience Factory Methods for Collections (JDK 9). OpenJDK JEP Standard.
  2. OpenJDK Repository. OpenJDK 21 Source Code: java.util.Collections. GitHub.
  3. Bloch, J. (2018). Effective Java (3rd Edition) — Item 55: Return Optionals Judiciously / Collection Factories. Addison-Wesley.

Up Next in Series →

Part 23: Lock-Free Sorted Range Queries: ConcurrentSkipListMap & SkipLists in Java

Continue to Part 23 →