Exchanger 用于两个线程在同步点 ,双向交换 数据的工具,可以看作双向版的 SynchronousQueue。
基本使用
java
import java.util.concurrent.Exchanger;
public class ExchangerDemo {
public static void main(String[] args) {
Exchanger<String> exchanger = new Exchanger<>();
// 线程A
new Thread(() -> {
try {
String dataA = "A的数据";
System.out.println("线程A准备交换:" + dataA);
// 阻塞,等待另一个线程,拿到对方的数据
String dataB = exchanger.exchange(dataA);
System.out.println("线程A收到对方数据:" + dataB);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// 线程B
new Thread(() -> {
try {
String dataB = "B的数据";
System.out.println("线程B准备交换:" + dataB);
String dataA = exchanger.exchange(dataB);
System.out.println("线程B收到对方数据:" + dataA);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
}
输出结果如下:
js
线程B准备交换:B的数据
线程A准备交换:A的数据
线程A收到对方数据:B的数据
线程B收到对方数据:A的数据
两个线程到达同一个 exchanger.exchange() 这个汇合点,互相交换传入的数据,各自拿到对方的数据后继续往下执行,先到的线程阻塞等待,直到配对线程到来。
js
// 阻塞交换,无超时;等待过程中断抛 InterruptedException
public V exchange(V x) throws InterruptedException
// 带超时版本:超时未等到配对线程,抛出 TimeoutException
public V exchange(V x, long timeout, TimeUnit unit) throws InterruptedException, TimeoutException
那 Exchanger 怎么实现同步交换的呢?难道用了两个 SynchronousQueue ?
java
public class ExchangerSim<V> {
private final SynchronousQueue<V> q1 = new SynchronousQueue<>();
private final SynchronousQueue<V> q2 = new SynchronousQueue<>();
private final AtomicBoolean slotOccupied = new AtomicBoolean(false);
public V exchange(V myItem) throws InterruptedException {
if (slotOccupied.compareAndSet(false, true)) {
// 【先到线程】占用槽位,把自己数据放到q1,等待对方从q2返回数据
q1.put(myItem);
V other = q2.take();
slotOccupied.set(false); // 释放槽位
return other;
} else {
// 【后到线程】已经有人在等,取对方数据,同时把自己数据发过去
V other = q1.take();
q2.put(myItem);
return other;
}
}
先到的线程,把自己的数据 put 到 q1,然后从 q2 take 对方数据;后到线程,的把自己的数据 put 到 q2,然后从 q1 take 对方数据,顺序不能写反,否则死锁。
为了标记先到的线程,可以加一个原子标记 AtomicBoolean,标记当前槽是否有人等待。
这种实现还不错,但这种实现 只有单个槽位,不能支持多对线程同时交换,当大量线程争抢同一个 slot 时,会出现很多线程阻塞排队,而 Exchanger 是多个槽位,而且 Exchanger 是 纯 CAS+LockSupport 自旋挂起,性能更好。
那下面就想看看 Exchanger 是如何交换数据的。
源码分析
java
public class Exchanger<V> {
private static final int ASHIFT = 5;
private static final int MMASK = 0xff;
private static final int SEQ = MMASK + 1;
private static final int NCPU = Runtime.getRuntime().availableProcessors();
static final int FULL = (NCPU >= (MMASK << 1)) ? MMASK : NCPU >>> 1;
private static final int SPINS = 1 << 10;
private static final Object NULL_ITEM = new Object();
private static final Object TIMED_OUT = new Object();
@jdk.internal.vm.annotation.Contended static final class Node {
int index; // Arena index
int bound; // Last recorded value of Exchanger.bound
int collides; // Number of CAS failures at current bound
int hash; // Pseudo-random for spins
Object item; // This thread's current item
volatile Object match; // Item provided by releasing thread
volatile Thread parked; // Set to this thread when parked, else null
}
static final class Participant extends ThreadLocal<Node> {
public Node initialValue() { return new Node(); }
}
private final Participant participant;
private volatile Node[] arena;
private volatile Node slot;
private volatile int bound;
static final class Participant extends ThreadLocal<Node> {
public Node initialValue() { return new Node(); }
}
public Exchanger() {
particip配对t = new Participant();
}
先看 Node,最主要的就是 本线程提交出去交换的数据 item,对方返回给我的数据 match 和 等待配对的线程 Thread parked。
需要注意的是,Participant 这个 ThreadLocal 里面有个 Node,也就是每个线程拥有自己专属 Node 对象,也行每次 exchange 时,都可以复用这个 Node 对象。
其实看到这,就可以大致推测出整个配对逻辑了,A 线程把自己 node 放在 slot/arena 槽,park 等待;B 线程进来 CAS 命中 A 节点,B 把自己 item 写到 A.match ,然后 unpark (A);A 看到 match 不为 null,配对成功,取出 match 作为交换结果。
java
public V exchange(V x) throws InterruptedException {
Object v;
Node[] a;
Object item = (x == null) ? NULL_ITEM : x; // translate null args
if (((a = arena) != null ||
(v = slotExchange(item, false, 0L)) == null) &&
(Thread.interrupted() || // disambiguates null return
(v = arenaExchange(item, false, 0L)) == null))
throw new InterruptedException();
return (v == NULL_ITEM) ? null : (V)v;
}
exchange 根据单槽配对成功与否,选择 slot 单槽配对 与 arena多槽交换。
先看简单的 单槽配对。
单槽配对
java
private final Object slotExchange(Object item, boolean timed, long ns) {
// 拿到当前线程绑定的Node(ThreadLocal里的Node)
Node p = participant.get();
Thread t = Thread.currentThread();
// 如果线程已经带有中断标记,直接返回null,让上层调用方处理中断
if (t.isInterrupted())
return null;
// 循环:尝试抢占slot,分两大分支:slot已有等待节点 / slot为空
for (Node q;;) {
// q = slot:读取全局主槽slot
if ((q = slot) != null) {
// CASE1:slot里面已经存在等待的Node(另一个线程已经占住slot在等配对)
// CAS:把slot置为null,抢占这个等待节点
if (SLOT.compareAndSet(this, q, null)) {
// q是对方留在slot等待的节点
Object v = q.item; // 拿到对方待交换的数据
q.match = item; // 将我当前的item写入对方node的match,完成数据交付
Thread w = q.parked; // 获取对方阻塞park的线程引用
if (w != null)
LockSupport.unpark(w);// 唤醒等待的对方线程
return v; // 返回对方的数据,交换成功!
}
// CAS抢占slot失败 → 发生竞争冲突
// 满足条件:多核CPU,bound=0(arena尚未初始化),CAS把bound设置SEQ序列号,创建arena数组
// 冲突达到条件,初始化arena多槽数组,后续线程会走arenaExchange
if (NCPU > 1 && bound == 0 &&
BOUND.compareAndSet(this, 0, SEQ))
arena = new Node[(FULL + 2) << ASHIFT];
}
else if (arena != null)
// CASE2:slot为空,但是arena数组已经初始化(高并发已经启用多槽)
// 本线程放弃slot路径,返回null,上层代码会路由到arenaExchange多槽逻辑
return null;
else {
// CASE3:slot为空,并且arena还没创建(低并发场景)
// 将当前线程要交换的数据放到自己Node的item
p.item = item;
// CAS尝试把当前线程Node p 写入全局slot,我留在slot等待别人来配对
if (SLOT.compareAndSet(this, null, p))
break; // CAS成功!我成功占住slot,跳出for循环,进入自旋+park等待配对
// CAS失败:有其他线程抢先写入slot,回滚,清空node.item,继续循环重试
p.item = null;
}
}
// ========== 代码走到这里:当前线程Node p已经成功放入slot,开始等待别的线程来配对 ==========
int h = p.hash;
// 如果是超时模式,计算截止时间;非超时模式end=0
long end = timed ? System.nanoTime() + ns : 0L;
// 自旋次数:多核开启SPINS=1024次自旋;单核只自旋1次,减少空转
int spins = (NCPU > 1) ? SPINS : 1;
Object v;
// 循环:自旋+park阻塞,直到p.match不为null(有人给我写入match,配对成功)
while ((v = p.match) == null) {
if (spins > 0) {
// 自旋阶段:伪随机hash扰动,用来随机退避,减少多线程同时CAS冲突
h ^= h << 1; h ^= h >>> 3; h ^= h << 10;
if (h == 0)
h = SPINS | (int)t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // 每轮部分自旋后yield让出CPU,避免占死CPU
}
else if (slot != p)
// 自旋耗尽,但是slot不再是当前node,说明slot被别人抢走了,重置自旋重新尝试
spins = SPINS;
else if (!t.isInterrupted() && arena == null &&
(!timed || (ns = end - System.nanoTime()) > 0L)) {
// 满足所有条件才park阻塞:
// 1.线程未中断;2.arena没有启用;3.非超时模式 或者 超时时间还没到
p.parked = t; // 在node记录当前线程,方便配对线程unpark唤醒
if (slot == p) { // 二次检查:slot还是我,防止状态变化
if (ns == 0L)
LockSupport.park(this); // 无限阻塞
else
LockSupport.parkNanos(this, ns);// 带超时阻塞
}
p.parked = null; // 唤醒后清空parked标记
}
else if (SLOT.compareAndSet(this, p, null)) {
// 退出等待分支:中断 / 超时,CAS把slot清空
// timed并且时间耗尽、线程未中断 → 返回TIMED_OUT标记;其余情况返回null(中断)
v = timed && ns <= 0L && !t.isInterrupted() ? TIMED_OUT : null;
break;
}
}
// 已经配对成功/超时/中断,清理node状态
MATCH.setRelease(p, null); // 清空match,释放volatile写
p.item = null; // 清空本次交换item,复用node
p.hash = h; // 保存hash值,留给下一次交换自旋使用
return v;
}
其实就是循环抢 slot,自己成功占住 slot 之后:先自旋,自旋耗尽再 park 阻塞 ,等待另一个线程来配对写入p.match;一旦match不为 null 代表配对成功;或者中断 / 超时触发,CAS 清 slot,返回对应标记;清理 Node 字段,复用 Node,返回结果。
多槽配对
java
private final Object arenaExchange(Object item, boolean timed, long ns) {
Node[] a = arena; // 获取arena多槽数组
int alen = a.length; // arena数组长度
Node p = participant.get(); // 获取当前线程ThreadLocal持有的私有Node
// 外层大循环:不断选择arena里的槽位j尝试配对;i是当前线程记录的候选槽索引
for (int i = p.index;;) {
// 计算arena数组实际下标j;ASHIFT=5,1<<5=32,每个逻辑槽占用32个物理数组位置
// j = (i << ASHIFT) + ((1 << ASHIFT)-1) 取该逻辑槽块最后一个物理位置
int b, m, c;
int j = (i << ASHIFT) + ((1 << ASHIFT) - 1);
if (j < 0 || j >= alen)
j = alen - 1; // 下标越界修正,防止数组越界
// 原子读arena[j],获取槽位当前Node
Node q = (Node)AA.getAcquire(a, j);
// --------------------------分支1:当前arena槽已经存在等待节点q--------------------------
if (q != null && AA.compareAndSet(a, j, q, null)) {
// CAS抢占该槽位,把槽置空,拿到对方等待的Node q
Object v = q.item; // 取出对方待交换数据
q.match = item; // 将我要交换的数据写入对方node.match,完成交付
Thread w = q.parked; // 获取对方阻塞park的线程
if (w != null)
LockSupport.unpark(w); // 唤醒等待线程
return v; // 返回对方数据,交换成功
}
// --------------------------分支2:槽为空,且i在bound有效范围内,尝试把自己Node放入槽等待--------------------------
else if (i <= (m = (b = bound) & MMASK) && q == null) {
// m = bound & MMASK:bound低8位是arena当前有效逻辑槽最大索引;高8位是SEQ版本号
p.item = item; // 将待交换数据写入自己Node
// CAS尝试:把当前线程Node p放到arena[j]槽,留在槽内等待别人来配对
if (AA.compareAndSet(a, j, null, p)) {
// 只有m==0(arena当前是最外层)并且timed模式,才计算超时截止时间
long end = (timed && m == 0) ? System.nanoTime() + ns : 0L;
Thread t = Thread.currentThread();
// 自旋+park等待循环:等待其他线程来配对写入 p.match
for (int h = p.hash, spins = SPINS;;) {
Object v = p.match;
if (v != null) {
// match不为null,配对成功,清理Node状态,返回对方数据
MATCH.setRelease(p, null);
p.item = null;
p.hash = h;
return v;
}
else if (spins > 0) {
// 自旋阶段:xorshift伪随机hash扰动,用来随机退避,减少多线程争抢同一槽
h ^= h << 1; h ^= h >>> 3; h ^= h << 10;
if (h == 0)
h = SPINS | (int)t.getId();
else if (h < 0 && (--spins & ((SPINS >>> 1) - 1)) == 0)
Thread.yield(); // 自旋过程中主动yield让出CPU
}
else if (AA.getAcquire(a, j) != p)
spins = SPINS; // 槽里已经不是我的Node,重置自旋重新尝试
else if (!t.isInterrupted() && m == 0 &&
(!timed || (ns = end - System.nanoTime()) > 0L)) {
// 满足条件:无中断、m==0、未超时,进入park阻塞
p.parked = t;
if (AA.getAcquire(a, j) == p) { // 二次校验防止状态变化
if (ns == 0L)
LockSupport.park(this);
else
LockSupport.parkNanos(this, ns);
}
p.parked = null;
}
else if (AA.getAcquire(a, j) == p &&
AA.compareAndSet(a, j, p, null)) {
// 超时 / 中断触发,CAS把自己Node从arena槽移除
if (m != 0)
// 尝试收缩arena:bound减SEQ,降低有效槽数量,arena缩容
BOUND.compareAndSet(this, b, b + SEQ - 1);
p.item = null;
p.hash = h;
i = p.index >>>= 1; // index右移,往更小槽位方向,降低搜索范围
if (Thread.interrupted())
return null;
if (timed && m == 0 && ns <= 0L)
return TIMED_OUT;
break; // 本轮等待失败,跳出内层循环,重新外层选槽
}
}
}
else
p.item = null; // CAS放Node进arena槽失败,回滚清空item,继续外层循环
}
// --------------------------分支3:槽位冲突 / bound版本变化,更新index、collides,触发扩容逻辑--------------------------
else {
if (p.bound != b) {
// 当前Node记录的bound版本 和 全局bound不一致:arena发生扩容/缩容,版本陈旧,重置状态
p.bound = b;
p.collides = 0;
i = (i != m || m == 0) ? m : m - 1;
}
else if ((c = p.collides) < m || m == FULL ||
!BOUND.compareAndSet(this, b, b + SEQ + 1)) {
// 冲突计数还没到阈值 / 已经达到arena最大容量FULL / bound CAS扩容失败
p.collides = c + 1; // 冲突计数+1
i = (i == 0) ? m : i - 1; // 循环遍历前一个槽位继续尝试
}
else
i = m + 1; // CAS修改bound成功,arena扩容,下一轮尝试更大的槽索引
p.index = i; // 更新线程Node里保存的候选槽索引,下一轮循环使用
}
}
}
多槽本质是一个Node[] 数组,但对数组进行并发修改有个问题,就是伪共享 (false sharing)。
CPU 缓存是以缓存行(Cache Line,一般 64 字节) 为单位加载。 如果两个对象存在同一个缓存行,CPU 修改其中一个,会让其他 CPU 核心上这个缓存行失效;即使修改的是完全独立变量,也会产生昂贵的缓存失效,性能暴跌。
数组是一块连续堆内存,数组元素是 Node 对象引用,数组里相邻的 arena[j]、arena[j+1] 两个引用(占 8 字节),很可能落在同一个 CPU 缓存行!
比如线程 A 修改 arena [0],线程 B 修改 arena [1],伪共享触发,大量缓存行失效,CAS 性能急剧下降。
解决办法也很简单就是,两个相邻逻辑槽,在 arena 数组上隔开 32 个物理位置。
逻辑槽 0 使用 arena [31],逻辑槽 1 使用 arena [63],逻辑槽 2 使用 arena [95]...... 它们的数组下标相差 32,数组引用在内存上拉开很大距离,保证不同逻辑槽对应的数组引用不会落在同一个 CPU 缓存行。
CAS 抢占槽 的逻辑和 单槽配对 差不多,除此之外,当线程在多个槽反复 CAS 失败,会给冲突计数 p.collides++,当冲突计数达到阈值,尝试BOUND.compareAndSet(b, b+SEQ+1),修改 bound,增大 m,arena 扩容,增加可用逻辑槽,分散竞争压力。
而且线程不是每次随机选槽,是带状态的搜索策略。每个线程 Node 自带index,记录自己优先尝试的槽位;冲突时向前遍历,扩容时向后移动。
使用场景
Exchanger 没有等待队列 ,slot /arena 里,槽位最多只存放一个等待线程 Node 。 而且 Exchanger 不指定配对线程,同一个槽内到达的两个线程互相交换;不同槽的交换互不干扰。
Exchanger 最核心的价值在于 "双向数据交换" ,比如数据校验的场景,两个线程并行独立计算,都算完之后交换结果,互相校验对方计算结果是否一致。
当然最最经典的场景是交换缓冲区 Double Buffering。
当生产者和消费者处理的数据量较大,且频繁创建/销毁对象(如大数组、ByteBuffer)开销很高时,可以用它来复用缓冲区。
- 生产者写满一个缓冲区后,调用
exchange(fullBuffer),交出满的,拿回一个空的。 - 消费者读完一个缓冲区后,调用
exchange(emptyBuffer),交出空的,拿回一个满的。
两者都完成各自工作后,调用 exchanger.exchange(buf),交换缓冲区引用,不需要频繁创建、销毁对象,复用缓冲区,减少 GC 压力。
js
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Exchanger;
import java.util.concurrent.TimeUnit;
/**
* Exchanger 经典场景:双缓冲交换缓冲区
*/
public class ExchangerDoubleBufferDemo {
// 缓冲区容量
private static final int BUF_CAP = 5;
public static void main(String[] args) {
Exchanger<List<String>> exchanger = new Exchanger<>();
// 生产者线程:填充buffer,填满后交换buffer
new Thread(() -> {
// 初始拿到空缓冲区
List<String> buffer = new ArrayList<>(BUF_CAP);
try {
for (int round = 0; round < 3; round++) {
// 1. 填充缓冲区
System.out.printf("【生产者】第%d轮开始填充缓冲区%n", round + 1);
for (int i = 0; i < BUF_CAP; i++) {
buffer.add("数据-" + round + "-" + i);
}
System.out.printf("【生产者】缓冲区填满,准备交换,buffer内容:%s%n", buffer);
// 2. 交换缓冲区:交出满buffer,拿到消费者消费完的空buffer
buffer = exchanger.exchange(buffer, 2, TimeUnit.SECONDS);
System.out.printf("【生产者】交换完成,拿到空缓冲区:%s%n%n", buffer);
// 清空(保险,消费者理论上应该清空)
buffer.clear();
}
} catch (Exception e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}, "生产者").start();
// 消费者线程:消费buffer,消费完成后交换buffer
new Thread(() -> {
// 初始空缓冲区
List<String> buffer = new ArrayList<>(BUF_CAP);
try {
for (int round = 0; round < 3; round++) {
// 1. 交换缓冲区:交出空buffer,拿到生产者填满的buffer
buffer = exchanger.exchange(buffer, 2, TimeUnit.SECONDS);
System.out.printf("【消费者】第%d轮拿到满缓冲区,开始消费:%s%n", round + 1, buffer);
// 2. 消费缓冲区数据
for (String data : buffer) {
// 模拟业务处理
TimeUnit.MILLISECONDS.sleep(200);
System.out.println("【消费者】处理数据:" + data);
}
// 消费完成,清空缓冲区,准备交换回去
buffer.clear();
System.out.printf("【消费者】缓冲区消费完毕,清空:%s%n%n", buffer);
}
} catch (Exception e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}, "消费者").start();
}
}
总结
Exchanger 是 JDK 提供的点对点两两交换数据的同步器,不基于 AQS。低并发走单 slot,高并发启用 arena 多槽数组,支持动态扩缩容;利用 ThreadLocal 复用 Node 减少 GC,搭配 @Contended 和逻辑槽隔离双重方案解决伪共享;采用自旋 + park 的等待策略。
| 同步工具 | 核心语义 | 线程模型 | 是否可复用 | 底层机制 | 典型场景 |
|---|---|---|---|---|---|
| Exchanger | 两个线程互相交换数据 | 两两配对,成对交换 | 可重复调用 exchange | CAS + slot/arena + ThreadLocal Node + park/unpark | 双缓冲、成对线程数据互换 |
| CountDownLatch | 等待 N 个任务完成;等待计数器归零 | 一组线程等待其他 N 个线程 | 不可复用,count 到 0 就失效 | AQS 共享模式 | 主线程等待多个子线程执行完毕 |
| CyclicBarrier | 一组线程互相等待,全部到达屏障点后再一起放行 | 固定数量线程,全部到达才放行 | 可循环复用 | ReentrantLock + Condition | 多线程分批计算,全部到齐再合并结果 |
| Semaphore | 控制并发访问许可数量,限流 | 多线程抢占许可,许可用完阻塞 | 可复用 | AQS 共享模式 | 接口限流、资源池控制(数据库连接池) |
| Phaser | 可动态注册线程数的阶段屏障,支持分层 | 动态增减参与线程,分阶段执行 | 可多阶段复用 | 无锁 CAS + 队列,比 CyclicBarrier 更灵活 | 分阶段任务,动态增减任务数量 |