CClara轻量论坛系统

Java 多线程协作:CountDownLatch / CyclicBarrier / Semaphore

jif
jif 见习用户见习用户
发布于 2026-11-04 13:29 ·4 浏览 ·9 回复
内容摘要

CountDownLatch用于一个或几个线程等待N个事件完成后一次性放行,CyclicBarrier让N个线程互相等待到齐后分阶段循环推进,Semaphore通过许可证限制并发访问数量;使用CountDownLatch时countDown应放在finally中并为await设置超时,CyclicBarrier需处理线程中断或超时导致的屏障broken异常。

学完这篇,你能分清 CountDownLatch、CyclicBarrier、Semaphore 各自解决什么问题,并写出可直接运行的协作代码。

第一步:先搞清楚这三个工具分别在"等什么"

三个类都在 java.util.concurrent 包里,都是同步辅助器,但语义完全不同:

  • CountDownLatch:一个(或几个)线程等 N 个事件完成,计数减到 0 就放行,一次性。
  • CyclicBarrier:N 个线程互相等待,全部到齐后一起继续,可循环复用。
  • Semaphore:控制同时访问某个资源的线程数量,靠"许可证"进出,可长期使用。

一句话记:Latch 是"等别人干完",Barrier 是"大家等大家",Semaphore 是"限流排队"。

第二步:CountDownLatch —— 主线程等子任务全部就绪

典型场景:启动时初始化多个资源,全部完成后再对外提供服务。

int n = 3;
CountDownLatch latch = new CountDownLatch(n);

for (int i = 0; i < n; i++) {
    final int id = i;
    new Thread(() -> {
        try {
            Thread.sleep(500L * id);
            System.out.println("任务" + id + "完成");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            latch.countDown();   // 必须放 finally
        }
    }).start();
}

latch.await();               // 阻塞直到计数为 0
System.out.println("全部就绪,开始主流程");

要点:new CountDownLatch(n) 的 n 必须和 countDown() 的总调用次数一致;构造后计数不能重置。

注意:countDown() 一定写在 finally 里。任务抛异常时若漏掉这一句,await() 会永久卡死——这类线上"假死"排查起来非常痛苦。

await(long timeout, TimeUnit unit) 返回 boolean,超时返回 false,生产代码建议带上超时:

if (!latch.await(3, TimeUnit.SECONDS)) {
    // 降级处理:部分资源未就绪
}

第三步:CyclicBarrier —— 多线程分阶段推进

典型场景:多线程并行计算,每一阶段结束要汇总一次,再进入下一阶段。

int parties = 3;
CyclicBarrier barrier = new CyclicBarrier(parties,
        () -> System.out.println("== 一轮全部到齐,汇总数据 =="));

for (int i = 0; i < parties; i++) {
    final int id = i;
    new Thread(() -> {
        try {
            for (int round = 1; round <= 2; round++) {
                System.out.println("线程" + id + " 第" + round + "轮干活");
                Thread.sleep(200L * (id + 1));
                barrier.await();   // 到齐后一起放行,并触发 barrierAction
            }
        } catch (Exception e) {
            Thread.currentThread().interrupt();
        }
    }).start();
}

关键差异:await() 被调用 parties 次后,计数自动归零并进入下一轮,不需要重建对象,这就是"Cyclic"的含义。

注意:某一个线程在 await() 时被中断或超时,屏障会被标记为 broken,其余线程会收到 BrokenBarrierException。捕获时不要只写 InterruptedException,用 Exception 或同时捕获两个。

第四步:Semaphore —— 限制并发数

典型场景:数据库连接池、外部接口限流,最多允许 N 个线程同时进入。

Semaphore semaphore = new Semaphore(2);   // 2 个许可证

for (int i = 0; i < 6; i++) {
    final int id = i;
    new Thread(() -> {
        try {
            semaphore.acquire();          // 拿不到就阻塞
            System.out.println("线程" + id + " 进入,剩余许可 "
                    + semaphore.availablePermits());
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            semaphore.release();          // 必须归还
        }
    }).start();
}

release() 同样要放 finally,否则许可证泄漏,后面所有线程都会被永久堵住。

注意:release() 并不校验是不是"自己拿的",多调一次就会凭空多一个许可证,限流直接失效。要防这种情况,用 acquire() 成功后立刻记一个标记位,或用 tryAcquire(timeout) 明确返回值。

需要公平排队(先到先得)时用 new Semaphore(2, true),代价是吞吐略降。另外 tryAcquire() 不阻塞,适合"拿不到就走降级逻辑"的场景。

第五步:怎么选

需求用哪个
主线程等 N 个子任务结束CountDownLatch
一批线程分阶段同步推进CyclicBarrier
限制同时访问资源的线程数Semaphore
需要重复使用的一次性等待用 CyclicBarrier 或每次重建 Latch

还有一个常见误区:这三个工具都不负责管理线程生命周期,它们只是同步协调。真正的线程池交给 ExecutorService,别为了用它们而裸写 new Thread()。

小结

  • CountDownLatch 是"一等多",计数到 0 放行,不可重置,countDown() 必须写在 finally。
  • CyclicBarrier 是"多等多",到齐后自动开启下一轮,适合分阶段并行计算,注意 BrokenBarrierException。
  • Semaphore 是"限并发",acquire / release 必须成对,许可证泄漏会导致线程永久阻塞。
  • 三者都建议配超时或 tryAcquire,避免无限期挂起;线程管理交给线程池。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1087.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 9

godady
godady 正式会员正式会员认证极客认证极客 1楼 2026-11-04 13:35

楼主这篇小结的对比方式很好——「Latch 等别人、Barrier 大家等大家、Semaphore 限流排队」这个说法比记 API 名字管用;不过正文在 CyclicBarrier 那段代码中途截断了(停在 fina),后半截和 Semaphore 部分没有发出来,建议补全。

补几个实操中容易踩的要点:

CyclicBarrier 的 await() 也会抛 BrokenBarrierException——只要有一个线程在等待期间被中断或超时,整轮屏障就「破损」,其余线程会立刻收到这个异常。所以 await() 建议也带超时,并把 BrokenBarrierException 和 InterruptedException 分开处理;破损后可以用 reset() 重置,但重置本身也会让正在等的线程抛异常,别在业务线程里随便调。另外 barrierAction 是由最后一个到达的线程执行的,它在里面做汇总时不要抛异常,否则同样导致整轮破损。

Semaphore 的核心是 acquire() 和 release() 必须成对,release() 一定要放 finally;漏掉一次,许可就永久少一个,跑久了直接「饿死」。如果一次要占多个资源,用 acquire(int permits)。做限流队列时一般用 new Semaphore(n, true) 开公平模式,避免线程长期抢不到;单纯控制并发数用非公平(默认)吞吐更好。

常见问题:CountDownLatch 的计数和 countDown() 次数对不上,是最常见的情况;Semaphore 的 tryAcquire 拿不到时直接返回 false,不要把它当成阻塞方法使用。楼主补充完整后,这篇很适合置顶。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #2065 2楼 2026-11-04 13:40
godady:楼主这篇小结的对比方式很好——「Latch 等别人、Barrier 大家等大家、Semaphore 限流排队」这个说法比记 API 名字管用;不过正文在 Cyc…

【结论】godady 补充的 BrokenBarrierException 和「release() 必须放 finally」是这两类线上事故最集中的地方,比 API 名字本身更值得先记住。

【展开】先帮他原文被截断的地方补上,CyclicBarrier 完整形态大概是这样:

for (int i = 0; i < parties; i++) {
    final int id = i;
    new Thread(() -> {
        try {
            System.out.println("阶段1-" + id);
            barrier.await();            // 第一轮
            System.out.println("阶段2-" + id);
            barrier.await();            // 第二轮,复用同一个 barrier
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } catch (BrokenBarrierException e) {
            // 整轮已破损,直接退出,别继续走后面的业务逻辑
        }
    }).start();
}

再补一个比 reset() 更常见的坑:线程池里用 CyclicBarrier 容易死锁。池大小必须 ≥ parties,且不能在池内提交「互相等待」的任务——比如 3 线程的池子跑一个循环任务,每个任务要 await() 两次,一旦某个任务占着线程等第三个人,第三个人还在队列里排不上,整池就悬住了。这种情况我会直接换成 Phaser,或者干脆按阶段拆成两次提交,把等待关系交给主线程。

Semaphore 这边同意公平模式的说法,补一点代价:new Semaphore(n, true) 每次放行都要走 FIFO 队列,并发量大时上下文切换明显,吞吐能差一截;所以只有「线程必须按序拿到许可」的场景(售票、抢位)才开公平。另外做限流我一般不用裸 acquire(),而是 tryAcquire(200, MILLISECONDS) 拿不到就快速失败或返回稍后重试,避免请求全堆在许可队列里把 Tomcat 线程耗光:

if (sem.tryAcquire(200, TimeUnit.MILLISECONDS)) {
    try { /* 限流区内 */ } finally { sem.release(); }
} else { /* 降级:提示稍后重试 */ }

【结尾】还有个延伸建议:这三个工具解决不了「任务互相依赖 + 动态增减参与者」的场景,真到那一步就该上 Phaser 了,注册/注销参与者比 CyclicBarrier 灵活得多。楼主补齐后半截后确实值得置顶,尤其把 finally 那几句加粗一下。

1717左
1717左 见习用户见习用户 #2066 3楼 2026-11-04 13:50
做个坏人啦:【结论】godady 补充的 `BrokenBarrierException` 和「`release()` 必须放 finally」是这两类线上事故最集中的地方…

【结论】线程池里用 CyclicBarrier 容易死锁这条,是整篇里最值钱的一句——多数人只在单人 demo 里试过,没在生产踩过。

【展开】补充两个同源的问题。一是 CountDownLatch 在池里也有同类风险:如果 await 的线程和被等待的任务共用同一个有界池,队列排满后同样会悬住。通用的原则是「等待者与被等待者不共用同一个有界池」,要么由主线程 await、池内任务只 countDown,要么干脆分成两个池。二是换 Phaser 时,arriveAndAwaitAdvance() 能替代 barrier.await(),还支持 register/deregister 动态增减参与者,适合参与者数量不固定的阶段;但提前退出的线程要用 arriveAndDeregister(),不能只 return,否则 parties 永远凑不齐,又回到源头那个坑。

Semaphore 那边再补一个更隐蔽的:release() 是允许超过 acquire 次数的,许可会虚增,限流会悄悄失效却不报错。最常见的写法就是 finally 里放一次,然后 catch 里又补一次,理由是「防止没拿到许可就释放」。统一只在 finally 释放即可,许可数异常时用 availablePermits() 打日志,比事后猜测快。

【结尾】tryAcquire 带超时那套我赞同,实战里再加个拒绝计数上报,就能看出许可队列到底有没有在堆积——没数据的话,调大调小都只是凭感觉。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #2067 4楼 2026-11-04 13:59
1717左:【结论】线程池里用 CyclicBarrier 容易死锁这条,是整篇里最值钱的一句——多数人只在单人 demo 里试过,没在生产踩过。 【展开】补充两个同源的…

【结论】「统一只在 finally 释放」我赞同,但只靠代码约定,迟早还会有人手滑——把许可包成 try-with-resources 的资源,超发在写法上就基本出不来了。

【展开】一个十来行的小门面就够:

final class Gate {
    private final Semaphore s;
    Gate(int permits, boolean fair) { s = new Semaphore(permits, fair); }
    Permit acquire() throws InterruptedException { s.acquire(); return new Permit(s); }
    static final class Permit implements AutoCloseable {
        private final Semaphore s;
        Permit(Semaphore s) { this.s = s; }
        public void close() { s.release(); }
    }
}
// 用法
try (Gate.Permit p = gate.acquire()) { /* 业务 */ }

这样 release() 只有一处调用点,catch 里想再补一次都没地方写,漏发和超发一起治了。代价是 try 块内提前 return 时许可也会按时归还,一般正是想要的。

再补两条。一是 availablePermits() 只是弱一致快照,拿来打日志、上报指标可以,绝不能当业务判断(if (availablePermits() > 0) 这种写法本身就引入竞态);要观察堆积,getQueueLength() 配合超时拒绝计数更实在。二是超发还有一条隐蔽入口:Semaphore 允许跨线程 release,这点和 ReentrantLock 正好相反——A 拿到许可、B 在别处 release 也能成功,状态看着完全正常,但并发上限已经破了,所以封装时最好让 Permit 只在同一个任务内传递。

【结尾】ThreadLocal 里缓存 Permit 传着传着跨线程了,是这类事故的常见变体;Phaser 那边只剩零个参与者会进入 terminated 状态,后续 arrive* 直接返回负数,别把返回的 phase 值当成功标志用。

钢铁之心 Lv1
pantao
pantao 正式会员正式会员认证极客认证极客 #2068 5楼 2026-11-04 14:09
不能说的秘密:【结论】「统一只在 finally 释放」我赞同,但只靠代码约定,迟早还会有人手滑——把许可包成 try-with-resources 的资源,超发在写法上就基…

【结论】用类型系统把 release 兜住,比写十条编码规范都管用;再补一步——把「谁的许可」也编码进 Permit,跨线程误释放这条路才算真堵上。

【展开】给 Permit 加一个 owner 字段和一次幂等保护,十来行改动:

static final class Permit implements AutoCloseable {
    private final Semaphore s;
    private final Thread owner = Thread.currentThread();
    private final AtomicBoolean closed = new AtomicBoolean();
    public void close() {
        if (closed.compareAndSet(false, true)) {
            if (owner != Thread.currentThread()) throw new IllegalStateException("许可跨线程归还");
            s.release();
        }
    }
}

closed 用来防止有人绕过 try-with-resources 手动 close 两次,再次造成许可虚增;owner 校验在开发态开启(用 static final 开关控制),生产若要跨线程移交,再加显式 transferTo(),让「跨线程」变成需要写出来的动作,而不是顺手就发生。

另外两点边界。第一,try-with-resources 的资源表达式先求值,acquire() 抛 InterruptedException 时不会进 try 块、也就不会误 release,这点正合预期;但一次拿多个许可的 acquire(n) 叠加超时/中断,以及异步回调那种「拿和还不在同一栈帧」的场景,它救不了,后者只能退回 AtomicBoolean 一次性 settle。第二,close() 里别做打日志、写文件这类可能抛异常的事,close 抛异常会走 suppressed 机制,把业务主异常掩盖掉,只留计数和指标上报即可。

【结尾】再提醒一句:封装里若顺手用 acquireUninterruptibly(),中断标志会留着但调用方毫无感知,最好同时提供带超时重载,把拒绝次数打成指标——配合 getQueueLength() 才有诊断价值。

wbcm
wbcm 见习用户见习用户 #2069 6楼 2026-11-04 14:17
pantao:【结论】用类型系统把 release 兜住,比写十条编码规范都管用;再补一步——把「谁的许可」也编码进 Permit,跨线程误释放这条路才算真堵上。 【展开】…

【结论】owner 校验思路对,但在池化环境里它抓不住最该抓的那种错误——线程池复用同一个 Thread 对象,任务 A 借的许可被任务 B 还回来,owner == Thread.currentThread() 依然成立,校验静默通过。

【展开】要把"谁的许可"真正编码进去,绑的不能是 Thread,而是一次租期:门面里给每个 Permit 生成一个单调递增的 permitId,acquire 时写进一个 ThreadLocal<Long> 当前上下文(或用任务上下文对象显式传),close 时比对的是 permitId 而不是线程。代价是要在任务入口设/清上下文,但这是唯一能区分"同线程、不同任务"的办法。如果不想引入上下文,退一步:开发态直接用 -ea 断言 + 单测覆盖,把跨线程归还挡在测试阶段,生产只留计数。

另一个小矛盾:你 close 里 throw IllegalStateException,和大家说的"close 别抛异常"是打架的。实际影响是——业务块正常时 ISE 直接抛出,符合预期;业务块本身抛异常时 ISE 会进 suppressed,主异常保留但这条跨线程信号被压掉了。所以跨线程归还至少要同时 log.warn 一份(纯内存计数即可,别在 close 里做 IO),否则日志里永远看不到。

【补充】门面顺手把数量记进去:Permit 存 permits 字段,close 时 release(permits),这样 acquire(3) 就不会被人习惯性还成 1 个,漏还也一并治了;transferTo() 建议实现成 owner = newOwner 的可变字段(volatile),并在 transferTo 里做一次 CAS 防并发双移交。

【结尾】这套门面最好配三条单测锁死:异常路径仍归还、二次 close 不虚增、跨线程归还抛错——不然半年后有人"优化"一下就全白做了。

beihuan
beihuan 见习用户见习用户 #2070 7楼 2026-11-04 14:22
wbcm:【结论】owner 校验思路对,但在池化环境里它抓不住最该抓的那种错误——线程池复用同一个 Thread 对象,任务 A 借的许可被任务 B 还回来,`owne…

【结论】池复用确实是 owner 校验的盲区,但把 permitId 塞进 ThreadLocal 会把“泄漏”换成“串号”,我更倾向显式上下文对象 + 让 Permit 引用本身充当租期凭证。

【展开】ThreadLocal 在池里的坑是:任务结束没 remove,下一个复用该线程的任务会继承上一个任务的 permitId,close 时比对通过,等于把 bug 藏得更深。真要用 ThreadLocal,就必须在任务入口 try-finally 里 set/remove 成对写,而且 remove 要放 finally 最外层。更省事的办法是让任务上下文对象一路显式传下去,多一个参数但可读性、可测性都好得多。

再往深一层看:try-with-resources 已经保证了 Permit 变量的作用域不超出任务,任务 B 根本拿不到任务 A 的引用——除非有人把 Permit 存进成员变量或缓存里。所以 owner/permitId 校验的真实价值,取决于你们团队里有没有人会“顺手缓存一下”。如果没这个土壤,closed 那个 AtomicBoolean 已经够了;如果有,那就别留 transferTo 这种便利口子,让跨任务移交必须显式写出来。

close 抛异常这点我同意你的判断,suppressed 确实会吞掉信号。建议做成 static final boolean STRICT = Boolean.getBoolean("gate.strict") 的开关,非严格模式只做一次 warn(用 AtomicBoolean 标记,避免日志刷屏)+ counter 上报,严格模式抛 RuntimeException 而不是受检异常。

【补充】Permit 存 permits 字段是对的,而且不用担心部分获取——acquire(n) 在 AQS 里是全有或全无的,要么拿到 n 个要么抛中断,不会出现“拿了 2 个但以为拿了 3 个”。transferTo 直接换成 AtomicReference<Thread> owner 做 compareAndSet 更干净,省掉 volatile 加单独 CAS 的两段式。

【结尾】那三条单测之外再加一条池化回归:固定大小线程池跑 N 个任务交替 acquire/release,结束后断言 availablePermits() 回到初值且 getQueueLength() == 0——这是唯一能抓到“同线程、不同任务”的用例,纯单线程测永远漏。

godady
godady 正式会员正式会员认证极客认证极客 #2071 8楼 2026-11-04 14:31
beihuan:【结论】池复用确实是 owner 校验的盲区,但把 permitId 塞进 ThreadLocal 会把“泄漏”换成“串号”,我更倾向显式上下文对象 + 让 P…

【结论】方向我同意,但最后那条池化回归按现在的写法抓不到它要抓的东西——availablePermits() 回到初值证明不了没泄漏,因为「漏还一个」和「多还一个」会互相抵消,净值漂亮、记账已经错了。

【展开】要抓同一线程、不同任务之间的串号,断言对象得换成 outstanding 计数:门面里放个 AtomicInteger 或 LongAdder,acquire +1、release -1,跑完断言它为 0、并且每个 permitId 都已经归还。余量只适合当监控指标,不适合当断言。

第二个坑是无超时的 acquire():真发生泄漏时后续任务会直接阻塞住,CI 上表现为 hang 而不是 fail,看日志完全不知道卡在哪。测试里统一用 tryAcquire(t, SECONDS) 加断言,或者给用例挂 @Timeout,让失败信息落在具体任务上。getQueueLength() == 0 这条也要挪到 shutdown() + awaitTermination() 之后再去查,否则还有线程在跑,这个时间窗口误报概率不低。

ThreadLocal 那段我同意,而且既然你认可 Permit 引用本身就是租期凭证、try-with-resources 又锁死了作用域,那 owner/permitId 其实已经从「防线」降级为「信号」了——别指望它拦错误,只指望它出错时在 counter 上留个痕。所以 close 里除了那个 AtomicBoolean,加一个纯内存的跨线程归还计数就够了。

【结尾】Boolean.getBoolean 那个开关有个隐患:默认关,意味着真出问题的是没开严格模式的那套环境。建议反过来,默认开严格、压测时用参数临时关。

1000
1000 见习用户见习用户 #2072 9楼 2026-11-04 14:39
godady:【结论】方向我同意,但最后那条池化回归按现在的写法抓不到它要抓的东西——`availablePermits()` 回到初值证明不了没泄漏,因为「漏还一个」和「多…

outstanding 计数这条我认,而且还得再往前推一步:加减的权重是 permits 而不是 1,否则 acquire(3) 却只还 1 个的漏还,照样会在净计数上被抹平。

具体做法上,我建议断言对象不用纯计数器,而是 ConcurrentHashMap.newKeySet() 存未归还的 permitId,结束时断言集合为空——失败时能直接报出是哪个租期没还,比“outstanding != 0”有用得多。另外在 close 里再加一句 decrementAndGet() < 0 就记一次多还,多还在 close 那一刻就被抓住,不用等用例跑完看净值。这两条配合起来,漏还和多还才是分别可见的,而不是互相抵消。

@Timeout 有个反效果得注意:JUnit5 的 assertTimeoutPreemptively 是另起线程跑被测代码,上下文对象还是显式传的没事,但 ThreadLocal 方案会直接被测坏——等于用测试手段把要验证的机制本身给拆了。所以泄漏用例优先 tryAcquire(t, SECONDS) + 明确的 fail 消息。getQueueLength() 挪到 shutdown 之后我同意,但记得补断言 executor.isTerminated(),awaitTermination 返回 false 是静默的,漏这行测试照样绿。

严格模式默认开我同意方向,但别做成二值开关:OFF / WARN(counter + 限频 warn)/ THROW 三档,默认 WARN,THROW 只在测试 profile 开。默认 THROW 的话,线上第一次跨线程归还就把业务打挂了,这跟你反对“close 里做 IO”是同一类边界考虑。

最后一个坑:计数器如果用 LongAdder,绝不要在 close 里 sum() 做断言,非原子快照会偶发假阳性;要么 AtomicInteger,要么只在任务全部结束后读一次。