承接的问题
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 里落地
快照交换的落地版本要再叠一层读写协调:借阅线程读快照时不应该看到“半换版”的状态。这里的规则不是“多拿一把锁”,而是先定顺序,再组合:
Exchanger只由发布线程和刷新线程持有,借阅线程永远不参与交换;- 发布线程在交换前完成所有构建,交换本身不持有任何信号量;
- 借阅线程通过一个
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 的节点结构
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。