Skip to content
Go back

CountDownLatch、CyclicBarrier、Semaphore——三大并发工具辨析

三大并发工具:CountDownLatch、CyclicBarrier、Semaphore

一句话结论(30s)

三大工具本质是三种不同的同步语义——CountDownLatch 是”一方等多方”的一次性倒计数,CyclicBarrier 是”多方互等”的可重置栅栏,Semaphore 是”多方竞争”的许可计数,因为它们的等待关系根本不同。关键设计是 Latch 靠 state 减到 0 放行且不可重置,Barrier 靠 generation 自动重置并支持到达回调,Semaphore 靠 AQS 许可数实现限流。权衡是三者都基于 AQS,应按业务”等待关系”而非”资源控制”来选型。

核心原理(2min)

CountDownLatch 子线程 countDown() 减计数、主线程 await() 等到 0(主等子,一次性);CyclicBarrier 所有线程 await() 后同时放行并自动重置,可带 barrierAction 回调做阶段汇总(多方互等,可重复);Semaphore acquire/release 控制同时进入临界区的线程数,适合连接池和限流(许可可还回,可重复)。先别急着背 API,问自己一句:这三者到底在”等什么”? 答案是等待关系不同——Latch 是一方等另一方(主等子),Barrier 是多方互相等(大家都等大家都到齐),Semaphore 是多方等一个”名额”(资源余量)。想清楚”谁在等谁、能不能重复”,比记住方法名重要得多。

底层深入(5-10min)

CountDownLatch:一次性倒计数

CountDownLatch latch = new CountDownLatch(5);
// 5 个子线程各自执行
for (int i = 0; i < 5; i++) {
    executor.submit(() -> {
        doWork();
        latch.countDown();  // 完成一个,计数-1
    });
}
latch.await();  // 主线程等待计数到 0
// 5 个子线程都完成后,主线程继续

源码:把 AQS 的 state 当作一次性倒计数器(JDK CountDownLatch.Sync):

private static final class Sync extends AbstractQueuedSynchronizer {
    private static final long serialVersionUID = 4982264981922014374L;

    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;
        }
    }
}

CountDownLatch 的 countDown() 本质就是一行 sync.releaseShared(1)。它把 AQS 的 state 当成”还剩几个未完成”:tryAcquireShared 只在 state == 0 时返回 1(表示所有 await 线程都能共享地通过),tryReleaseShared 用 CAS 自旋把计数减一,只有恰好从 1 减到 0 的那一次才返回 true 触发唤醒。这里的”共享”语义是:一旦计数归零,所有在 await() 上排队的线程被一次性全部放行。

💭 思考:为什么 countDown() 走的是「release 共享」、await() 走的是「acquire 共享」,而不是各用各的独占锁?——从需求反推:await() 要的是「计数归零时,所有等待者一起放行」,这恰好就是 AQS 共享模式的语义(一次 release 能唤醒所有 acquire 的线程)。如果换成独占模式,一次只能唤醒一个,计数归零后剩下的 await 线程会继续睡死。所以「共享」两个字不是顺手写的,而是被「一方等多方、一旦放行全部放行」这个业务关系逼出来的。

一次性:计数到 0 后无法重置。为什么不能重置? 因为 state 就是一个普通计数器,减到 0 后没有”世代”概念去区分”上一轮”和”下一轮”——所有 await 线程一旦被放行就彻底通过,之后再怎么 countDown 也无法把 state 加回去。场景:主线程等所有子线程初始化完成。

CyclicBarrier:可重复使用的栅栏

CyclicBarrier barrier = new CyclicBarrier(3, () -> log.info("本阶段全部到达!"));

// 3 个线程各自执行
for (int i = 0; i < 3; i++) {
    executor.submit(() -> {
        phase1();
        barrier.await();  // 等另外 2 个也完成 phase1
        phase2();
        barrier.await();  // 等另外 2 个也完成 phase2
    });
}

源码:Generation 换代实现可重置(JDK CyclicBarrier):

private static class Generation {
    Generation() {}                 // prevent access constructor creation
    boolean broken;                 // initially false
}

private void nextGeneration() {
    // signal completion of last generation
    trip.signalAll();
    // set up next generation
    count = parties;
    generation = new Generation();
}

private void breakBarrier() {
    generation.broken = true;
    count = parties;
    trip.signalAll();
}

dowait() 是栅栏的核心:每个到达的线程拿到锁后 --count,若减到 0 就是”触发方”,先执行可选的 barrierCommand,再调用 nextGeneration() 用一把全新的 Generation 换掉旧对象并 signalAll() 唤醒所有等待线程;其余线程在循环里靠判断 g != generation 才离开循环返回。被中断、超时或 barrierCommand 抛异常时则走 breakBarrier(),把当前 generation 标记为 broken,让所有等待者抛出 BrokenBarrierException

private int dowait(boolean timed, long nanos)
    throws InterruptedException, BrokenBarrierException,
           TimeoutException {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        final Generation g = generation;

        if (g.broken)
            throw new BrokenBarrierException();

        if (Thread.interrupted()) {
            breakBarrier();
            throw new InterruptedException();
        }

        int index = --count;
        if (index == 0) {  // tripped
            Runnable command = barrierCommand;
            if (command != null) {
                try {
                    command.run();
                } catch (Throwable ex) {
                    breakBarrier();
                    throw ex;
                }
            }
            nextGeneration();
            return 0;
        }

        // loop until tripped, broken, interrupted, or timed out
        for (;;) {
            try {
                if (!timed)
                    trip.await();
                else if (nanos > 0L)
                    nanos = trip.awaitNanos(nanos);
            } catch (InterruptedException ie) {
                if (g == generation && ! g.broken) {
                    breakBarrier();
                    throw ie;
                } else {
                    // We're about to finish waiting even if we had not
                    // been interrupted, so this interrupt is deemed to
                    // "belong" to subsequent execution.
                    Thread.currentThread().interrupt();
                }
            }

            if (g.broken)
                throw new BrokenBarrierException();

            if (g != generation)
                return index;

            if (timed && nanos <= 0L) {
                breakBarrier();
                throw new TimeoutException();
            }
        }
    } finally {
        lock.unlock();
    }
}

看到这里先停一下:为什么 CyclicBarrier 不学 CountDownLatch 直接用 AQS 的共享模式,而要自己拿 ReentrantLock + Condition 重造一个? 带着这个疑问看下面的说明——答案藏在”可复用”三个字里。

注意 CyclicBarrier 底层不是直接继承 AQS 的同步器,而是用 ReentrantLock + Condition 自己实现等待/唤醒,再靠 generation 对象引用是否变化来判断”这一轮是否已经结束”。这与 CountDownLatch 直接用 AQS 共享模式放行不同——Barrier 需要”换一个新世代”来支持复用,所以它绕开了 AQS 的一次性语义。

💭 思考:为什么「判断这一轮是否结束」要用 generation 换一个新对象,而不是把一个布尔值或计数器原地置回?——一步步想:如果复用同一个 generation、只在内部改标志,等待中的线程醒来后无法区分「这是上一轮刚结束」还是「下一轮又开始了」,被中断、超时、broken 的状态会串到下一轮。换成每次 new 一个新对象,让「引用 g != generation」成为天然的一轮结束信号——比较一次引用,比维护一堆标志更简洁、也更不容易串轮。这也正是它叫 Cyclic 的原因:靠不断换新世代来「循环复用」。

可重复:自动重置,所有人都到达栅栏后同时放行进入下一阶段。场景:多线程分阶段并行计算,每阶段结束需要同步。

Semaphore:控制并发数

Semaphore semaphore = new Semaphore(10);  // 最多 10 个线程同时执行

executor.submit(() -> {
    semaphore.acquire();
    try {
        accessLimitedResource();  // 同时最多 10 个线程
    } finally {
        semaphore.release();
    }
});

源码:AQS 共享模式下的许可计数(JDK Semaphore.Sync):

final int nonfairTryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 ||
            compareAndSetState(available, remaining))
            return remaining;
    }
}

protected final boolean tryReleaseShared(int releases) {
    for (;;) {
        int current = getState();
        int next = current + releases;
        if (next < current) // overflow
            throw new Error("Maximum permit count exceeded");
        if (compareAndSetState(current, next))
            return true;
    }
}

acquire() 一行转交给 sync.acquireSharedInterruptibly(1)。Semaphore 的 state 表示”剩余许可数”:nonfairTryAcquireShared 用 CAS 自旋把 state 减掉申请的许可,remaining < 0 表示不够就返回负数,AQS 会把线程挂起排队;tryReleaseShared 则把许可加回去并返回 true 唤醒后继。它的共享语义是”允许多个线程同时持有许可”,而不是像 CountDownLatch 那样等一个临界点一次性放行——同一个 state,在 Latch 里是”闸门开关”,在 Semaphore 里是”资源余量”。

💭 思考:为什么 nonfairTryAcquireShared 不先判断 available < acquires 再决定要不要减,而是直接算 remaining = available - acquires、靠 remaining < 0 来判不够?——因为并发下「先判断、再减」会留出竞态窗口:判断说许可够,等你真要减时别人已经把许可抢走了。这里把「减许可」和「判够不够」合成一次 CAS 自旋——减完的 remaining 是正是负,一次运算同时告诉上层「扣没扣成、还剩几个」。若 remaining < 0,CAS 也没提交,许可并未多扣,AQS 再把线程挂起排队。一步到位,才没有「判断与执行之间被别人插队」的缝隙。

为什么 Semaphore 的许可能还回去、Latch 的计数不能? 因为语义不同:Semaphore 的 state 是”资源余量”,acquire 减、release 加,本来就应该有增有减;Latch 的 state 是”还剩几件事没做完”,方向只会单向递减,做完就归零,没有”加回去”的业务含义。

场景:数据库连接池(同时最多 N 个连接)、API 限流。

三件套对比

CountDownLatchCyclicBarrierSemaphore
核心语义倒计数栅栏等齐许可数
可重置❌ 一次性✅(release 还回许可)
参与方一方等多方多方互相等多方竞争资源
典型场景主线程等子线程多线程阶段同步连接池/限流

章末提问

1. CountDownLatch 和 CyclicBarrier 都能让线程互相等待,本质区别是什么?

结论:等待关系不同——Latch 是”一方等多方”(主等子,子线程之间不互等),Barrier 是”多方互等”(每个参与者都等其他人都到齐)。因为 Latch 的 countDown 和 await 通常发生在不同线程(主子角色分离),而 Barrier 的 await 是同一批线程都调、所有参与者彼此同步,因此它们承载的业务语义天然不同。

2. CountDownLatch 计数到 0 后能复用吗?为什么 CyclicBarrier 能复用?

结论:Latch 不能复用、Barrier 能复用,根因在于有没有”世代”概念。因为 Latch 只用一个 state 单向递减到 0,没有任何机制区分”上一轮”和”下一轮”;Barrier 用 generation 对象,一轮结束就 new 一个新对象并重置 count,靠对象引用变化天然支持新一轮,所以叫 Cyclic。

3. CyclicBarrier 为什么不用 AQS 共享模式,而要自己用 ReentrantLock + Condition 实现?

结论:因为 AQS 的 state 是一次性共享计数,撑不起”可重置”语义。Barrier 需要在一轮到达后”换新世代”并自动重置计数,用 ReentrantLock 保护 count/generation、用 Condition 做等待唤醒、用 generation 引用判断本轮是否结束,比硬套 AQS 更清晰、更贴合可复用的需求。

4. Semaphore 和 CountDownLatch 都用 AQS 的 state,它们的 state 含义有什么不同?

结论:Latch 的 state 是”闸门开关”(只等一个临界点 0),Semaphore 的 state 是”资源余量”(可增可减)。因为 Latch 的 acquire 只在 state==0 时放行、release 只减不加,一次性共享放行;Semaphore 的 acquire 减许可、release 加许可,允许多线程同时持有并动态归还,是持续的共享资源控制。

5. Semaphore 默认非公平,acquire 时许可不够的线程去哪了?如何做到公平?

结论:许可不够的线程被 AQS 挂起并进入同步队列排队,公平性取决于构造时是否传 fair=true。因为非公平版 nonfairTryAcquireShared 每次都先 CAS 抢,后来者可能插队抢到刚归还的许可;公平版会先检查队列里有没有等得更久的线程,有就乖乖排队,从而避免饥饿。


Share this post on:

Previous Post
ForkJoinPool分治并行:工作窃取与双端队列
Next Post
CompletableFuture——异步编程的链式编排