Exchanger详解

222 阅读12分钟

一、概述

Exchanger 是 Java 并发包 java.util.concurrent 中一个较为特殊的同步工具类,它允许两个线程在某个汇合点交换彼此的数据。简单来说,线程 A 调用 exchange() 方法传入数据 a,线程 B 调用 exchange() 方法传入数据 b,当两者都到达汇合点时,A 会收到 b,B 会收到 a。这种机制常用于遗传算法(交换基因)、校对任务(交换中间结果)或双线程管道协作等场景。

SynchronousQueue 不同,Exchanger 专为成对双向交换设计,且内部采用无锁算法(基于 Unsafe 的 CAS 操作)实现高吞吐量。在 JDK 8 中,其实现引入了缓存行填充@Contended)和多槽竞技场(Arena)机制,有效缓解了伪共享(False Sharing)和高并发下的竞争问题。本文将基于 JDK 8 源码(openjdk-8-src-b132-03_mar_2014)深入剖析 Exchanger 的设计精髓。


二、核心方法说明

Exchanger 仅提供两个重载的交换方法,方法签名、参数、返回值及异常说明如下:

方法签名参数返回值异常
public V exchange(V x) throws InterruptedExceptionx:要交换给另一个线程的数据另一个线程提供的交换数据InterruptedException:当前线程在等待时被中断
public V exchange(V x, long timeout, TimeUnit unit) throws InterruptedException, TimeoutExceptionx:交换数据
timeout:超时时间
unit:时间单位
另一个线程提供的交换数据InterruptedException:等待时被中断
TimeoutException:等待超时

两个方法的行为本质相同,区别在于后者支持超时等待。当调用 exchange() 时,当前线程会阻塞直到另一个线程也调用了 exchange()(或超时/中断)。返回值总是对方线程传入的数据,而非自己的数据。


三、核心特性及实现原理

JDK 8 中 Exchanger 的核心特性可归纳为五点,下面逐一结合源码片段进行剖析。

特性一:双线程点对点交换

Exchanger 严格限定两个线程进行数据交换。当第三个线程调用 exchange() 时,它必须等待前一对交换完成,或者与另一个新到达的线程配对。内部通过一个槽位(slot)竞技场(arena) 来匹配线程对。

源码证据Exchanger 的核心交换逻辑位于私有方法 doExchange()(实际源码中为 exchange() 内部调用的 arenaExchangeslotExchange)。JDK 8 源码片段如下(简化展示关键判断):

// Exchanger.java 核心交换入口
private Object doExchange(Object item, boolean timed, long nanos) {
    Node me = new Node(item);                 // 当前线程的节点
    int index = me.hash;                      // 哈希槽索引
    int tries = 1;                            // 重试次数
    // ... 根据竞争程度选择使用 slot 或 arena
}

每个 Node 代表一个等待交换的线程。配对成功的标志是一个线程的 Node 被另一个线程通过 CAS 替换并获取其数据。

特性二:完全无锁化设计(CAS + Volatile)

Exchanger 不使用 synchronizedReentrantLock,而是依赖 sun.misc.Unsafe 提供的 CAS(Compare-And-Swap)原语,配合 volatile 变量保证内存可见性。

源码证据Exchanger 内部定义了一个 volatileslot 字段(Node 类型),以及一个 Node[] 类型的 arena 数组。所有对 slotarena 元素的修改都通过 UNSAFE.compareAndSwapObject() 完成。

// Exchanger.java 中的字段声明
private static final Unsafe U = Unsafe.getUnsafe();
private static final long SLOT;
private volatile Node slot;               // 单槽位

// 对 slot 进行 CAS 设置
boolean casSlot(Node cmp, Node val) {
    return U.compareAndSwapObject(this, SLOT, cmp, val);
}

特性三:伪共享避免(缓存行填充)

在多核 CPU 中,不同线程修改同一缓存行的不同变量会导致“伪共享”,严重损害性能。JDK 8 引入了 @sun.misc.Contended 注解,为 Exchanger 的内部类 Node 的字段进行缓存行填充,确保 Node 的关键字段独占缓存行。

源码证据Exchanger.Node 类定义如下:

@sun.misc.Contended
static final class Node {
    int index;          // arena 中的索引
    int bound;          // 最后一次记录的 bound 值
    int collides;       // 当前 CAS 失败次数
    int hash;           // 伪随机哈希值
    Object item;        // 当前线程要交换的数据
    volatile Object match;   // 配对线程传来的数据
    volatile Thread parked;  // 阻塞的线程(用于挂起)
}

@Contended 注解会使 JVM 在布局对象时,在字段前后填充足够的空白字节(通常 128 字节),确保 matchparked 等高频修改的字段不会与相邻对象共享缓存行。

特性四:多槽竞技场(Arena)自适应竞争

当大量线程使用同一个 Exchanger 时,单槽位会成为瓶颈。JDK 8 实现了一种自适应竞技场:初始时使用单槽位(slot),如果检测到 CAS 频繁失败(即竞争激烈),则动态创建一个 Node 数组(arena),每个线程根据其哈希值选择不同的槽位进行匹配,从而分散竞争。

源码证据doExchange() 中根据 arena 是否为空及当前线程的哈希值决定走 slotExchange 还是 arenaExchange

// 简化后的交换逻辑
private Object doExchange(Object item, boolean timed, long nanos) {
    Node me = new Node(item);
    Node[] a = arena;
    if (a == null || me.hash == 0) {
        // 尝试使用 slot 交换
        return slotExchange(me, timed, nanos);
    } else {
        // 使用 arena 交换
        return arenaExchange(me, timed, nanos);
    }
}

arenaExchange 方法中,线程会根据哈希值定位到某个槽位,并在该槽位上 CAS 等待配对。每次失败后,哈希值会重新计算(hash ^= hash << 13 等),从而“漂移”到不同槽位,避免所有线程挤在同一个槽。

特性五:多阶段自旋与阻塞

为了避免线程频繁进入内核态挂起,Exchanger 采用了多阶段自旋策略:先进行一定次数的自旋(空循环),若仍未配对,再通过 LockSupport.park() 阻塞。自旋次数与 CPU 核心数相关(单核不旋转,多核视竞争程度动态调整)。

源码证据:在 slotExchangearenaExchange 中可以看到如下模式:

// 自旋等待(源码中通过 for 循环和 spins 变量实现)
for (int spins = (NCPU == 1) ? 0 : 2000; spins > 0; --spins) {
    Object m = slot.match;
    if (m != null) {          // 配对成功
        // 清理并返回
        return m;
    }
}
// 自旋未果,挂起当前线程
LockSupport.park(this);

自旋次数会根据历史成功/失败次数动态调整(记录在 boundcollides 中),体现了自适应优化。


四、交换流程时序图

下面使用 Mermaid 绘制两个线程(Thread-1Thread-2)通过 Exchanger 交换数据的核心时序过程。

sequenceDiagram
    participant T1 as Thread-1
    participant EX as Exchanger
    participant T2 as Thread-2

    Note over T1,EX: Thread-1 先到达
    T1->>EX: exchange("A")
    activate EX
    EX->>EX: 检查 slot 是否为 null
    EX->>EX: CAS 将 slot 设为 Node(item="A")
    EX->>EX: 开始自旋/阻塞等待
    EX-->>T1: (阻塞)

    Note over T2,EX: Thread-2 后到达
    T2->>EX: exchange("B")
    activate EX
    EX->>EX: 检查 slot 不为 null
    EX->>EX: CAS 将 slot 置为 null (取出 Node)
    EX->>EX: 将 Node.match 设为 "B"
    EX->>T2: 返回 Node.item ("A")
    deactivate EX
    EX->>T1: 唤醒 Thread-1
    deactivate EX
    T1->>T1: 读取 Node.match ("B")
    T1->>T1: 返回 "B"

详细描述

  1. 线程1 调用 exchange("A"),此时 Exchangerslot 为空。线程1 创建一个 Node 对象(包含数据 "A"),通过 CAS 将 slot 指向该 Node,然后进入自旋+阻塞等待状态。
  2. 线程2 调用 exchange("B"),发现 slot 非空。它通过 CAS 将 slot 置为 null(提取出线程1的 Node),然后把自己的数据 "B" 写入该 Nodematch 字段(volatile 写,保证线程1可见)。
  3. 线程2 立即获得线程1 的数据 "A" 作为返回值,并退出方法。
  4. 线程1 在被唤醒后(由线程2 或自旋成功)读取 Node.match,得到 "B",返回。

如果两个线程几乎同时到达,CAS 竞争会导致一个线程成功设置 slot,另一个线程则会发现 slot 已被占用,从而进入配对流程。


五、实际应用场景与代码举例

场景:双线程数据校对

假设有两个数据源,需要将同一批数据分别处理后交换中间结果进行校对。例如,两个线程分别计算一段文本的 MD5 和 SHA-1 哈希值,然后交换结果用于交叉验证。

import java.util.concurrent.Exchanger;
import java.util.concurrent.atomic.AtomicReference;

public class ExchangerDemo {
    public static void main(String[] args) throws InterruptedException {
        Exchanger<String> exchanger = new Exchanger<>();
        String sharedData = "Hello, Exchanger!";

        Thread thread1 = new Thread(() -> {
            try {
                String md5 = computeMD5(sharedData);
                System.out.println(Thread.currentThread().getName() + " computed MD5: " + md5);
                String sha1FromOther = exchanger.exchange(md5);
                System.out.println(Thread.currentThread().getName() + " received SHA-1: " + sha1FromOther);
                // 进行校对逻辑...
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "MD5-Worker");

        Thread thread2 = new Thread(() -> {
            try {
                String sha1 = computeSHA1(sharedData);
                System.out.println(Thread.currentThread().getName() + " computed SHA-1: " + sha1);
                String md5FromOther = exchanger.exchange(sha1);
                System.out.println(Thread.currentThread().getName() + " received MD5: " + md5FromOther);
                // 进行校对逻辑...
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "SHA1-Worker");

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }

    private static String computeMD5(String input) {
        // 模拟计算,实际应使用 MessageDigest
        return "MD5(" + input + ")";
    }

    private static String computeSHA1(String input) {
        return "SHA1(" + input + ")";
    }
}

输出示例

MD5-Worker computed MD5: MD5(Hello, Exchanger!)
SHA1-Worker computed SHA-1: SHA1(Hello, Exchanger!)
MD5-Worker received SHA-1: SHA1(Hello, Exchanger!)
SHA1-Worker received MD5: MD5(Hello, Exchanger!)

两个线程分别计算后,通过 Exchanger 交换结果,各自获得了对方的计算结果。

代码分析要点

  • 线程1 和 线程2 的 exchange() 调用必须配对,否则会阻塞。
  • 如果某个线程因为异常中断,另一个线程将收到 InterruptedException
  • 数据交换是内存可见的,无需额外同步。

六、吞吐量分析(为什么高/低?)

Exchanger 的吞吐量表现取决于竞争程度、CPU 核心数、数据交换频率等因素。下面从设计原理分析其吞吐量特征。

1. 低竞争场景(仅两个线程,且交替到达)

此时 Exchanger 主要使用单槽位 slot。线程1 设置 slot 后进入自旋,线程2 到达后几乎无阻塞地完成交换。吞吐量极高,原因:

  • 无锁:CAS 操作通常只需要几十个 CPU 时钟周期。
  • 无上下文切换:如果自旋期间配对成功,线程1 不会真正进入阻塞状态,避免了用户态/内核态切换。
  • 数据局部性好slotNode 经过缓存行填充,L1/L2 缓存命中率高。

2. 高竞争场景(多个线程频繁调用同一个 Exchanger)

例如,4 个线程两两配对使用同一个 Exchanger 实例。此时单槽位会成为瓶颈,因为多个线程会同时 CAS 修改 slot,大量失败。JDK 8 的 arena 机制会自动生效:

  • slotExchange 中 CAS 失败次数超过阈值(collides),arena 数组被创建(默认大小 NCPU)。
  • 每个线程通过哈希值选择不同的槽位(例如 arena[hash & mask]),将竞争分散到多个独立的 CAS 变量上。
  • 这大大减少了 CAS 重试和缓存一致性流量,吞吐量相比单槽位可提升接近线性(但受限于 CPU 核心数)。

为什么不可能无限提升?

  • Exchanger 本质是成对交换,每个交换需要恰好两个线程。即使有多个槽位,若线程到达速率不均,某些槽位可能长期空闲,而另一些槽位依然拥堵。
  • arena 数组元素之间也可能存在伪共享(尽管 Node 已填充,但数组本身的索引相邻可能导致竞争),JDK 8 对此未做完美处理(JDK 9 以后进一步优化)。
  • 自旋消耗 CPU:当线程到达时间差较大时,自旋等待会空耗 CPU 周期,降低有效吞吐量(可调整自旋策略或使用超时缓解)。

3. 与其他同步结构的吞吐对比

结构适用场景吞吐特征
Exchanger双线程对称交换极低延迟,高吞吐(尤其在低竞争时)
SynchronousQueue生产者-消费者单方向传递公平模式下吞吐略低(锁开销),非公平模式接近 Exchanger
LinkedBlockingQueue缓冲队列高吞吐但延迟较高(队列容量允许缓冲)
CountDownLatch一次性的等待点较低,因为 await()countDown() 涉及 AQS 排队

结论:Exchanger恰好两个线程紧密协作的场景下是最优选择,吞吐量接近理论极限(仅受 CAS 和内存屏障开销限制)。超出两个线程使用时,性能会因配对失败而下降,但仍优于使用锁实现的交换逻辑。


七、注意事项及原因

1. 只能用于两个线程交换

原因Exchanger 内部没有维护多个等待队列,只有单槽位或有限数组。第三个线程到达时,如果已有线程在等待,它要么直接配对(若等待者存在),要么自己等待(若槽位为空)。但如果有两个以上线程同时等待,则只有一对能成功,其余可能饿死。实际上 Exchanger 的语义就是“成对交换”,多于两个线程时无法保证所有线程都完成交换。

2. 线程配对不具有公平性

原因Exchanger 不保证等待时间最长的线程优先获得交换。它依赖于 CAS 竞争和哈希漂移,哪个线程成功设置 slot 或选中 arena 槽位是随机的。需要公平性的场景应改用 SynchronousQueue 或其他同步器。

3. 必须处理 InterruptedExceptionTimeoutException

原因exchange() 是一个阻塞方法,可能因中断或超时而提前返回。如果不处理,外层代码可能捕获到未预期的异常,导致数据不一致。尤其是当两个线程中有一个超时退出时,另一个线程会永远阻塞(除非也设置了超时)。

4. 避免在交换后继续使用原始对象引用

原因Exchanger 只保证交换数据的引用传递,不进行深拷贝。如果交换的是可变对象,两个线程后续对该对象的修改会互相影响,可能引起并发问题。示例:

List<String> list = new ArrayList<>();
list.add("A");
List<String> other = exchanger.exchange(list);
// 此时 list 和 other 指向同一个对象!修改 one 会影响 two

5. 高并发下注意自旋对 CPU 的消耗

原因:自旋等待会持续占用 CPU,对于核心数较少或对功耗敏感的环境,应使用超时版本的 exchange,或确保线程到达时间差极小。JDK 8 中自旋次数默认可能高达数千次,可通过 -XX:PreBlockSpin 参数调整。

6. 内存可见性由 Exchanger 保证,无需额外 volatile

原因Exchanger 内部对 match 字段的写入是 volatile 操作,并且 CAS 包含了 full barrier,所以从一个线程写入数据到另一个线程读取该数据,天然满足 happens-before 关系。

7. Exchanger 实例可重复使用

原因:一次交换完成后,Exchanger 内部状态被清空(slot 置为 null),可以立即用于下一对线程的交换。因此,一个 Exchanger 对象可以被反复使用,无需重新创建。


总结

Exchanger 是 JUC 中一个精巧的同步工具,通过无锁 CAS、缓存行填充、自适应竞技场等技术,在双线程数据交换场景下实现了极高的吞吐量和极低的延迟。理解其源码实现不仅有助于正确使用 Exchanger,还能学习到现代并发库的优化思想(如伪共享避免、多槽位竞争分解)。在实际开发中,当遇到两个线程需要对称交换数据且对性能敏感时,Exchanger 是首选方案,但需要注意其使用限制和异常处理。