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

Java Specialized Queues: SynchronousQueue Handoffs & DelayQueue Expiration

Zero-capacity rendezvous handoffs, cached thread pools, and delayed task expiration.

Part 24 in Series — Catch up on the previous article: Lock-Free Sorted Range Queries: ConcurrentSkipListMap & SkipLists in Java (Part 23) before diving into this post.

Suppose you are building an automated customer retry engine for failed credit card payments.

When a payment attempt fails, your application schedules a retry attempt 15 minutes into the future. Hundreds of failed payment retries sit in your system with varying expiration timestamps (e.g. retry in 3 minutes, retry in 12 minutes, retry in 45 seconds).

If a background thread continuously polls a list of retry tasks in a while(true) loop checking if (currentTime >= retryTime), your server burns 100% CPU on useless checks.

If you sort tasks by expiration time in a standard queue, a worker thread calling poll() fetches a task scheduled for 15 minutes from now and processes it immediately, breaking payment retry delays.

Java provides two specialized queues for these unique operational constraints: SynchronousQueue and DelayQueue.


Why You Need This in Real Life

These two concurrent collections solve specific architectural challenges:

  • Direct Thread-to-Thread Handoffs (SynchronousQueue): A queue with zero internal storage capacity. A producer thread putting an item blocks until a consumer thread arrives to take it directly. Powers Executors.newCachedThreadPool().
  • Scheduled Expiration Queues (DelayQueue): A priority queue where items can only be un-queued when their individual expiration delay timer reaches zero. Powers task schedulers, session timeouts, and message retry topics.

Part A: SynchronousQueue — The Rendezvous Queue

SynchronousQueue is a queue that contains no internal storage slots. isEmpty() always returns true, and remainingCapacity() always returns 0.

PRODUCER THREAD                                CONSUMER THREAD
  put("TaskA")                                   take()
       |                                           |
       v                                           v
   [ WAITS AT RENDEZVOUS POINT ] <=======> [ TAKES ELEMENT DIRECTLY ]

When a producer thread calls put(item):

  1. It looks for an existing consumer thread waiting in the rendezvous queue.
  2. If a consumer is waiting, the item is transferred directly from producer to consumer.
  3. If no consumer is waiting, the producer thread blocks and waits until a consumer thread arrives.

Why Executors.newCachedThreadPool() Uses SynchronousQueue

A cached thread pool expands dynamically to handle bursts of incoming tasks:

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(
        0,                  // Core pool size: 0
        Integer.MAX_VALUE,  // Max pool size: Unlimited
        60L, TimeUnit.SECONDS,
        new SynchronousQueue<Runnable>() // Zero-capacity handoff queue!
    );
}

Because SynchronousQueue has zero capacity, submitting a task (executor.execute(task)) cannot buffer the item. The pool immediately hands the task to an idle worker thread or spawns a brand new thread if all workers are busy.


Part B: DelayQueue — Time-Based Element Unlocking

DelayQueue holds elements implementing the java.util.concurrent.Delayed interface:

public interface Delayed extends Comparable<Delayed> {
    long getDelay(TimeUnit unit);
}

An item inside a DelayQueue cannot be dequeued via poll() or take() until its getDelay() method returns a value less than or equal to zero.

Internal Mechanics: Min-Heap Priority Queue + Condition

DelayQueue wraps an internal PriorityQueue<E> sorted by remaining delay time:

DelayQueue Internal Min-Heap (Sorted by getDelay()):
[ TaskA (delay: 2 sec) | TaskB (delay: 45 sec) | TaskC (delay: 15 min) ]
        ^
        |
   Head Element (Only node inspected by take())

When a consumer thread calls take():

  1. It inspects the head element of the min-heap.
  2. If the head element’s getDelay(NANOSECONDS) <= 0, it dequeues and returns the item.
  3. If getDelay(NANOSECONDS) > 0, the thread calls available.awaitNanos(delay), sleeping for the exact remaining delay duration.
// Simplified DelayQueue take() mechanics
public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        for (;;) {
            E first = q.peek();
            if (first == null)
                available.await(); // Queue empty! Wait for producer.
            else {
                long delay = first.getDelay(NANOSECONDS);
                if (delay <= 0)
                    return q.poll(); // Expiration reached! Return task.
                
                first = null; // don't retain ref while waiting
                if (leader != null)
                    available.await();
                else {
                    Thread thisThread = Thread.currentThread();
                    leader = thisThread;
                    try {
                        available.awaitNanos(delay); // Sleep for exact delay!
                    } finally {
                        if (leader == thisThread)
                            leader = null;
                    }
                }
            }
        }
    } finally {
        if (leader == null && q.peek() != null)
            available.signal();
        lock.unlock();
    }
}

Code Example: Building a Scheduled Payment Retry Task

import java.util.concurrent.DelayQueue;
import java.util.concurrent.Delayed;
import java.util.concurrent.TimeUnit;

class PaymentRetryTask implements Delayed {
    private final String paymentId;
    private final long executeTimeMs;

    public PaymentRetryTask(String paymentId, long delayMs) {
        this.paymentId = paymentId;
        this.executeTimeMs = System.currentTimeMillis() + delayMs;
    }

    @Override
    public long getDelay(TimeUnit unit) {
        long diff = executeTimeMs - System.currentTimeMillis();
        return unit.convert(diff, TimeUnit.MILLISECONDS);
    }

    @Override
    public int compareTo(Delayed o) {
        return Long.compare(this.executeTimeMs, ((PaymentRetryTask) o).executeTimeMs);
    }

    public String getPaymentId() {
        return paymentId;
    }
}

public class DelayQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        DelayQueue<PaymentRetryTask> queue = new DelayQueue<>();

        queue.put(new PaymentRetryTask("PAY-1001", 5000)); // Retry in 5 seconds
        queue.put(new PaymentRetryTask("PAY-1002", 1000)); // Retry in 1 second

        System.out.println("Waiting for scheduled payment retries...");
        
        // Blockingly fetches earliest expired task
        PaymentRetryTask first = queue.take(); // Returns PAY-1002 after 1 second!
        System.out.println("Processing retry for: " + first.getPaymentId());

        PaymentRetryTask second = queue.take(); // Returns PAY-1001 after 4 more seconds!
        System.out.println("Processing retry for: " + second.getPaymentId());
    }
}

Quick Summary

  • SynchronousQueue has zero storage capacity. It facilitates direct thread-to-thread handoffs, powering cached thread pools.
  • DelayQueue wraps a Min-Heap priority queue of Delayed elements.
  • Consumer threads calling take() on a DelayQueue sleep for the exact remaining delay duration, consuming zero CPU cycles until tasks expire.

Complete Series Master Recap (Parts 1–24)

You have completed the master architecture series on Java Collections:

  1. JVM Heap Memory, Stack Frames, and Array Layouts (Parts 01-02)
  2. Lists, Resizing, and Iterators (Parts 03-05)
  3. Stacks, Ring Buffer Queues, and Deques (Parts 06-08)
  4. Maps, Hashes, Resizing, and LRU Caches (Parts 09-12)
  5. Binary Search Trees, Red-Black Rotations, and TreeSets (Parts 13-15)
  6. Min-Heap Priority Queues (Part 16)
  7. Concurrency: Fail-Fast vs Fail-Safe, Immutable Collections, and ConcurrentHashMap (Parts 17-18)
  8. Bitmask Sets, EnumMaps, Weak References, and Identity Mapping (Parts 19-21)
  9. Blocking Queues, SkipLists, and Delay Queues (Parts 22-24)

References & Further Reading

  1. OpenJDK Code Tools. Java Microbenchmark Harness (JMH) User Guide & Samples. OpenJDK.
  2. Shipilëv, A. (2016). Nanofiddling Benchmark Tasks & Pitfalls in JMH. OpenJDK Performance Engineering.
  3. Goetz, B., et al. (2006). Java Concurrency in Practice — Chapter 12: Testing Concurrent Programs. Addison-Wesley.

Up Next in Series →

Part 25: Building a Custom In-Memory Data Store in Java: The Collections Capstone

Continue to Part 25 →