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.

- 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.
| Method | Partner thread is waiting | No partner thread |
|---|---|---|
| put(e) | Hands over e and returns | Waits for a consumer |
| offer(e) | Hands over e, returns true | Returns false |
| offer(e, timeout, unit) | Hands over e, returns true | Waits up to the timeout, returns false |
| take() | Returns the element | Waits for a producer |
| poll() | Returns the element | Returns null |
| poll(timeout, unit) | Returns the element | Waits 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.
| Class | Producer returns when | Stores elements | Use it for |
|---|---|---|---|
| SynchronousQueue | A consumer took the element | No | Direct handoff, cached thread pools |
| ArrayBlockingQueue(1) | The element is stored in the single slot | One | Producer may be one element ahead |
| LinkedTransferQueue | Per call, at once with put() or after delivery with transfer() | Unbounded | Mixed handoff and buffering |
| Exchanger | The partner thread also arrived | No | Two 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
- SynchronousQueue Javadoc (Java 25)
- ThreadPoolExecutor Javadoc (Java 25)
- Executors.newCachedThreadPool() Javadoc (Java 25)
Happy Learning !!