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. PowersExecutors.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):
- It looks for an existing consumer thread waiting in the rendezvous queue.
- If a consumer is waiting, the item is transferred directly from producer to consumer.
- 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():
- It inspects the head element of the min-heap.
- If the head element’s
getDelay(NANOSECONDS) <= 0, it dequeues and returns the item. - If
getDelay(NANOSECONDS) > 0, the thread callsavailable.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
SynchronousQueuehas zero storage capacity. It facilitates direct thread-to-thread handoffs, powering cached thread pools.DelayQueuewraps a Min-Heap priority queue ofDelayedelements.- Consumer threads calling
take()on aDelayQueuesleep 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:
- JVM Heap Memory, Stack Frames, and Array Layouts (
Parts 01-02) - Lists, Resizing, and Iterators (
Parts 03-05) - Stacks, Ring Buffer Queues, and Deques (
Parts 06-08) - Maps, Hashes, Resizing, and LRU Caches (
Parts 09-12) - Binary Search Trees, Red-Black Rotations, and TreeSets (
Parts 13-15) - Min-Heap Priority Queues (
Part 16) - Concurrency: Fail-Fast vs Fail-Safe, Immutable Collections, and ConcurrentHashMap (
Parts 17-18) - Bitmask Sets, EnumMaps, Weak References, and Identity Mapping (
Parts 19-21) - Blocking Queues, SkipLists, and Delay Queues (
Parts 22-24)
References & Further Reading
- OpenJDK Code Tools. Java Microbenchmark Harness (JMH) User Guide & Samples. OpenJDK.
- Shipilëv, A. (2016). Nanofiddling Benchmark Tasks & Pitfalls in JMH. OpenJDK Performance Engineering.
- Goetz, B., et al. (2006). Java Concurrency in Practice — Chapter 12: Testing Concurrent Programs. Addison-Wesley.
Part 25: Building a Custom In-Memory Data Store in Java: The Collections Capstone
Continue to Part 25 →