《Java 并发编程·JUC 源码与容器》4.3 Exchanger 与同步器组合

拆开 Exchanger 的 Slot、Participant 与 arena,说明它为什么不用 AQS、0 号槽为何是唯一的阻塞点、配对与超时怎样收尾,再用双缓冲快照和两个信号量反序获取的真实死锁输出,收束本章,并指出这类死锁的检测盲区与组合风险。

承接的问题

Exchanger 是 JUC 里唯一做“双向交换”的同步器:两个线程各带一个对象到达,互相交换后各自返回。它没有“许可”、没有“计数”、也没有“阶段”,只有一个动作——配对。

4.1 和 4.2 的同步器都能被多个线程使用,Exchanger 严格只服务两个。这个限制决定了它的实现方式,也决定了它不能复用 AQS 的队列模型。这一节先拆它的数据结构,再看配对、自旋与超时,最后讨论本章真正容易被忽略的部分:多个同步器组合时,死锁是怎么悄悄出现的。

Slot、Participant 与 arena

Exchanger 的核心数据结构只有三个类:

// JDK 25 源码摘录,已删减
@jdk.internal.vm.annotation.Contended
static final class Slot {
    Node entry;
}

/**
 * Nodes hold partially exchanged data, plus other per-thread
 * bookkeeping.
 */
static final class Node {
    long seed;              // Random seed
    int index;              // Arena index
    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
    Node() {
        index = -1;         // initialize on first use
        seed = Thread.currentThread().threadId();
    }
}

/** The corresponding thread local class */
static final class Participant extends ThreadLocal<Node> {
    public Node initialValue() { return new Node(); }
}

private static final Participant participant = new Participant();

private final Slot[] arena;

三个类各司其职。Node 是每个线程的“交换信封”,用 ThreadLocal 保存,里面放自己要给对方的对象、对方给自己的对象、以及阻塞时挂在这个节点上的线程引用。Slot 是信封架,@Contended 填充是为了让相邻槽位不共享缓存行——否则两个线程在不同槽位上 CAS,仍然会互相拖慢。arena 是槽位数组,构造时只初始化 0 号槽,其余槽位按需创建:

// JDK 25 源码摘录,已删减
public Exchanger() {
    int h = (ncpu = Runtime.getRuntime().availableProcessors()) >>> 1;
    int size = (h == 0) ? 1 : (h > MMASK) ? MMASK + 1 : h;
    (arena = new Slot[size])[0] = new Slot();
}

本机 10 核,反射读出的形状是 arena.length=5, bound=0, slot0=true:数组长度是核数的一半,初始只有 0 号槽有对象。bound 的低位记录当前可用槽位的上界,高位是版本序号,扩容和收缩都靠 CAS 改它。

为什么不用 AQS? AQS 建模的是“资源状态 + 等待者竞争”:有 state、有队列、有独占或共享的语义。Exchanger 没有需要保护的状态——它要回答的是“有没有人在等我”,而这个答案就存在 Slot.entry 里:非 null 表示有人,CAS 成功表示我配上了。任何线程都可以既是生产者又是消费者,没有“持有者”概念,套进 AQS 的 acquire/release 反而要额外维护状态。

配对、自旋与超时

一次交换在 xchg 的大循环里完成,核心是两条路径:发现槽里有人,就取走;槽是空的,就把自己放进去。

// JDK 25 源码摘录,已删减
outer: for (;;) {
    int b, m; Slot s; Node q;
    if ((m = (b = bound) & MMASK) == 0)  // volatile read
        i = 0;
    if (i < 0 || i > m || i >= alen || (s = a[i]) == null) {
        long r = p.seed;                 // randomly move
        r ^= r << 13; r ^= r >>> 7; r ^= r << 17; // xorShift
        i = p.index = (int)((p.seed = r) % (m + 1));
    }
    else if ((q = s.entry) != null) {    // try release
        if (ENTRY.compareAndSet(s, q, null)) {
            Thread w;
            v = q.item;
            q.match = item;
            if (i == 0 && (w = q.parked) != null)
                LockSupport.unpark(w);
            break;
        }
        // 后略:碰撞分支累计 misses 后扩容,offer 分支把节点 CAS 进槽位
    }
}

取走路径是“先 CAS 把槽清空,再写对方的 match”。顺序不能反:如果先写 match 再清槽,第三个线程可能看到槽里还有人,拿到一个已经被消费的节点。CAS 成功的一方拥有这次配对,它把 item 交给等待者,必要时 unpark。

放进去的路径是 CAS 从 null 到自己的节点,然后自旋 SPINS = 1 << 10 次检查 match。这里有一个容易忽略的设计:只有 0 号槽的线程会真正 park。非 0 槽的线程自旋失败后,会把自己从槽里撤掉、换一个下标重试;0 号槽的线程才会设置 parked 并 LockSupport.park。于是任意时刻最多只有一个线程处于阻塞态,其余线程在有限的自旋和换槽中寻找配对。这既是吞吐优化,也是为什么 arena 要按核数缩放:核越多,可并行的配对尝试越多。

超时用绝对截止时间表示。exchange(x, timeout, unit) 把相对时间换算成 System.nanoTime() 基准的 deadline,0 会被特意改成 1 纳秒(源码注释写明是 avoid zero deadline),所以 timeout=0 的含义是“立刻超时”,而不是“永不超时”——永不超时是另一个重载。

撤销路径同样靠 CAS:线程把槽里属于自己的节点换回 null,然后检查中断标志和截止时间,分别抛 InterruptedException 或 TimeoutException。这意味着超时的线程不会留下一个会被别人取走的信封:要么配对成功、要么干净撤回。

实验:双缓冲快照

libraryd-conc 的库存快照每隔一段时间重建一次:写线程在后台缓冲区构建新快照,读线程持有当前前台快照。Exchanger 让两者在“换版”这一瞬间完成交接,而且只有两块缓冲区在循环使用:

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.Exchanger;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public final class SnapshotExchangeCompact {
    record Snapshot(long version, Map<String, Integer> stock) {}

    public static void main(String[] args) throws Exception {
        Exchanger<Snapshot> exchanger = new Exchanger<>();
        Thread publisher = Thread.ofPlatform().name("publisher").start(() -> {
            Map<String, Integer> back = new HashMap<>();
            try {
                for (int v = 1; v <= 3; v++) {
                    back.clear();
                    back.put("9787111213826", 10 * v);
                    back.put("9787111544937", 10 * v + 1);
                    Thread.sleep(40);
                    Snapshot recycled = exchanger.exchange(new Snapshot(v, back));
                    back = recycled.stock();      // 旧前台回收成下一轮后缓冲
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });

        Snapshot front = new Snapshot(0, new HashMap<>());
        for (int i = 0; i < 3; i++) {
            Snapshot next = exchanger.exchange(front);
            System.out.printf("[consumer] v%d -> v%d, buffer=%d, stock=%s%n",
                    front.version(), next.version(),
                    System.identityHashCode(next.stock()), next.stock());
            front = next;
        }
        publisher.join();

        long t0 = System.nanoTime();
        try {
            new Exchanger<String>().exchange("only-me", 100, TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            System.out.printf("lone exchange timed out after %d ms%n",
                    (System.nanoTime() - t0) / 1_000_000);
        }
    }
}

本机真实输出(javac --release 25,单次运行):

[consumer] v0 -> v1, buffer=1309552426, stock={9787111544937=11, 9787111213826=10}
[consumer] v1 -> v2, buffer=1956725890, stock={9787111544937=21, 9787111213826=20}
[consumer] v2 -> v3, buffer=1309552426, stock={9787111544937=31, 9787111213826=30}
lone exchange timed out after 104 ms

三行 buffer 只有两个不同的 identityHashCode,交替出现——这就是双缓冲:写线程交出新快照后,拿回读线程释放的旧快照,清空后作为下一轮的后缓冲,全程只分配两块 HashMap。如果每轮 new HashMap<>(),交换仍然正确,但旧快照的垃圾会在高频率刷新时变成 GC 压力。

超时那行验证了另一半语义:只有一个线程时,exchange 在约 100 ms 后抛 TimeoutException,不会永久挂起。注意这里打印的 104 ms 略大于 100 ms,是线程被唤醒后重新调度的时间。

同步器组合的常见模式

单个同步器很少单独出现,真实代码里更多是组合:

组合典型用途关键约束
Semaphore + CountDownLatch启动门加并发上限先 countDown 再 acquire,否则线程占着许可等开门
CyclicBarrier + 线程池分批并行、批间对齐parties 必须等于“批大小 + 协调线程”
Exchanger + 两个工作线程双缓冲、流水线交接严格成对;奇数个线程会有一个永久等待
Phaser + ExecutorService分阶段 fork-join注册与注销必须成对,异常路径也要注销
Semaphore A + Semaphore B两个资源的联合占用全局固定获取顺序

前四种组合的风险是“等错人”,最后一种的风险是死锁。

组合的死锁风险

下面这个最小复现把两个信号量当成两把锁,两个线程以相反顺序获取,用一个闩锁保证“各自先拿到第一把”:

import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.util.Arrays;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;

public final class CompositionDeadlockCompact {
    public static void main(String[] args) throws Exception {
        Semaphore stockGate = new Semaphore(1);
        Semaphore notifyGate = new Semaphore(1);
        CountDownLatch bothHoldingFirst = new CountDownLatch(2);

        Thread t1 = Thread.ofPlatform().name("loan-worker").start(() ->
                run(stockGate, notifyGate, bothHoldingFirst));
        Thread t2 = Thread.ofPlatform().name("notify-worker").start(() ->
                run(notifyGate, stockGate, bothHoldingFirst));

        bothHoldingFirst.await();
        Thread.sleep(300);

        ThreadMXBean mx = ManagementFactory.getThreadMXBean();
        System.out.println("findDeadlockedThreads=" + Arrays.toString(mx.findDeadlockedThreads()));
        for (Thread t : new Thread[] {t1, t2}) {
            StackTraceElement[] st = t.getStackTrace();
            System.out.printf("%s state=%s, frame=%s#%s%n", t.getName(), t.getState(),
                    st[3].getClassName(), st[3].getMethodName());
        }

        t1.interrupt();
        t2.interrupt();
        t1.join();
        t2.join();
        System.out.println("both workers exited after interrupt");
    }

    static void run(Semaphore first, Semaphore second, CountDownLatch gate) {
        try {
            first.acquire();
            gate.countDown();
            gate.await();
            System.out.println(Thread.currentThread().getName() + " holds first, wants second");
            second.acquire();
        } catch (InterruptedException e) {
            first.release();
            Thread.currentThread().interrupt();
        }
    }
}

本机真实输出:

notify-worker holds first, wants second
loan-worker holds first, wants second
findDeadlockedThreads=null
loan-worker state=WAITING, frame=java.util.concurrent.locks.AbstractQueuedSynchronizer#acquireSharedInterruptibly
notify-worker state=WAITING, frame=java.util.concurrent.locks.AbstractQueuedSynchronizer#acquireSharedInterruptibly
both workers exited after interrupt

两个线程都停在 AQS.acquireSharedInterruptibly 里,互相等待对方释放,构成死锁。但 findDeadlockedThreads() 返回 null:JVM 的死锁检测只覆盖监视器和 OwnableSynchronizer(ReentrantLock 那类有持有者的锁),信号量的等待是 LockSupport.park,它看不到。这不是 JDK 的缺陷,而是“无持有者”同步器的必然结果——没有持有关系,就没有可检测的环。

因此,靠 JVM 自动报警发现不了这类死锁,只能靠线程栈里成对出现的 Semaphore.acquire 加人工判断,或者自己在业务层记录“谁持有哪些许可”。诊断流程卷三第 4 章展开。上面这段程序最后用 interrupt 退出,是因为 Semaphore.acquire 响应中断;如果换成 acquireUninterruptibly,连这个退路都没有。

在 libraryd-conc 里落地

快照交换的落地版本要再叠一层读写协调:借阅线程读快照时不应该看到“半换版”的状态。这里的规则不是“多拿一把锁”,而是先定顺序,再组合:

  1. Exchanger 只由发布线程和刷新线程持有,借阅线程永远不参与交换;
  2. 发布线程在交换前完成所有构建,交换本身不持有任何信号量;
  3. 借阅线程通过一个 Semaphore 限制并发读的数量,但绝不在持许可期间调用 exchange 或等待发布线程。

第 3 条是死锁防线:如果借阅线程持着许可等发布线程,而发布线程又要拿许可才能完成交换,环就闭合了。组合同步器时先画一张“谁等谁”的图,比事后看栈有用。

这个设计的代价

Exchanger 只支持两个参与者,而且是对称的:两边都必须调用 exchange。奇数个线程会有一个永远等不到配对,只能靠超时兜底。需要 N 方交接时应该用 SynchronousQueue 或阻塞队列(第 7 章)。

自旋不是免费的。SPINS 是 1024 次,高核数机器上大量线程同时交换会烧 CPU;arena 扩容也是启发式的,碰撞多时可能反复扩缩。它适合“低频、成对、交接大对象”的场景,不适合每秒百万次的小对象交换。

交接的是引用,不是副本。交换完成后,交出方不应该再修改那个对象——双缓冲之所以安全,是因为交出方拿到的是另一块缓冲区,而且同一时刻只有一方在写。如果两个线程交换后都继续改同一个 Map,Exchanger 不会帮你做任何同步。

什么时候不要用

不要用 Exchanger 做“等待结果”。它没有“谁先到”的语义,两边是对等的;需要一问一答时用 Future 或 CompletableFuture(第 13 章)。

不要在虚拟线程场景里无脑使用。Exchanger 的 park 是平台线程 park,大量虚拟线程在 0 号槽排队会失去虚拟线程的伸缩优势;JDK 25 的 xchg 里对 ForkJoinWorkerThread.hasKnownQueuedWork() 有判断,但这属于实现细节,不是使用承诺。

最后,不要用“多个信号量分别保护多个资源”来替代一把锁。每多一个需要同时持有的同步器,就多一条可能的环;能用一把锁或一个不可变快照解决时,组合越少越安全。

小结

Exchanger 用每个线程一个 Node、一个 @Contended 的 Slot 数组完成配对,没有 state、没有 AQS 队列:槽里有节点就取走,没有就放进去,0 号槽负责唯一的一次 park。超时是绝对截止时间,撤销靠 CAS 干净撤回。它适合双缓冲这类严格成对的交接。组合多个同步器时,真正的风险不是性能,而是获取顺序——两个信号量反序获取就能死锁,而 JVM 的 findDeadlockedThreads() 对此返回 null。

阅读导航:上一节 4.2 CountDownLatch、CyclicBarrier、Phaser | 下一节 5.1 ConcurrentHashMap 的节点结构

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收