JDK 并发包里除了常见的锁和 CountDownLatch,还有一个相对低调的工具类 Exchanger。它解决的是一个很具体的问题:两个线程各自持有对象,在约定的同步点上互相交换,A 拿到 B 的数据,B 拿到 A 的数据。这个语义用锁和条件变量也能实现,但代码会繁琐不少。本文从使用方式、底层实现和适用场景三个角度,把 Exchanger 讲清楚。

Exchanger 的基本用法与核心语义
Exchanger 的 API 极其简单,核心只有一个 exchange(V x) 方法。线程调用该方法时把自己的数据作为参数传入,如果对方线程还没到达交换点,当前线程就阻塞等待;一旦两个线程都到达,JVM 内部就把两者的数据互换并各自返回。也就是说,exchange 的返回值不是你传入的对象,而是对方线程传入的对象。
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
new Thread(() -> {
try {
String data = "生产者提交的数据";
String received = exchanger.exchange(data);
System.out.println("线程A 拿到了: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "线程A").start();
new Thread(() -> {
try {
String data = "消费者回传的确认";
String received = exchanger.exchange(data);
System.out.println("线程B 拿到了: " + received);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "线程B").start();
}
}运行后你会发现,线程 A 打印的是线程 B 的数据,反之亦然。这里有几个值得注意的细节。第一,Exchanger 是泛型类,两边的类型必须一致,编译期就能约束住数据类型。第二,exchange 可能抛出 InterruptedException,因为等待方可能被中断唤醒,调用处必须处理好中断语义,通常做法是恢复中断标志。第三,如果担心对方迟迟不来导致自己永久阻塞,可以使用带超时的重载方法 exchange(V x, long timeout, TimeUnit unit),超时后抛出 TimeoutException 而不是无限等待。
另一个常被忽略的特性是:当参与交换的线程数量超过两个时,Exchanger 并不会报错,而是让线程两两自由配对,具体谁和谁交换取决于到达顺序和内部调度。JDK 文档把这个行为描述为可以当作双向同步栅栏使用。所以如果你的业务严格要求固定的两个线程交换,必须在逻辑上自己保证只有两个线程参与,否则结果可能不符合预期。
底层原理:exchange 如何完成阻塞与配对
Exchanger 在 JDK 8 之后的实现基于一个叫做 PairExchanger 的内部结构,核心是 Slot 和 Node 两个内部类,并利用了 @sun.misc.Contended 注解做伪共享消除。简单来说,每个 Exchanger 实例内部维护一个 slot 槽位,第一个到达的线程把自己的数据和线程引用包装成 Node 放进 slot,然后通过自旋加阻塞的方式等待;第二个到达的线程发现 slot 里已经有人,就取出对方的 Node,把自己的数据填进去,并唤醒第一个线程,双方各自返回对方的数据。
这里的性能设计很有讲究。单个 slot 的场景(也就是典型的两线程交换)走的是精心优化的快路径:先自旋若干次,避免线程刚到就被挂起,因为对方可能马上就到;自旋耗尽后才通过 LockSupport 挂起。而 slot 数组的设计是为了支持多个 Exchanger 调用方并发交换的场景,减少同一缓存行上的竞争。Contended 注解会把 slot 填充到独立的缓存行,避免不同 slot 的写入互相干扰对方的 CPU 缓存,这在高并发下对吞吐量的提升非常明显。
从内存语义上看,exchange 相当于一个完整的同步点:数据通过 Node 中的引用传递,配合 volatile 写和 CAS 操作保证可见性,一方对数据的修改在另一方 exchange 返回之后一定可见。这意味着你不需要额外加 volatile 或锁来保护被交换的对象本身,但要注意交换的是引用,如果交换后双方仍然并发修改同一个可变对象,依旧会有线程安全问题。安全的做法是交换不可变对象,或者交换后各自只操作自己拿到的那个对象。
典型应用场景与替代方案对比
Exchanger 最经典的场景是流水线式的缓冲区交换:一个线程负责填充缓冲区(比如从网络或磁盘读数据),另一个线程负责消费缓冲区(解析、写库),两个缓冲区轮流交换,填充和消费永不互相干扰。这在高性能 IO 处理中能显著减少内存分配和 GC 压力。
import java.util.concurrent.Exchanger;
public class BufferSwapDemo {
static final int CAPACITY = 1024;
public static void main(String[] args) {
Exchanger<char[]> exchanger = new Exchanger<>();
new Thread(() -> {
char[] buffer = new char[CAPACITY];
try {
while (true) {
// 填充数据,模拟读取
for (int i = 0; i < CAPACITY; i++) {
buffer[i] = (char) ('A' + (i % 26));
}
buffer = exchanger.exchange(buffer); // 交出满缓冲区,拿回空缓冲区
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "填充线程").start();
new Thread(() -> {
char[] buffer = new char[CAPACITY];
try {
while (true) {
buffer = exchanger.exchange(buffer); // 交出空缓冲区,拿回满缓冲区
// 消费数据,模拟处理
System.out.println("处理到缓冲区首字符: " + buffer[0]);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "消费线程").start();
}
}这个例子的精妙之处在于缓冲区对象在两个线程之间循环流转,全程零分配。类似思路也可以用在遗传算法中个体配对交叉、校对任务双线程互相比对结果等场合。
那么什么时候不该用 Exchanger?如果交换不是成对发生的,或者参与方超过两个,应该考虑 SynchronousQueue 或者 BlockingQueue。SynchronousQueue 支持多生产者多消费者,但它是单向传递,数据只从一方流向另一方,不返回值;而 Exchanger 是双向的,一次调用完成交换,语义更贴合配对场景。如果只是想传递单向数据,用队列更合适。至于用共享变量加锁自己实现交换,虽然可行,但要处理状态标志、条件等待、唤醒顺序等一堆细节,容易写出死锁或丢失唤醒的 bug,除非有特殊需求,否则没必要重复造轮子。
总结一下,Exchanger 是一个语义清晰、实现精巧的轻量级同步点,适合两个线程在固定节奏下互换数据的场景。使用时把握三点:确保恰好两个线程参与、正确处理中断和超时、交换不可变对象或交换后各自独占修改,就能安全又高效地把它用起来。