交换器 Exchanger

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 更灵活 分阶段任务,动态增减任务数量
相关推荐
霸道流氓气质1 小时前
MinIO 入门与实战:S3 兼容对象存储、Spring Boot 集成与 AI 应用文件管理
人工智能·spring boot·后端
我真是泰库辣1 小时前
我写了 300 行 Python,让代码替我在《三角洲行动》里吹口琴
后端
CaptainT1 小时前
先把例子跑起来:MySQL 环境与本文的示例数据
数据库·后端·mysql
程序猿乐锅1 小时前
【从0-1学习Python | 第四篇】Python 字符串和元组
java·开发语言·笔记·后端·python·学习
码事漫谈1 小时前
一条 ALTER TABLE 的跨库旅程:KFS 如何自动翻译 DDL
后端
Rain的Java大神之路1 小时前
10 万人抢 2000 个座位,电影票选座系统怎么防超卖?一文讲透大厂高频场景题
java·后端·面试
武子康1 小时前
用户说“算了”,设备指令真的取消了吗?
人工智能·后端·agent
威哥爱编程1 小时前
【AI全栈12-07】Spring Boot 用 Agent 自动完成多步运营任务
java·spring boot·后端
未秃头的程序猿1 小时前
我用 Spring AI + MCP 把公司内部系统接给了 AI,过程没想象中顺利
java·后端