《Java 并发编程·JUC 源码与容器》4.2 CountDownLatch、CyclicBarrier、Phaser

对比 CountDownLatch 的一次性计数、CyclicBarrier 用锁加条件队列维护的“代”与 Phaser 的 long 位域拆分,再用同一份六分片加载场景的三种写法与真实输出,说明一次性、按代重用、按阶段推进各自的边界与选型取舍。

承接的问题

这三个同步器回答同一个问题:一组线程如何在一个点上会合。差别在生命周期——CountDownLatch 一次性,CyclicBarrier 按“代”重用,Phaser 按阶段推进且参与者可以动态增减。

4.1 的信号量数的是“资源”,可以被任何线程 release;这三者数的是“事件”,参与者身份是显式的,谁到达、到达几次都有明确语义。选错的典型症状是:用闩锁做周期性任务,第二轮直接空转;用栅栏做数量可变的会合,构造时就把 parties 写死。

CountDownLatch:一次性、共享模式

CountDownLatch 的实现是三者里最短的,全部逻辑就是一个 AQS 子类:

// JDK 25 源码摘录,已删减
private static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) {
        setState(count);
    }

    int getCount() {
        return getState();
    }

    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    protected boolean tryReleaseShared(int releases) {
        // Decrement count; signal when transition to zero
        for (;;) {
            int c = getState();
            if (c == 0)
                return false;
            int nextc = c - 1;
            if (compareAndSetState(c, nextc))
                return nextc == 0;
        }
    }
}

await() 走 acquireSharedInterruptibly(1),countDown() 走 releaseShared(1),所以等待者在共享队列上排队,和 1.3 讲的传播链完全一致。两个细节值得对照 4.1 看:

  • tryReleaseShared 只有把计数减到 0 的那一次返回 true,只有那一次触发唤醒;Semaphore.tryReleaseShared 则每次返回 true,因为每个许可都对应一个可放行的等待者。
  • 计数只减不增,也没有复位入口。countDown() 在计数已经是 0 时返回 false 并且什么都不做——它不报错,只是静默失败。这正是“不可重用”的实现层面含义:第二轮调用 countDown() 不会有任何效果,第二轮 await() 会立刻返回。

getCount() 就是读 state,可以在监控里用,但不要拿它做“还差几个”的业务判断,读到的值随时会变。

CyclicBarrier:ReentrantLock + Condition,不是 AQS 子类

CyclicBarrier 没有继承 AQS。它用一把 ReentrantLock 加一个条件队列,加一个“代”对象:

// JDK 25 源码摘录,已删减
private static class Generation {
    Generation() {}                 // prevent access constructor creation
    boolean broken;                 // initially false
}

/** The lock for guarding barrier entry */
private final ReentrantLock lock = new ReentrantLock();
/** Condition to wait on until tripped */
private final Condition trip = lock.newCondition();
/** The number of parties */
private final int parties;
/** The command to run when tripped */
private final Runnable barrierCommand;
/** The current generation */
private Generation generation = new Generation();

private int count;

为什么不用 AQS?因为栅栏要维护的是“一整代”的状态:剩余人数、是否被打破、以及“换代”这个整体动作。用锁加条件队列表达更直接:dowait 在锁内执行 --count,减到 0 的线程执行 barrierCommand,然后 nextGeneration() 做三件事——trip.signalAll() 唤醒所有人、count = parties 复位、换一个新的 Generation 对象。

等待者在 for (;;) 循环里调用 trip.await(),醒来后要重新检查三件事:这一代是否被打破、generation 是否已经换人、以及是否超时。对条件队列的循环等待与虚假唤醒,卷一第 5 章已经讲过,这里是同一个模式。

这个设计带来两个重要语义:

全员或全无。 任何一个等待者被中断或超时,都会调用 breakBarrier():把 generation.broken 置为 true、count 复位、signalAll()。其他等待者醒来看到 broken 就抛 BrokenBarrierException,已经通过栅栏的线程不受影响。业务上要么整批一起走,要么整批失败重来,不存在“走了一半”。

broken 不会自愈。 被打破的这一代已经作废,后续到达的线程看到 g.broken 会立刻抛异常。要恢复必须显式调用 reset(),它会 breakBarrier() 再 nextGeneration(),等于人为作废当前代。这个语义经常被忽略:一次超时之后如果不 reset,这个栅栏对后来者就是永久抛异常的。

还有一点:barrierCommand 是在持锁状态下执行的。命令里做慢操作会阻塞所有后来者,命令里再调用 barrier.await() 会直接卡死。

Phaser:一个 long 拆三段

Phaser 既不是 AQS 子类,也不用锁,它把所有状态压进一个 volatile long,用 VarHandle 做 CAS:

// JDK 25 源码摘录,已删减
private volatile long state;

private static final int  MAX_PARTIES     = 0xffff;
private static final int  MAX_PHASE       = Integer.MAX_VALUE;
private static final int  PARTIES_SHIFT   = 16;
private static final int  PHASE_SHIFT     = 32;
private static final int  UNARRIVED_MASK  = 0xffff;      // to mask ints
private static final long PARTIES_MASK    = 0xffff0000L; // to mask longs
private static final long COUNTS_MASK     = 0xffffffffL;
private static final long TERMINATION_BIT = 1L << 63;

private static final int  ONE_ARRIVAL     = 1;
private static final int  ONE_PARTY       = 1 << PARTIES_SHIFT;
private static final int  ONE_DEREGISTER  = ONE_ARRIVAL|ONE_PARTY;
private static final int  EMPTY           = 1;

private static int unarrivedOf(long s) {
    int counts = (int)s;
    return (counts == EMPTY) ? 0 : (counts & UNARRIVED_MASK);
}

private static int partiesOf(long s) {
    return (int)s >>> PARTIES_SHIFT;
}

private static int phaseOf(long s) {
    return (int)(s >>> PHASE_SHIFT);
}

位布局可以画成一张表:

位宽度含义
631TERMINATION_BIT,终止标记
32–6231phase,阶段号,到 Integer.MAX_VALUE 后回绕
16–3116parties,已注册的参与者数
0–1516unarrived,还没到达的人数

压进一个 long 的理由和 3.1 的读写锁把读写计数压进 64 位一样:一次 CAS 同时更新三个字段。如果 phase、parties、unarrived 分成三个字段,到达操作就要维护三者的一致性,任何一步失败都可能留下“人数减了但阶段没推进”的中间态。

doArrive 的主路径也因此很短:CAS 把 unarrived 减一;如果减之前 unarrived 是 1(自己是最后一个),就调用 onAdvance(phase, nextUnarrived),返回值决定是置终止位还是推进到 phase + 1,然后 releaseWaiters(phase) 唤醒等待这一阶段的线程。等待者不在 AQS 队列里,而是压在两个 QNode Treiber 栈上——evenQ 和 oddQ 按阶段奇偶分栈,注释说这是为了“释放一部分线程的同时加入另一部分”时减少竞争。

和 CyclicBarrier 的关键差异:

  • 参与者可变。 构造时给初始 parties,之后可以 register() 增加、arriveAndDeregister() 减少;CyclicBarrier 的 parties 是 final。
  • 阶段是数字。 getPhase() 返回递增的阶段号,没有“代对象”;onAdvance 返回 true 表示终止,之后 arrive 返回负数,register 也不会恢复。
  • 没有内置的“最后一人动作”参数。 要做和 barrierCommand 一样的事,只能覆盖 onAdvance。
  • 可以分层。 构造时传入 parent,子 Phaser 的到达会向父级传播,适合把大团队拆成小组。

arriveAndAwaitAdvance() 的 javadoc 写的是“返回到达阶段的编号”,但 JDK 25 的实现返回的是推进后的 phase:本机输出里第一轮 returned 1、getPhase() 也是 1。写代码时不要依赖“返回值等于进入时的 getPhase()”这个假设。

同一场景的三种写法

libraryd-conc 每晚要把 6 个分片目录加载进内存,全部就绪后才发布快照。下面这份程序用三种同步器各写一遍,每个分片的加载耗时固定为 20 × (id + 1) 毫秒,便于对照:

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Phaser;
import java.util.concurrent.atomic.AtomicLong;

public final class ThreeWaysCompact {
    static final int N = 6;

    public static void main(String[] args) throws Exception {
        latch();
        barrier();
        phaser();
    }

    static long ms(long t0) { return (System.nanoTime() - t0) / 1_000_000; }

    static void load(int id) {
        try { Thread.sleep(20L * (id + 1)); }
        catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    static void latch() throws Exception {
        CountDownLatch latch = new CountDownLatch(N);
        long t0 = System.nanoTime();
        for (int i = 0; i < N; i++) {
            int id = i;
            Thread.ofPlatform().start(() -> { load(id); latch.countDown(); });
        }
        latch.await();
        System.out.printf("[latch] round1 publish at %d ms, count=%d%n", ms(t0), latch.getCount());

        long t1 = System.nanoTime();
        for (int i = 0; i < N; i++) {
            Thread.ofPlatform().start(() -> {
                try { Thread.sleep(100); }
                catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                latch.countDown();           // count 已经是 0,这次调用没有效果
            });
        }
        latch.await();
        System.out.printf("[latch] round2 publish at %d ms (workers still loading)%n", ms(t1));
    }

    static void barrier() throws Exception {
        AtomicLong roundStart = new AtomicLong(System.nanoTime());
        CyclicBarrier barrier = new CyclicBarrier(N + 1, () ->
                System.out.printf("[barrier] publish at %d ms%n", ms(roundStart.get())));
        for (int i = 0; i < N; i++) {
            int id = i;
            Thread.ofPlatform().start(() -> {
                try {
                    for (int round = 0; round < 2; round++) { load(id); barrier.await(); }
                } catch (Exception e) { throw new RuntimeException(e); }
            });
        }
        for (int round = 0; round < 2; round++) {
            roundStart.set(System.nanoTime());
            barrier.await();
        }
        System.out.printf("[barrier] two generations done, waiting=%d%n", barrier.getNumberWaiting());
    }

    static void phaser() throws Exception {
        Phaser phaser = new Phaser(N + 1) {
            @Override protected boolean onAdvance(int phase, int registered) {
                System.out.printf("[phaser] onAdvance phase=%d registered=%d%n", phase, registered);
                return registered == 0;
            }
        };
        for (int i = 0; i < N; i++) {
            int id = i;
            Thread.ofPlatform().start(() -> {
                for (int round = 0; round < 2; round++) { load(id); phaser.arriveAndAwaitAdvance(); }
            });
        }
        for (int round = 0; round < 2; round++) {
            int returned = phaser.arriveAndAwaitAdvance();
            System.out.printf("[phaser] publish at phase=%d (returned %d), registered=%d%n",
                    phaser.getPhase(), returned, phaser.getRegisteredParties());
        }
    }
}

本机真实输出(javac --release 25 编译,单次运行,耗时会随调度波动):

[latch] round1 publish at 130 ms, count=0
[latch] round2 publish at 3 ms (workers still loading)
[barrier] publish at 123 ms
[barrier] publish at 123 ms
[barrier] two generations done, waiting=0
[phaser] onAdvance phase=0 registered=7
[phaser] publish at phase=1 (returned 1), registered=7
[phaser] onAdvance phase=1 registered=7
[phaser] publish at phase=2 (returned 2), registered=7

三段代码的等待语义完全一致:慢分片 id=5 睡 120 ms,几次发布都在 120–135 ms 之间,说明发布确实发生在最后一个分片加载完之后。差异在第二轮:

  • 闩锁第二轮在 3 ms 就“发布”了,因为 count 已经是 0,await() 立即返回,而新一批工作线程还在睡 100 ms。这就是一次性的代价。
  • 栅栏第二轮在 123 ms 发布,因为 nextGeneration() 把 count 复位成 7,signalAll 之后所有人重新排队。
  • Phaser 第二轮在 phase=2 发布,onAdvance 在第一轮结束和终止时各被调用一次。

在 libraryd-conc 里落地

分片数不固定时,栅栏和闩锁都不合适:CyclicBarrier 的 parties 在构造时写死,CountDownLatch 的计数一旦设定只能减少。Phaser 的 register / arriveAndDeregister 正好覆盖两种情况——启动时按实际分片数注册,某个分片没有数据时直接注销:

package com.plume.conc.core;

import java.util.List;
import java.util.concurrent.Phaser;

/** 每轮按实际分片数注册,加载完再注销;空分片通过 arriveAndDeregister 退出。 */
public final class SnapshotPipeline {
    private final Phaser phaser = new Phaser(1);   // 1 是协调线程自己

    public void loadRound(List<ShardLoader> shards) throws InterruptedException {
        for (ShardLoader shard : shards) {
            phaser.register();
            Thread.ofPlatform().name("shard-" + shard.id()).start(() -> {
                try {
                    if (shard.isEmpty()) {
                        phaser.arriveAndDeregister();   // 不参与本轮会合
                    } else {
                        shard.load();
                        phaser.arriveAndAwaitAdvance();
                    }
                } catch (RuntimeException e) {
                    phaser.arriveAndDeregister();
                    throw e;
                }
            });
        }
        phaser.arriveAndAwaitAdvance();          // 协调线程在这里等齐
    }

    public int phase() {
        return phaser.getPhase();
    }

    public interface ShardLoader {
        int id();
        boolean isEmpty();
        void load();
    }
}

两个边界要留意。第一,注册必须发生在启动线程之前,否则线程可能先 arrive、协调线程后 register,人数永远凑不齐;第二,异常路径也要 arriveAndDeregister(),否则这一轮永远等不到最后一个人,表现为协调线程永久挂起。这也是 Phaser 比闩锁难用的地方:每个参与者都必须保证“要么到达,要么注销”。

日常的停机协调仍然更适合 CountDownLatch:它只减不加、没有复位,语义和“等所有组件关闭”完全吻合,也不需要担心参与者注销。

这个设计的代价

闩锁不可重用,这是设计而不是缺陷。 它换来的是实现最短、语义最窄:只有一个计数,只有归零这一个事件。周期性任务请直接用栅栏或 Phaser。

栅栏的唤醒是 signalAll。 每一代结束都会唤醒所有等待者,然后他们还要重新竞争锁才能返回;参与人数多时这是一次惊群。Phaser 按阶段只唤醒等待该阶段的节点,粒度更细。

栅栏的命令在锁内执行。 barrierCommand 慢,整代参与者都慢;命令抛异常会直接打破栅栏。

Phaser 的状态机最难调试。 phase 回绕、终止位、父级传播、注销与到达的交错,任何一个用错都表现为“人数对不上”。它适合参与者结构会变的场景,不适合拿来替代简单的闩锁。

什么时候不要用

参与者在同步点上做长耗时的工作,三个同步器都不合适。同步点只应该承担“会合”,耗时的加载、计算要在到达之前完成,否则一个慢参与者会拖住所有人,而且从线程栈上只能看到 park,看不出是谁慢——这类问题卷三第 6 章展开。

需要“部分成功也能继续”的场景不要用栅栏。CyclicBarrier 的语义是全有或全无,一个超时就让整代失败;如果业务允许部分分片失败,应该用 Phaser 加自己的成功计数,或者根本不用会合点,改用 CompletableFuture.allOf 这类带结果聚合的机制(第 13 章)。

最后,不要用 CountDownLatch 做“等待某个条件成立”。它只能等计数归零,不能表达“库存大于 0”这种可反复变化的条件;那类需求用条件队列或 Semaphore。

小结

CountDownLatch 把计数放在 AQS 的 state 里,只有归零那一次释放会唤醒等待者,且永不复位;CyclicBarrier 用 ReentrantLock 加 Condition 维护“代”,signalAll 实现整代放行,broken 一旦置位必须 reset() 才能恢复;Phaser 把 phase、parties、unarrived 压进一个 long,一次 CAS 完成到达与推进,支持动态参与者与分层。三者的选择标准不是性能,而是生命周期:一次、按代、按阶段。

阅读导航:上一节 4.1 Semaphore 与许可模型 | 下一节 4.3 Exchanger 与同步器组合

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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