Keyboard Shortcuts N Next post
P Previous post
S Save / unsave
R Read aloud
T Toggle theme
/ Focus search
Esc Close panels
🔥
Ready to read...
Java Java and Cryptography Mastery Series — Extended Java Programming Java Tutorial Module 19 - Advanced Collections and Concurrency

Java SynchronousQueue for Direct Handoff Between Threads

Reviewed & accurate
AI Summary

Introduction

The Java SynchronousQueue class was added in Java 5 (2004) as part of JSR 166. It is a special-case blocking queue that holds no elements at all: every put() must wait for a corresponding take() (and vice versa). The class implements a direct hand-off between producer and consumer threads, with the queue acting as a meeting point rather than a buffer. The java synchronousqueue implementation is the underlying transport for Executors.newCachedThreadPool, where task submission blocks until a worker thread is available to run the task.

For a Java learner, SynchronousQueue is the right tool when you want to enforce that producers and consumers meet one-to-one without buffering. The classic alternative is an unbounded LinkedBlockingQueue, which decouples producers from consumers but allows unbounded heap growth when the producer outruns the consumer. SynchronousQueue provides natural back-pressure: if no consumer is ready, the producer blocks until one appears. This post walks through the dual-queue data structure used internally, the fair versus non-fair mode, and a working producer-consumer demo with direct hand-off.

What is Java SynchronousQueue?

The java synchronousqueue class implements the BlockingQueue interface but does not actually store elements. The interface methods are inherited but every queue-inspecting method (peek, iterator, size, isEmpty) returns the obvious trivial value (null, empty iterator, zero, true). The interface methods that transfer elements (put, offer, take, poll) implement a rendezvous protocol: a put() blocks until a take() arrives, and a take() blocks until a put() arrives. When both sides are present, the element is handed directly from the producer to the consumer, with the queue mediating the meeting but never storing the element.

Conceptually, SynchronousQueue belongs to the family of rendezvous channels, also known as synchronous channels or exchange points. It is distinct from ArrayBlockingQueue and LinkedBlockingQueue (which buffer elements) and from Exchanger (which swaps values between two threads rather than handing one value from a producer to a consumer). It is also distinct from a zero-capacity queue built from a one-capacity ArrayBlockingQueue, because the rendezvous does not require a slot to be filled before being emptied; it can happen in a single atomic step.

How Java SynchronousQueue Works - Step by Step

Step 1: The internal data structure. SynchronousQueue uses one of two implementations depending on the fairness flag. The fair variant uses a TransferQueue (a FIFO queue of waiting requests); the non-fair variant (the default) uses a TransferStack (a LIFO stack of waiting requests). Both implementations store requests, not elements; the element is handed from one side to the other when the matching request arrives.

Step 2: A put(e) call. The producer creates a request node carrying e and pushes or enqueues it onto the internal data structure. If a consumer is already waiting (its request is at the head or top of the data structure), the producer hands e to that consumer and returns immediately. If no consumer is waiting, the producer parks until a consumer arrives and takes e.

Step 3: A take() call. The consumer pops the head of the queue (or top of the stack) of waiting producers. If a producer is waiting, the consumer takes its element and returns it. If no producer is waiting, the consumer parks until a producer arrives. The two sides coordinate through CAS operations on the head or top of the data structure, which is what makes the queue thread-safe without explicit locks.

Step 4: The timed variants (offer with timeout, poll with timeout) block for at most a given duration. If the duration expires before a counterpart arrives, the method returns the special value (false for offer, null for poll) without throwing. This is the right choice when the caller has other work to do and cannot block forever.

Step 5: The fair flag chooses between FIFO and LIFO ordering of waiting requests. The fair variant (TransferQueue) serves requests in arrival order, which prevents starvation but adds a small context-switch cost. The non-fair variant (TransferStack, the default) allows the most recent waiter to be matched first, which is faster in practice because of cache locality but can starve older waiters. Most applications use the default non-fair variant.

Java Program for SynchronousQueue

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class SynchronousQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        SynchronousQueue<String> handoff = new SynchronousQueue<>();
        AtomicInteger delivered = new AtomicInteger();

        Runnable producer = () -> {
            String[] items = {"apple", "banana", "cherry"};
            for (String item : items) {
                try {
                    System.out.println("producer offering " + item);
                    handoff.put(item);
                    delivered.incrementAndGet();
                    System.out.println("producer handed off " + item);
                } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt();
                    return;
                }
            }
        };

        Runnable consumer = () -> {
            try {
                for (int i = 0; i < 3; i++) {
                    Thread.sleep(50); // pretend to be slow
                    String item = handoff.take();
                    System.out.println("  consumer got " + item);
                }
            } catch (InterruptedException ex) {
                Thread.currentThread().interrupt();
            }
        };

        ExecutorService pool = Executors.newFixedThreadPool(2);
        pool.submit(consumer);
        pool.submit(producer);
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);

        System.out.println("delivered total = " + delivered.get());
        System.out.println("queue size       = " + handoff.size());
    }
}

Code Explanation - Line by Line

The main method constructs a SynchronousQueue. The no-arg constructor uses the non-fair variant, which is the right default for most workloads. The AtomicInteger delivered counts how many items the producer has handed off; this counter is incremented after the put() returns, which means it only counts items that have actually reached a consumer.

The producer Runnable iterates over three string items. For each item, it prints "offering" and then calls handoff.put(item). The put method blocks until a consumer is ready to take the item, so the "handed off" print appears only after the rendezvous has actually happened. If no consumer is ready, the producer parks indefinitely.

The consumer Runnable calls Thread.sleep(50) at the top of each iteration to simulate slow work. The sleep makes the producer block on the put for at least 50 milliseconds each round, which makes the rendezvous behaviour visible in the output trace. The consumer then calls handoff.take(), which blocks if the producer has not arrived yet. The two sides meet, the element is handed directly, and the consumer prints "got".

The thread pool spins up the two Runnables on separate threads. The submit order matters here: the consumer is submitted first so that it has a head start on its initial sleep. The producer is submitted second; by the time it starts running, the consumer is in its first sleep. The producer's first put blocks for a few dozen milliseconds until the consumer's first take arrives.

The shutdown and awaitTermination sequence ensures both threads have finished before the main thread prints the totals. The 5-second timeout is generous for a workload that should finish in about 200 milliseconds (three 50-millisecond sleeps). The final print statements confirm that three items were delivered (delivered equals 3) and the queue is empty (size equals 0).

Sample Input and Output

Input:
  SynchronousQueue (no buffer)
  Producer: 3 items via put()
  Consumer: 3 items via take() with 50ms delay between iterations

Output (interleaving varies):
  producer offering apple
  producer handed off apple
    consumer got apple
  producer offering banana
  producer handed off banana
    consumer got banana
  producer offering cherry
  producer handed off cherry
    consumer got cherry
  delivered total = 3
  queue size       = 0

The output shows the producer-consumer rendezvous pattern: every "offering" is immediately followed by "handed off" only after the consumer's take() has arrived. The 50-millisecond consumer sleep means the producer's first put blocks for about 50 milliseconds waiting for the consumer to arrive. The delivered count is 3, and the queue size is 0 (always 0 because SynchronousQueue never stores elements).

Common Mistakes to Avoid

Mistake 1: Calling peek() or iterator() and expecting a non-trivial result. peek() always returns null because no element is buffered. iterator() always returns an empty iterator. size() always returns 0, and isEmpty() always returns true. These methods are inherited from the BlockingQueue interface and exist for backward compatibility, but they have no useful semantics for SynchronousQueue.

Mistake 2: Using offer(e) without a timeout and expecting it to block. The no-arg offer(e) returns immediately, returning false if no consumer is waiting. This is the BlockingQueue interface contract: offer is the non-blocking variant of put. If you want to block for a bounded time, use offer(e, time, unit).

Mistake 3: Forgetting to handle InterruptedException. The put and take methods declare InterruptedException because they block. The canonical handling is to call Thread.currentThread().interrupt() to restore the interrupt flag and return early. Swallowing the exception silently is a bug because the caller loses the information that the operation was cancelled.

Mistake 4: Submitting the producer and consumer to a single-threaded pool. If both Runnables share a pool of size 1, the producer runs first, calls put, blocks, and never yields to the consumer. The result is a deadlock. Always use a pool of at least size 2, or use two separate pools.

Real-World Applications

SynchronousQueue is the work queue for Executors.newCachedThreadPool. The cached-thread-pool pattern spawns a new worker thread for each submitted task up to the pool's maximum; when the maximum is reached, new submissions block until a worker is free. The SynchronousQueue enforces the back-pressure: if no worker is ready, the submission blocks. This is the right pattern for bursty workloads with short tasks.

Message passing between threads in actor-style systems. Frameworks that implement the actor model often use a SynchronousQueue (or a custom channel) for ask-style messages where the sender waits for a reply. Akka and other actor libraries use similar rendezvous protocols for synchronous ask operations.

Resource pool hand-off. A database connection pool that hands connections to requesters one at a time can use a SynchronousQueue for the hand-off: the requester calls take() to wait for a connection, and the pool calls put(connection) to release one. The rendezvous ensures that the requester is present before the connection is released.

Test harnesses for blocking-code demos. Many Java concurrency tutorials use SynchronousQueue to demonstrate the rendezvous pattern without the complexity of a buffered queue. The class is also used in stress tests for thread pool sizing, where the goal is to measure how quickly a pool can absorb bursts of tasks under back-pressure.

Conclusion

The java synchronousqueue class is a zero-capacity blocking queue that implements a direct hand-off between producer and consumer threads. Every put() waits for a take() and vice versa; the queue acts as a meeting point rather than a buffer. To go further, study Exchanger for the bidirectional swap variant and LinkedTransferQueue for the hybrid queue that can either hand off directly or buffer when needed.

Test Your Knowledge
How did you find this?

Comments

Join the discussion! Sign in with your Google or Blogger account, or comment as Anonymous - no account needed. For quick questions, also reach me on Telegram @cytestch.

Comments