Advanced Topics: Exchanger, DelayQueue & Continuations
A grab-bag of the mechanisms that separate senior engineers from the rest: a two-party data handoff, time-based scheduling without a full scheduler, the memory model rules that make visibility guarantees precise, and the continuation mechanism that makes virtual threads possible.
Learning objectives
- Use Exchanger for a synchronous, two-party data handoff between threads
- Use DelayQueue to implement retry-with-backoff and TTL-based eviction
- Apply the Java Memory Model's happens-before rules to reason about visibility
- Explain false sharing and the cache-line padding technique that fixes it
- Explain what a continuation is and how it underlies virtual threads
- Distinguish a mutex from a binary semaphore by ownership semantics
Most of concurrent programming is built from a small set of well-known tools - locks, executors, atomics - used correctly. But a handful of mechanisms sit one layer underneath those tools, and understanding them is what separates "I can use ReentrantLock" from "I know why ReentrantLock behaves the way it does under contention." this topic collects six of them.
Two are ready-made coordination primitives that don't come up as often as a lock or a queue, but solve their specific problem better than anything you'd build yourself: Exchanger, a synchronization point where exactly two threads swap data atomically, and DelayQueue, a queue whose elements refuse to come out until a per-element timer expires. Two are about the memory model itself - the precise rules the JVM guarantees about when one thread's writes become visible to another thread's reads, and a hardware-level performance trap, false sharing, that has nothing to do with correctness and everything to do with how CPU caches actually work. And two round out the picture of what a "thread" fundamentally is: continuations, the suspend-and-resume mechanism that makes virtual threads possible, and the precise difference between a mutex and a binary semaphore - two things that look interchangeable until ownership matters.
None of these come up in every day-to-day task. All of them come up in a production incident, a system-design interview, or a code review comment from someone who's hit the specific edge case in question before.
Exchanger<T> is a synchronization point where exactly two threads can swap objects. When thread A calls exchanger.exchange(dataA), it blocks until thread B also calls exchanger.exchange(dataB). At that moment, both calls return simultaneously: A receives dataB, B receives dataA, and the swap itself is an atomic, O(1) reference exchange - no data is copied.
The typical use cases share a shape: two independent producers or workers that periodically need to trade complete units of work. Double-buffering is the cleanest example - a producer fills a buffer while a consumer drains a different one, and periodically the two swap: the producer hands off its full buffer and receives an empty one to keep filling, while the consumer receives a full buffer to drain and hands back the one it just emptied. Other genuine uses include genetic-algorithm crossover, where two candidate solutions swap genetic material, and two-player game state synchronization, where each side's move needs to reach the other atomically.
Exchanger is strictly a two-party primitive - there's no version that coordinates three or more threads. CyclicBarrier or Phaser are the right tools once more than two parties need to rendezvous. And there's no timeout by default: if only one thread ever calls exchange(), the other blocks forever. The overload exchange(data, timeout, unit) exists specifically to throw a TimeoutException instead of hanging indefinitely when a partner might not show up.
💻 Code example
package concurrency.advanced; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Exchanger; public class ExchangerDoubleBufferDemo { public static void main(String[] args) throws Exception { // Exchanger is strictly a two-party rendezvous, not N-party. Exchanger<List<String>> exchanger = new Exchanger<>(); List<String> producerBuffer = new ArrayList<>(); List<String> consumerBuffer = new ArrayList<>(); Thread producer = new Thread(() -> { try { producerBuffer.add("log-event"); System.out.println("Producer: swapping full buffer..."); // Blocks until the consumer also calls exchange(); returns // the consumer's (empty) buffer - an O(1) reference swap. List<String> next = exchanger.exchange(producerBuffer); System.out.println("Producer swapped. New buffer size: " + next.size()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread consumer = new Thread(() -> { try { System.out.println("Consumer: swapping empty buffer..."); List<String> next = exchanger.exchange(consumerBuffer); System.out.println("Consumer received data of size: " + next.size()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); consumer.start(); producer.start(); producer.join(); consumer.join(); // Producer ends up with the consumer's empty list; consumer ends up // with the producer's one-item list. Neither exchange() call returns // until both threads have reached it. } }
DelayQueue<E extends Delayed> is an unbounded blocking queue where an element can only be removed once its delay has expired. take() blocks until whatever's at the head of the queue has expired, and then returns it - no manual polling or sleeping required. Every element must implement Delayed: getDelay(TimeUnit) returns the remaining time until that element is ready (negative means already expired), and compareTo orders elements so the internal priority heap always surfaces the soonest-due one first, regardless of insertion order.
That last property - ordering by deadline, not by arrival - is the whole point. A task enqueued first but with a longer delay does not come out first; a task enqueued later but with a shorter delay jumps ahead of it. This maps directly onto two common production patterns. Retry with exponential backoff: wrap each failed call as a delayed task whose delay doubles on every attempt - 100ms, 200ms, 400ms, and so on - and push it onto a shared queue; a worker thread loops on take(), which naturally blocks until the next retry is actually due, with zero manual timer bookkeeping. TTL cache eviction: push a delayed task with delay equal to the entry's time-to-live alongside each cache entry; a background eviction thread loops take(), and every return means that specific entry's TTL has just expired.
Both use cases turn "do this later" into the same take()-in-a-loop pattern, without needing a full scheduler or manually tracked wake-up times anywhere in application code.
💻 Code example
package concurrency.advanced; import java.time.Instant; import java.util.concurrent.DelayQueue; import java.util.concurrent.Delayed; import java.util.concurrent.TimeUnit; public class DelayQueueRetryDemo { static class DelayedTask implements Delayed { private final String name; // Absolute wall-clock deadline, captured once at construction. private final long executeTimeMs; DelayedTask(String name, long delayMs) { this.name = name; 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) { // Orders by absolute deadline - earlier sorts first, backing the // queue's internal priority heap regardless of insertion order. return Long.compare(this.executeTimeMs, ((DelayedTask) o).executeTimeMs); } public String getName() { return name; } } public static void main(String[] args) throws InterruptedException { DelayQueue<DelayedTask> queue = new DelayQueue<>(); // Enqueued FIRST but has the LONGER delay. queue.put(new DelayedTask("retry-attempt-1", 300)); // Enqueued SECOND but has the SHORTER delay - taken before task 1. queue.put(new DelayedTask("retry-attempt-2", 100)); System.out.println("Polling in delay order, not insertion order:"); while (!queue.isEmpty()) { DelayedTask task = queue.take(); // Blocks until due. System.out.println("Executed: " + task.getName() + " at " + Instant.now()); } } }
Every Java thread has its own private stack, holding method call frames, local primitives, and object references - not the objects themselves. A purely local variable that's never passed to another thread is automatically thread-confined and needs no synchronization at all, because no other thread can ever reach it. All objects and arrays, by contrast, live on a single shared heap. The moment a reference to a heap object crosses a thread boundary - passed to new Thread, stored in a static field, submitted to an executor - that object's mutable state becomes a real synchronization concern, since every thread that can reach it can also mutate it.
A subtler, purely hardware-level problem hides inside that shared heap: false sharing. CPU caches work in fixed-size blocks, typically 64 bytes, called cache lines. If thread A writes to one field and thread B writes to an adjacent field, and both fields happen to land in the same 64-byte cache line, every write by A invalidates B's cached copy of that line and vice versa - even though the two threads are logically touching completely independent data. The result is constant cache-line bouncing between CPU cores, which can be 10 to 100 times slower than expected, for a bug that has nothing to do with logical correctness. The standard portable fix - used by the LMAX Disruptor and other high-performance queues - is manual padding: surround a hot field with enough unused filler fields that it ends up alone on its own cache line, so two instances padded this way never share one.
Correctness questions about visibility are governed precisely by the Java Memory Model's happens-before rules (JSR-133), not by intuition. The core rules: program order (each action in a thread happens-before every later action in that same thread); the monitor lock rule (unlocking a monitor happens-before the next lock of that same monitor, making everything done inside a synchronized block visible to the next thread that enters one on the same lock); the volatile rule (a write to a volatile field happens-before every later read of it); the thread-start rule (everything before Thread.start() happens-before any action in the started thread); the thread-termination rule (everything in a thread happens-before another thread observes its completion via join()); and transitivity (if A happens-before B and B happens-before C, then A happens-before C). Without one of these edges connecting a write to a read, the JIT is legally permitted to cache a stale value in a register and never observe another thread's update at all - not a rare bug, but a specification-sanctioned outcome.
💻 Code example
package concurrency.advanced; public class HappensBeforePublicationDemo { // `volatile` guarantees (a) every write is immediately visible to other // threads, and (b) a happens-before edge between this field's writes and // subsequent reads that observe them. private static volatile String sharedState; public static void main(String[] args) throws InterruptedException { Thread writer = new Thread(() -> { // The "publish" step: because sharedState is volatile, this write // happens-before any read in another thread that observes it. sharedState = "fully constructed configuration"; }); Thread reader = new Thread(() -> { // Guaranteed to eventually terminate - rather than looping // forever on a stale cached value - ONLY because the field is // volatile. while (sharedState == null) { /* busy-wait */ } System.out.println("Read safely: " + sharedState); }); reader.start(); writer.start(); writer.join(); reader.join(); // Without `volatile`, the JIT is legally permitted to hoist the read // loop into a register-cached, non-terminating spin - correctness // here depends entirely on the happens-before edge, not luck. } }
A continuation captures the complete execution state of a computation - its program counter, local variables, and stack frames - in a way that lets it be suspended and resumed later, potentially on a different underlying thread. It's a bookmark placed in the middle of a running function, precise enough that execution can later pick back up exactly where it left off.
This is the mechanism virtual threads are built on. When a virtual thread hits a blocking operation such as socket.read(), the JVM captures its entire call stack as a continuation object and moves it onto the heap. The carrier OS thread that was running it is now free to run a different virtual thread. When the I/O eventually completes, the saved continuation is resumed - its stack is restored from the heap, possibly onto a different carrier thread than the one it started on, and execution continues from exactly the point it paused.
This is also the same underlying idea behind coroutines in other ecosystems - Kotlin's coroutines, Python's async/await, Go's goroutines all implement some version of suspend-and-resume. Java's version is unusual in that it's implemented at the JVM level rather than through language syntax: there's no async keyword, no special function color. You write ordinary blocking code, and the suspension and resumption happen transparently underneath it.
The real implementation lives in jdk.internal.vm.Continuation, an internal JDK class never meant to be used directly from application code - Continuation.yield() is called when a virtual thread blocks, and Continuation.run() resumes it once the blocking operation completes. Understanding the shape of this mechanism, even without ever touching the internal API, explains a specific and important limitation: a long CPU-bound loop with no safepoint, or certain operations that "pin" a virtual thread to its carrier, can prevent this unmount-and-resume dance from happening at all, silently degrading a virtual thread back toward platform-thread-like behavior for that stretch of code.
💻 Code example
package concurrency.advanced; public class ContinuationConceptDemo { // A toy stand-in for "this computation's state has been captured and is // sitting on the heap, unmounted from any carrier thread." A real // continuation captures an actual stack of frames, not a single boolean. static class ToyContinuation { private boolean suspended = false; public void run() { if (!suspended) { // Plays the role of the JVM capturing a virtual thread's // stack onto the heap at the moment it would otherwise block. System.out.println("Starting: saving state to heap (suspend point)..."); suspended = true; // Next call takes the resume branch. } else { // Plays the role of the JVM re-mounting the captured // continuation onto a (possibly different) carrier thread. System.out.println("Resuming from heap... execution finished."); } } } public static void main(String[] args) { ToyContinuation c = new ToyContinuation(); c.run(); // Starts and suspends. // Printed while the continuation is (conceptually) suspended - stands // in for the carrier thread being free to do other work during this // window, which is the entire performance benefit over platform // threads for blocking calls. System.out.println("Carrier thread is free to do other work."); c.run(); // Resumes and finishes. } }
A mutex and a binary semaphore look nearly identical from a distance - both let at most one thread through at a time - but they encode a fundamentally different concept, and the difference matters the moment something goes wrong.
A mutex has an owner. Only the thread that acquired it can release it; synchronized and ReentrantLock both enforce this, and ReentrantLock even throws IllegalMonitorStateException if a different thread attempts the unlock. A mutex also supports reentrancy - the owning thread can acquire it again without deadlocking itself, with an internal hold count tracking how many times it needs to be released. Its purpose is mutual exclusion of a critical section: exactly one thread executes that code at a time.
A binary semaphore, a Semaphore initialized with one permit, has no owner at all. Any thread can release it, not only the thread that acquired it - there's no ownership bookkeeping whatsoever. It's also not reentrant: a thread that calls acquire() twice on a semaphore that's down to zero permits deadlocks itself, since nothing distinguishes "I already hold this" from "someone else holds this." Its purpose is signalling between threads, not mutual exclusion - one thread acquiring, and a different thread releasing, is the normal, intended usage, not a bug. A worker thread waiting on a permit that a supervisor thread releases when work becomes available is the canonical use.
Using a binary semaphore where a mutex belongs is technically possible but error-prone, precisely because nothing stops an unrelated thread from accidentally releasing it. Using a mutex where signalling is needed doesn't work at all, because a mutex's ownership rule actively rejects a release from any thread other than the one that acquired it.
▲ Common mistake
Reaching for Thread.yield() expecting it to meaningfully influence scheduling. It's only a hint that the OS scheduler is free to ignore, and on virtual threads it has essentially no effect on the underlying carrier thread at all - a blocking operation is the real yield point for a virtual thread, not an explicit yield call.
▲ Edge case
Context-switch cost compounds at scale in a way that's easy to underestimate. A single switch costs roughly 1 to 10 microseconds, which sounds negligible - but under 10,000 platform threads all contending for CPU time, that overhead becomes a measurable fraction of total capacity, which is exactly why virtual threads' much lower carrier-thread count matters for I/O-heavy workloads even beyond the memory savings.
💻 Code example
package concurrency.advanced; import java.util.concurrent.Semaphore; import java.util.concurrent.locks.ReentrantLock; public class MutexVsBinarySemaphoreDemo { // The mutex side: tracks exactly which thread holds it and enforces that // only that thread may unlock it. private static final ReentrantLock mutex = new ReentrantLock(); // The binary semaphore side: at most one acquire succeeds at a time, but // unlike ReentrantLock, it tracks no notion of ownership at all. private static final Semaphore binarySemaphore = new Semaphore(1); public static void main(String[] args) throws InterruptedException { mutex.lock(); try { System.out.println("Mutex locked by " + Thread.currentThread().getName()); } finally { // Only legal because it's the SAME thread that called lock(). mutex.unlock(); } binarySemaphore.acquire(); System.out.println("Semaphore acquired by main."); Thread t = new Thread(() -> { // Releases from a DIFFERENT thread than the one that acquired it. // Succeeds without exception - Semaphore has no ownership concept. binarySemaphore.release(); System.out.println("Semaphore released by a different thread."); }); t.start(); t.join(); // Trying the same trick with `mutex` instead - having `t` call // mutex.unlock() after main called mutex.lock() - throws // IllegalMonitorStateException. That enforced difference is the // entire distinction between the two. } }
Exchanger : A synchronization point where exactly two threads swap objects atomically; each call blocks until the other side arrives. Strictly two-party - use CyclicBarrier or Phaser for more participants. Good for double-buffering and other paired handoffs.
DelayQueue
: An unbounded queue whose elements become available only once getDelay() returns zero or negative. Elements implement Delayed. Ordered by deadline, not insertion order - powers retry-with-backoff and TTL cache eviction with no manual timer bookkeeping.
What causes false sharing, and how is it fixed? : Two threads writing to different variables that happen to share a 64-byte CPU cache line invalidate each other's cached copy on every write, causing a 10-100x slowdown despite touching logically independent data. The portable fix is padding a hot field with unused filler so it lands alone on its own cache line.
What is a continuation? : A captured, suspendable snapshot of a computation's stack, program counter, and locals. Virtual threads unmount their continuation to the heap on a blocking call and resume it later, possibly on a different carrier thread - this is the entire mechanism behind blocking-style code getting non-blocking scalability.
Mutex vs binary semaphore - what's the real difference? : A mutex has an owner and only that owning thread may release it; it's reentrant. A binary semaphore has no owner - any thread can release it - and is not reentrant. Use a mutex for mutual exclusion; use a semaphore for signalling between threads.
Want a visual for this concept?
Generate a diagram tailored to “Advanced Topics: Exchanger, DelayQueue & Continuations” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →