Java Exchanger: Synchronized Data Exchange Between Two Threads

Exchanger is a synchronization point at which exactly two threads can exchange data: each thread presents an object, blocks until its partner arrives, then receives the partner's object in return. Both threads are blocked until both have arrived at the rendezvous point. This article covers the exchange() and exchange(timeout) overloads, the classic double-buffer pattern, and guidance on when Exchanger is, and is not, the right tool.

What Is an Exchanger?

Exchanger<V>, found in java.util.concurrent, is a synchronization primitive designed for a very specific scenario: exactly two threads need to swap objects at a meeting point. Think of it as a two-person handshake: neither side can proceed until both have arrived, and when they do, each walks away with what the other brought. The swap is atomic and thread-safe; no explicit locks or volatile declarations are needed.

The design constraint is intentional: Exchanger is for pairs. It does not generalise to three or more threads. This makes it simpler and more efficient than a general-purpose barrier for this one use case. It has been available since Java 5.

The canonical use case is double-buffering: a producer thread fills one buffer while a consumer thread processes another buffer filled in the previous cycle. When both finish, they swap buffers at the exchanger. This decouples production speed from consumption speed with no allocation overhead and no shared mutable state between cycles.

Basic Exchange Between Two Threads

Both threads must call exchange(V) on the same Exchanger instance. The first thread to call exchange() blocks. When the second thread calls exchange(), both are unblocked simultaneously: each receives the object that the other passed in. If a thread is interrupted while waiting, InterruptedException is thrown.

Java
import java.util.concurrent.Exchanger;

public class BasicExchangeDemo {

    public static void main(String[] args) throws InterruptedException {
        Exchanger<String> exchanger = new Exchanger<>();

        Thread producer = new Thread(() -> {
            try {
                String dataToSend = "Hello from producer";
                System.out.println("Producer sending: " + dataToSend);
                // Blocks until consumer also calls exchange()
                String received = exchanger.exchange(dataToSend);
                System.out.println("Producer received: " + received);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Thread consumer = new Thread(() -> {
            try {
                Thread.sleep(500); // simulate work before arriving at the exchange
                String dataToSend = "Hello from consumer";
                System.out.println("Consumer sending: " + dataToSend);
                String received = exchanger.exchange(dataToSend);
                System.out.println("Consumer received: " + received);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        producer.start();
        consumer.start();
        producer.join();
        consumer.join();
    }
}
Output
Producer sending: Hello from producer
Consumer sending: Hello from consumer
Producer received: Hello from consumer
Consumer received: Hello from producer

Exchange with Timeout

The blocking exchange(V) will wait indefinitely for the partner thread. In real applications, indefinite blocking is often unacceptable, if the partner thread dies, crashes, or stalls, the first thread would be stuck forever. The timed overload exchange(V, long timeout, TimeUnit unit) throws TimeoutException if the partner does not arrive within the deadline, letting you clean up and report the problem.

Java
import java.util.concurrent.*;

Exchanger<Integer> exchanger = new Exchanger<>();

Thread workerA = new Thread(() -> {
    try {
        Integer result = 42;
        // Wait at most 2 seconds for partner; throw TimeoutException if it doesn't come
        Integer partnerResult = exchanger.exchange(result, 2, TimeUnit.SECONDS);
        System.out.println("Worker A got: " + partnerResult);
    } catch (TimeoutException e) {
        System.err.println("Worker A timed out — partner never arrived");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

// Worker B never starts — Worker A will time out after 2 seconds
workerA.start();
workerA.join();
After a TimeoutException, the exchanger is still usable: the timed-out thread's value was never exchanged. However, if Worker B later calls exchange(), it will block waiting for the next partner. Make sure both threads coordinate their shutdown to avoid leaving one perpetually waiting.

The Double-Buffer Pattern

Double-buffering is the most common reason to reach for Exchanger. The idea is that a filler thread and a drainer thread each hold a buffer. In each cycle, the filler writes new data into its buffer while the drainer processes data in its own buffer. At the end of each cycle, they swap buffers at the exchanger. Neither thread ever touches the other's buffer while work is in progress: there is no shared mutable state during the work phase, only at the swap point. This pattern achieves maximum throughput because filling and draining happen in parallel.

Java
import java.util.concurrent.*;

public class DoubleBufferDemo {
    private static final int BUFFER_SIZE = 8;
    private static final int CYCLES = 4;

    public static void main(String[] args) throws InterruptedException {
        Exchanger<int[]> exchanger = new Exchanger<>();

        // Filler: writes incrementing integers into its buffer, then swaps
        Thread filler = new Thread(() -> {
            int[] buffer = new int[BUFFER_SIZE];
            int value = 0;
            try {
                for (int cycle = 0; cycle < CYCLES; cycle++) {
                    // Fill the buffer
                    for (int i = 0; i < BUFFER_SIZE; i++) {
                        buffer[i] = ++value;
                    }
                    System.out.println("Filler filled: " + java.util.Arrays.toString(buffer));
                    // Swap — hand over the filled buffer, get back the drained one
                    buffer = exchanger.exchange(buffer);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        // Drainer: receives filled buffer, sums it, then swaps back an empty one
        Thread drainer = new Thread(() -> {
            int[] buffer = new int[BUFFER_SIZE]; // starts with an empty buffer
            try {
                for (int cycle = 0; cycle < CYCLES; cycle++) {
                    // Swap — hand over the empty buffer, get back the filled one
                    buffer = exchanger.exchange(buffer);
                    // Drain the buffer
                    int sum = 0;
                    for (int v : buffer) sum += v;
                    System.out.println("Drainer sum: " + sum);
                    java.util.Arrays.fill(buffer, 0); // clear for next cycle
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        filler.start();
        drainer.start();
        filler.join();
        drainer.join();
    }
}
The filler and drainer never write to the same array at the same time: one always owns the buffer currently being worked on. The swap at the exchanger is the only synchronization point needed. This is why the pattern is zero-allocation after the initial setup.

When to Use, and When to Avoid, Exchanger

Exchanger is the right choice when your design naturally involves exactly two threads that need to synchronize and swap data at a rendezvous point in a recurring cycle. The double-buffer pattern is the canonical example. It is also useful in genetic algorithm implementations where two populations swap subsets of candidates in each generation.

Avoid Exchanger in these situations:

  • More than two threads. Exchanger only supports pairs. For N-thread barriers use CyclicBarrier; for one-way handoff use SynchronousQueue or BlockingQueue.
  • One-directional data flow. If data always flows one way (producer to consumer), a BlockingQueue is simpler and more flexible: it decouples the threads without requiring both to arrive simultaneously.
  • Optional or conditional exchange. Exchanger always blocks. If the exchange is optional or the partner may not always participate, use SynchronousQueue.offer()/poll() with timeouts instead.
  • Unbounded parallelism. If you have a pool of worker threads that pair off dynamically, Exchanger is not designed for that. The pairing is arbitrary and can lead to starvation if the number of threads is odd.
NeedBest tool
Two threads swap data each cycleExchanger
One-way producer → consumer handoffBlockingQueue
N threads rendezvous at a phase boundaryCyclicBarrier
One-shot countdown to completionCountDownLatch
Direct thread-to-thread handoff (no buffer)SynchronousQueue