Java SynchronousQueue: Direct Handoff Between Threads

Java SynchronousQueue explained with put(), take(), offer() and poll() examples, a producer-consumer handoff, thread pool use and the alternatives.

Timeline of two threads. The producer calls put(ride-1) and waits. Later, the consumer calls take(); at that moment ride-1 passes from producer to consumer and both threads continue.

A Java SynchronousQueue is a BlockingQueue with no capacity, so every put() waits until another thread calls take(), and every take() waits for a put(). It never stores elements, it only hands one element from a producer thread to a consumer thread at the moment both meet.

We use a SynchronousQueue for direct handoff, where a producer must not run ahead of the consumer at all. The best-known user is Executors.newCachedThreadPool(), which hands each task to an idle thread or starts a new one instead of queuing it.

The following example calls the non-blocking methods on an empty SynchronousQueue, with the result of each line as a comment.

SynchronousQueue<String> rides = new SynchronousQueue<>();
boolean offered = rides.offer("ride-1");        // false (no consumer waiting)
String polled = rides.poll();                   // null (no producer waiting)
String peeked = rides.peek();                   // null (always)
int size = rides.size();                        // 0 (always)
boolean empty = rides.isEmpty();                // true (always)
int capacity = rides.remainingCapacity();       // 0 (always)

Notice that offer() fails even on an empty queue, because there is no slot to put the element in. We look at what makes the class different, how its methods behave, a producer-consumer example, its role in thread pools, and when to choose another queue.

1. What Makes SynchronousQueue Different

Most blocking queues are buffers. A producer adds an element, returns, and a consumer picks it up later. A SynchronousQueue has no buffer, so the insert and the removal happen together in two different threads.

Timeline of two threads. The producer calls put(ride-1) and waits. Later, the consumer calls take(); at that moment ride-1 passes from producer to consumer and both threads continue.
With a SynchronousQueue, put() returns only when a consumer takes the element, so producer and consumer move in step
  • The queue has no internal capacity, not even one slot.
  • The methods size() and remainingCapacity() always return 0, isEmpty() always returns true and peek() always returns null.
  • The iterator is always empty, so a for-each loop over the queue does nothing.
  • A null element is rejected with a NullPointerException.
  • It is thread-safe and supports an optional fairness policy for waiting threads.
  • The name means synchronous handoff between threads. It has nothing to do with the synchronized keyword.

In the Java Collections Framework, a SynchronousQueue belongs to the BlockingQueue family of the Java Queue interface, next to ArrayBlockingQueue and LinkedBlockingQueue.

2. Working with SynchronousQueue

Every method either waits for a partner thread, waits up to a timeout or fails at once. Picking the right one decides whether a producer can get stuck.

MethodPartner thread is waitingNo partner thread
put(e)Hands over e and returnsWaits for a consumer
offer(e)Hands over e, returns trueReturns false
offer(e, timeout, unit)Hands over e, returns trueWaits up to the timeout, returns false
take()Returns the elementWaits for a producer
poll()Returns the elementReturns null
poll(timeout, unit)Returns the elementWaits up to the timeout, returns null

The timed versions are the safe choice in request threads. For example, a ride-hailing app offers a ride request to a driver thread for 50 ms and shows “no driver available” when nobody takes it.

SynchronousQueue<String> requests = new SynchronousQueue<>();
boolean assigned = requests.offer("ride-7", 50, TimeUnit.MILLISECONDS);   // false (no driver took it)
String taken = requests.poll(50, TimeUnit.MILLISECONDS);                   // null (no request arrived)

The constructor SynchronousQueue(boolean fair) sets the fairness policy. With true, waiting producers and consumers are served in FIFO order. With the default false, the order is not specified, which gives better throughput.

3. Solving Producer-Consumer Problem using SynchronousQueue

In a classic producer-consumer setup, a buffer lets the producer get ahead of the consumer. With a SynchronousQueue, the producer hands each element to the consumer and waits until it is taken, so at most one element is in flight.

The following example is a ride dispatcher. A driver thread takes three ride requests, and the main thread puts them. Each put() returns only after the driver has the request, and we wait for the driver with join().

SynchronousQueue<String> dispatch = new SynchronousQueue<>();
List<String> accepted = new CopyOnWriteArrayList<>();

Thread driver = Thread.ofVirtual().start(() -> {
    try {
        for (int i = 0; i < 3; i++) {
            accepted.add(dispatch.take());      // waits for the next request
        }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});
dispatch.put("ride-1");                         // returns when the driver took ride-1
dispatch.put("ride-2");
dispatch.put("ride-3");
driver.join();
List<String> rideLog = List.copyOf(accepted);   // [ride-1, ride-2, ride-3]

If we start no driver thread, the first put() waits forever, and if we start no producer, the driver waits forever in take(). Code that cannot afford that uses the timed offer() and poll() from section 2, or interrupts the waiting thread on shutdown.

4. SynchronousQueue in Thread Pools

A ThreadPoolExecutor puts each new task into its work queue with offer(). With a SynchronousQueue, the offer succeeds only when an idle worker is waiting for a task. Otherwise, the executor starts a new thread, up to the maximum pool size, and rejects the task beyond that. This queuing strategy is called direct handoff.

The method Executors.newCachedThreadPool() uses this setup with a core size of 0, a maximum of Integer.MAX_VALUE and a 60-second keep-alive, so idle threads are reused and new threads start on demand.

In the next snippet, we build a pool with one thread and a SynchronousQueue. The first task keeps the only thread busy until a CountDownLatch opens, so the second task has nowhere to go.

CountDownLatch release = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(1, 1, 60, TimeUnit.SECONDS, new SynchronousQueue<>());
pool.execute(() -> {
    try {
        release.await();                        // keeps the only worker busy
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});
Future<?> second = pool.submit(() -> {});       // RejectedExecutionException
release.countDown();
pool.shutdown();
boolean finished = pool.awaitTermination(1, TimeUnit.SECONDS);   // true

A pool with a SynchronousQueue and an unlimited maximum size, like newCachedThreadPool(), starts a new platform thread for every task that finds no idle worker, so a burst of slow tasks can create thousands of threads. For blocking I/O tasks, a virtual thread per task executor avoids that cost, and for CPU work, a fixed thread pool with a bounded queue limits it.

5. When to Use SynchronousQueue

A SynchronousQueue fits hand-off designs, where an object in one thread must meet a thread that processes it, and buffering would only hide that the consumers are too slow. Other classes cover nearby needs.

ClassProducer returns whenStores elementsUse it for
SynchronousQueueA consumer took the elementNoDirect handoff, cached thread pools
ArrayBlockingQueue(1)The element is stored in the single slotOneProducer may be one element ahead
LinkedTransferQueuePer call, at once with put() or after delivery with transfer()UnboundedMixed handoff and buffering
ExchangerThe partner thread also arrivedNoTwo threads swapping objects both ways

We choose SynchronousQueue when every element must be taken before the producer continues. When a small buffer is acceptable, a bounded ArrayBlockingQueue gives smoother throughput.

6. SynchronousQueue FAQs

Most questions about SynchronousQueue come from its zero capacity, which surprises developers who read thread pool code.

6.1. Does SynchronousQueue Have a Capacity of One?

No, the capacity is zero. An ArrayBlockingQueue with capacity 1 accepts one element without a consumer, whereas a SynchronousQueue accepts an element only when a consumer takes it at the same moment.

6.2. Why Does offer() Return false on an Empty SynchronousQueue?

The method offer() succeeds only when a consumer is already waiting in take() or poll() with a timeout. An empty SynchronousQueue has no slot for the element, so without a waiting consumer the call fails at once.

6.3. Why Does size() Always Return 0?

The queue never holds elements. Even while producers wait in put(), their elements are not counted as stored, so size() returns 0 and isEmpty() returns true.

6.4. Is SynchronousQueue Fair?

Only when we create it with new SynchronousQueue<>(true). A fair queue serves waiting threads in FIFO order, and the default non-fair queue does not guarantee any order.

7. Conclusion

A SynchronousQueue is a queue with zero capacity. Each put() meets a take() in another thread, offer() and poll() fail at once without a partner, and methods such as size() and peek() always report an empty queue.

We use it for direct handoff between threads and see it in newCachedThreadPool(), where it makes the executor reuse an idle thread or start a new one. When a producer may run ahead, a bounded ArrayBlockingQueue or a LinkedTransferQueue fits better.

8. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.