CountDownLatch与CyclicBarrier:用法、原理与踩坑指南

发布时间:2026/10/8 16:04:37
CountDownLatch与CyclicBarrier:用法、原理与踩坑指南 CountDownLatch与CyclicBarrier可以说是Java并发编程里线程协作的双子星。前两天我帮同事排查一个线上问题一批任务明明都执行完了主线程却一直卡在CountDownLatch上最终结果迟迟没有汇总。查到最后才发现有个子线程在业务代码里抛了异常根本没走到countDown()计数器刚好差了一个没归零。这种问题代码层面极难复现但一旦出现就是线上事故级别的卡顿。所以我觉得这两个工具虽然API简单真正用对、用活的人并不多尤其是它们背后那套“等待-通知”协作模型很多同学理解得还是比较模糊。这篇文章我会把CountDownLatch和CyclicBarrier的用法、原理、坑点、选型一次性讲透。无论你是正在学JUC的Java开发者还是在准备面试、排查线上并发问题都应该能从里面找到自己能直接用的东西。没有“高大上”的术语堆砌只有代码、案例和踩坑经验。1. 线程协作到底在解决什么问题1.1 从 wait/notify 到 JUC 协作工具差在哪儿多线程不是各跑各的很多场景需要线程之间“等我一下”或者“大家到齐了再干活”。比如主线程要等几个子线程把数据都准备好再统一汇总又比如一批工作线程要等彼此都完成当前阶段再一起进入下一阶段。这种“等待-通知”语义Java从一开始就给了最原始的实现synchronized配合wait/notifyAll。但用wait/notifyAll写协作逻辑有多痛苦写过的人都懂。你不仅要手动管理锁对象还要小心wait被意外唤醒、通知丢失、锁释放时机错误等问题。比如在主线程里写了while (count ! n) wait();一旦某个子线程忘记notifyAll或者提前返回整个程序就僵住了。这种代码调试起来非常折腾定位问题全靠猜。JUC包里的协作工具本质上是把这套“等待-通知”逻辑做成了通用组件。你不再需要关心锁条件、等待队列、唤醒传播这些细节只要告诉工具“你要等几个信号”或者“大家约好在哪个点集合”剩下的由它内部帮你搞定。这种抽象让协作代码的编写和维护成本大幅降低也让并发的正确性更有保障。1.2 CountDownLatch 和 CyclicBarrier 的第一印象CountDownLatch和CyclicBarrier经常被放在一起讲因为它们都涉及“多个线程到达某个条件后再继续”但给人的第一印象其实完全不同。CountDownLatch像一个“倒数计时的门闩”。你告诉它一共有几个任务要完成每个任务完成时countDown()一下计数减一其他线程在await()那里等着计数归零时门闩打开等待线程继续往下走。它关心的是“事件有没有凑够”并不关心这些任务是哪个线程完成的。CyclicBarrier像一个“集合点”。它要求N个线程都必须到达同一个屏障处任何一个线程都不能提前走。最后一个到达的人会把大家唤醒然后所有人一起继续。它关心的是“参与方是否到齐”而且这个屏障可以循环使用下一轮到齐后再次放行。看起来都是计数等待但一个是被动等待别人完成一个是主动参与互相等待。这个区别是后续所有场景和选型的出发点。2. CountDownLatch 实战等 N 个任务完成别忘掉 finally2.1 一个能直接跑的核心示例CountDownLatch的用法很清晰核心只有三个方法构造时传入计数countDown()减一await()阻塞等待计数归零。下面这个例子是标准的“主线程等三个子任务执行完”的场景import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int taskCount 3; CountDownLatch latch new CountDownLatch(taskCount); ExecutorService pool Executors.newFixedThreadPool(taskCount); for (int i 0; i taskCount; i) { pool.execute(() - { try { Thread.sleep(ThreadLocalRandom.current().nextInt(1000)); System.out.println(Thread.currentThread().getName() 执行完成); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } latch.await(); System.out.println(所有任务执行完成主线程继续); pool.shutdown(); } }这里最关键的就是finally里的latch.countDown()。为什么必须放在finally因为线程执行过程中可能遇到运行时异常也可能被外部中断。只要走到finally这个计数就一定被扣掉。如果因为某个偶发异常导致计数没扣主线程的await()就会永久阻塞整个应用可能就“假死”了。2.2 countDown 和 await 的底层原理计数器不止是计数器CountDownLatch内部用了一个继承AQSAbstractQueuedSynchronizer的同步器Sync计数器的值就存在AQS的state里。await()会调用acquireSharedInterruptibly当state不为0时当前线程就进入等待队列挂起countDown()会调用releaseShared(1)通过CAS把state减1直到减为0时触发释放把所有等待的线程唤醒。所以这个“计数器”不是简单的volatile int加判断而是“原子递减 等待队列 唤醒传播”的组合。多线程同时countDown()时光靠volatile会出现原子性问题必须用CAS保证递减过程安全。你可以理解成很多人同时对账本上的同一个数字做减一操作如果没有CAS最后减出来的结果一定是不对的。AQS在这里就是那个“靠谱的账本管理员”。这里也解释了CountDownLatch的一个重要特性它是一次性的。计数一旦归零不能再重置。你再调用await()会直接返回调用countDown()也不会把数字扣成负数因为AQS发现state 0时会直接返回false不做任何事。如果你想循环使用一个“集合点” CountDownLatch做不到那是CyclicBarrier的主场。2.3 两个高频场景汇总等待和并发起跑场景一主线程等待多个子系统初始化完成后再对外提供服务。比如一个中间件进程启动时要加载配置、连接数据库、预热缓存。这三件事可以并行做但必须全部完成才能接收请求。这时候用一个CountDownLatch(3)每个初始化任务完成后countDown()主流程await()等齐后发布“服务可用”状态。这个模式在测试工具、批处理系统里非常常见。场景二压测时让N个线程同时发起请求。如果你做性能测试希望并发请求尽量在同一瞬间打出去可以用CountDownLatch(1)做“发令枪”。每个请求线程先执行ready.countDown()告诉主线程“我已准备好”然后start.await()等待发令所有线程都准备好后主线程执行start.countDown()所有请求线程同时被唤醒开始压测。代码大概是这样int n 10; CountDownLatch ready new CountDownLatch(n); CountDownLatch start new CountDownLatch(1); for (int i 0; i n; i) { new Thread(() - { ready.countDown(); try { start.await(); // 这里统一发起请求 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } ready.await(); start.countDown();这种“两个CountDownLatch组合”的玩法在压测场景里很经典它能让线程的启动误差缩小到毫秒级比手动sleep然后祈祷“差不多同时”要靠谱得多。2.4 最容易翻车的地方计数器没归零CountDownLatch最常见的线上事故就是“差一个没数完”。除了忘记在finally里countDown还有一种情况是任务线程被线程池拒绝执行了压根没跑起来但主线程构造latch时已经按预期任务数设了计数。比如打算提交10个任务但线程池队列满了、拒绝策略把第5个任务拒了这个任务所在的execute()直接抛异常对应的countDown()永远不会执行最终主线程只等到9个信号。所以在设计阶段就要明确CountDownLatch的计数一定要和实际执行的信号数量严格对齐。任务可能提交失败那就要在提交处捕获异常及时补一次countDown()或者干脆用invokeAll这类批量执行API从源头避免人为计数。另外一定要给await()加超时兜底。比如latch.await(10, TimeUnit.SECONDS)返回false说明超时了这时候可以打印日志、检查latch.getCount()至少能让程序快速失败而不是无限期挂起。我的经验是分布式系统里任何依赖外部资源的等待都不能裸等。3. CyclicBarrier 实战全员到齐再出发还能自动重置3.1 核心 API 和一版带循环使用的示例CyclicBarrier的构造方法有两种new CyclicBarrier(int parties)和new CyclicBarrier(int parties, Runnable barrierAction)。后者在所有线程到达屏障后由“最后一个到达的线程”去执行barrierAction然后才打开屏障放行。看个带循环使用的例子。假设有4个玩家组队闯关每一关必须全员到齐才能一起进入下一关一共跑两圈import java.util.Random; import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CyclicBarrier; public class CyclicBarrierDemo { public static void main(String[] args) { int players 4; CyclicBarrier barrier new CyclicBarrier(players, () - System.out.println(当前关卡全员到齐一起进入下一关)); for (int i 0; i players * 2; i) { int playerId i % players; new Thread(() - { try { Thread.sleep(new Random().nextInt(1000)); System.out.println(玩家 playerId 通过第一关); barrier.await(); Thread.sleep(new Random().nextInt(1000)); System.out.println(玩家 playerId 通过第二关); barrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } }).start(); } } }注意这里创建了8个线程但parties 4。为什么会触发两轮因为同一时刻只有4个玩家在等屏障剩下4个玩家其实也是“玩家0到玩家3”各两个线程但每个线程都执行两段await。当第一轮的4个玩家都到达第一个屏障时触发一次barrierAction紧接着第二轮4个线程也会在第一个屏障处集合再触发一次。这个示例想表达的是CyclicBarrier每一轮结束后会自动重置可以反复使用。3.2 谁在最后执行 barrierAction这里有个隐藏行为很多同学会误以为barrierAction是在一个独立线程里执行的其实不是。它是在“最后一个到达屏障的线程”的await()调用里同步执行的。也就是说哪个线程最后到达哪个线程就要额外负责跑一遍barrierAction。这意味着两件事。第一如果barrierAction非常耗时最后一个线程会被拖很久而其他已经到达的线程也不会提前被唤醒只能继续等着。所以barrierAction尽量轻量只做状态标记、日志、简单的通知不要在它里面去做重量级计算或者远程调用。第二如果barrierAction内部抛出了异常这个异常会直接破坏屏障导致其他所有等待线程收到BrokenBarrierException。你原本只是想“做点收尾工作”结果把整队人都炸飞了。这里补充一个细节await()是有返回值的返回的是当前线程在本轮到达屏障时的序号。比如parties 4第一个到达的线程返回3第二个返回2最后一个返回0。返回值为0的线程就是执行barrierAction的那一个。如果业务上只需要让某一方做汇总可以通过判断返回值是否为0来实现。3.3 BrokenBarrierException屏障损坏机制别只 catch 完就完事CyclicBarrier有一个CountDownLatch没有的“破窗机制”如果某个正在等待的线程被中断或者等待超时或者barrierAction抛异常屏障就会进入broken状态。一旦屏障broken其他所有正在等待的线程都会结束等待并抛出一个BrokenBarrierException。这个机制初看很突兀其实是对的。它避免了“一个人掉队全队永远等下去”的极端情况。比如某个线程在屏障等的时候被中断了如果屏障不broken其他线程根本感知不到只好继续干等。现在大家统一收到异常至少能做到“快速失败”由上层决定是重试还是退出。实际项目中处理BrokenBarrierException不能只是打印个日志就完事。你需要判断当前屏障是否还能继续用调用barrier.isBroken()看看状态。如果已经broken通常要么调用reset()重建一个新的代数要么干脆新new一个CyclicBarrier继续下一轮任务。我个人更倾向于重新构建因为reset()在执行的过程中如果有线程还在旧屏障上等待这些线程会被直接打断处理不好又是一轮新的异常。4. 实操案例用 CountDownLatch CyclicBarrier 搭一个多阶段协作任务4.1 从“组队闯关”到真实业务场景刚才的闯关案例业务上太玩具了我们换个更有工程感的场景模拟一个多线程数据同步任务。有10个工作线程每个线程负责同步一批数据。要求所有线程先同时开始然后各自完成第一阶段的拉取等10个线程全部完成第一阶段才允许进入第二阶段的清洗同样等所有人完成第二阶段才能进入第三阶段的落库。这个需求如果用CountDownLatch硬做会非常别扭因为阶段数是动态的CountDownLatch不能复用如果只用CyclicBarrier做“同时开始”又很难控制“准备完毕再发令”。最自然的做法就是让这对双子星分工协作CountDownLatch负责开局放行CyclicBarrier负责每一阶段的集合。4.2 完整代码与执行过程拆解import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; public class StageSyncDemo { private static final int WORKERS 10; public static void main(String[] args) throws InterruptedException { CountDownLatch startGate new CountDownLatch(1); CyclicBarrier phaseBarrier new CyclicBarrier(WORKERS, () - System.out.println(阶段同步点全员已到齐进入下一阶段)); ExecutorService pool Executors.newFixedThreadPool(WORKERS); for (int i 0; i WORKERS; i) { final int workerId i 1; pool.execute(() - { try { startGate.await(); System.out.println(Worker workerId 开始阶段1拉取数据); Thread.sleep(ThreadLocalRandom.current().nextLong(500, 1500)); System.out.println(Worker workerId 完成阶段1); phaseBarrier.await(); System.out.println(Worker workerId 开始阶段2清洗数据); Thread.sleep(ThreadLocalRandom.current().nextLong(500, 1500)); System.out.println(Worker workerId 完成阶段2); phaseBarrier.await(); System.out.println(Worker workerId 开始阶段3落库); Thread.sleep(ThreadLocalRandom.current().nextLong(300, 1000)); System.out.println(Worker workerId 阶段3完成); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } catch (BrokenBarrierException e) { System.err.println(Worker workerId 发现屏障已损坏); } }); } Thread.sleep(1000); System.out.println(所有Worker准备完毕统一开始); startGate.countDown(); pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); System.out.println(全部阶段执行结束); } }这段代码里每个Worker线程都会先卡在startGate.await()上等主线程一声令下。主线程调用startGate.countDown()后10个Worker几乎是同一时刻醒过来开始各自的阶段任务。到每个阶段结束时所有Worker都调用phaseBarrier.await()当第10个Worker到达后屏障触发打印“全员已到齐”然后所有人一起进入下一阶段。注意这里我用了pool.awaitTermination(30, TimeUnit.SECONDS)等待线程池结束避免主线程提前退出。实际项目中你还会遇到一个Worker在某个阶段失败退出其他Worker就会收到BrokenBarrierException。这时候要根据业务决定是终止整批任务还是把这个Worker标记为失败继续推进。4.3 为什么说这个组合是“双向奔赴”在这个案例里CountDownLatch和CyclicBarrier形成了一个很好的互补CountDownLatch是“单向门闩”用于统一放行CyclicBarrier是“双向屏障”用于阶段同步。前者只管一次后者能循环使用。这种组合的价值在于职责清晰。如果只用CountDownLatch你每到一个阶段都要新建一个latch代码会变得非常臃肿而且阶段越多计数管理越容易出错。如果只用CyclicBarrier你很难优雅地实现“所有线程准备完毕后统一开始”因为CyclicBarrier的设计前提是所有参与方都主动到达屏障主线程如果不参与就多了一个不一致的角色。所以遇到“多阶段协作”需求我的默认方案就是“一个CountDownLatch管开始一个CyclicBarrier管每一个阶段”。但如果你的阶段数量是动态变化的、参与线程还会中途增减CyclicBarrier就不够灵活了这时候应该去看Phaser。5. 常见问题速查死等、错等、等不齐5.1 CountDownLatch 永久阻塞怎么快速定位线上遇到“主线程一直卡在某个await上”不要急着重启。先拿jstack看一下线程栈如果看到CountDownLatch$Sync.acquireSharedInterruptibly基本可以确定是计数没归零。接着看日志里有没有打印过latch.getCount()如果一直停在某个数字就说明有几个信号永远没发出来。另一个思路是反向找该countDown()的线程。重点检查业务代码里有没有提前return、有没有抛出RuntimeException、有没有把countDown()放在了try块的最后一行而不是finally。从我排查过的案例来看90%都是这个问题某段业务代码抛了异常后线程直接终止countDown被跳过了。所以规范真的很重要。我给团队定的规矩是CountDownLatch的countDown()必须放在finally块里而且变量命名要带上业务含义比如latchForConfigLoad。否则jstack出来只有一堆Sync.acquireSharedInterruptibly你想定位是哪个业务都难。5.2 CyclicBarrier 被打断后是 reset 还是重建收到BrokenBarrierException后第一件事是判断要不要继续跑。如果业务允许重新来一轮可以调用reset()重置屏障但要注意reset()本身也可能引发新的异常。假设A线程发现屏障broken后调用reset()此时B线程还在旧代际的等待队列里没有返回B会被异常打断整个流程就更加混乱。所以我更推荐“重建替代重置”。如果你要开启新一轮协作直接new CyclicBarrier(parties, action)创建一个新的屏障旧屏障让它自然废弃。虽然多了一个对象创建但状态迁移要清晰得多。另外每次使用await(timeout, unit)可以防止单个线程卡死拖垮全队超时后收到TimeoutException你可以在上层决定是重试还是中断任务。5.3 parties 和线程数对不上如何兜底这是另一个高频事故CyclicBarrier设置了parties 5结果因为分页大小变化实际只有4个线程在执行于是所有线程永远等在屏障处。CountDownLatch也一样初始计数10结果只有9个任务被提交await永远不返回。预防方法有三个。第一parties不要写死尽量通过提交任务的数量动态计算第二给await()加超时超时后检查isBroken()并打印参与线程数这样至少能快速发现问题第三在线程进入屏障前后打日志时刻掌握“已到达数量/期望数量”。说白了这类问题都是“协作双方对不上暗号”导致的日志和超时是最后的救命稻草。5.4 两个工具在面试题里的常见变体面试里最常问的是“CountDownLatch和CyclicBarrier的区别”但最近很多面试官会加一句“如果让你等一个线程池里的任务全部结束你会选哪个”。这里有个陷阱线程池本身就有awaitTermination和invokeAll根本不需要手动用CountDownLatch去等任务结束除非你要等待的是业务上的某个完成信号。所以别一听到“等待”就用latch先想清楚工具API本身是否已经有这个能力。还有一个变体是“多个线程同时开始怎么实现”。标准答案是用CountDownLatch(1)当发令枪。如果面试官追问“CyclicBarrier行不行”可以回答“也可以但需要一个额外线程也持有barrier并参与await这样主线程无法直接只放行一次逻辑不如CountDownLatch简洁”。这样回答既能体现原理理解又能体现工程判断。6. 选型建议什么时候用 CountDownLatch什么时候用 CyclicBarrier6.1 一张表看清核心差异维度CountDownLatchCyclicBarrier协作语义等待N个事件计数完成N个线程互相等待到齐参与角色等待方不参与计数可以是主线程所有线程都参与等待复用能力一次性不能重置可循环使用自动重置到达后的动作无内置动作支持传入barrierAction核心异常await可能阻塞无Broken概念BrokenBarrierException需处理底层实现基于AQS共享锁基于ReentrantLock Condition典型场景服务启动等待、压测发令、汇总等待多阶段任务同步、迭代计算这张表基本能覆盖日常90%的选型判断。你不需要背只要记住一句话等别人完成任务用CountDownLatch大家互相等齐再走用CyclicBarrier。6.2 和 Phaser、Semaphore 的边界在哪里有些同学会把Semaphore也拉进来对比其实Semaphore解决的是“同时允许多少个线程进入临界区”是流量控制不是协作等待。你用Semaphore是可以模拟latch的部分功能但语义不对代码可读性会很差。Phaser则是CyclicBarrier的“进阶版”。它支持动态调整参与方数量线程可以在中间register()或arriveAndDeregister()对多阶段、多动态场景特别友好。如果你发现自己因为参与方数量变化而频繁重建CyclicBarrier就应该考虑用Phaser了。但由于Phaser的API更复杂如果业务阶段固定、参与方固定用CyclicBarrier反而更清晰。还有一个小工具Exchanger只能两个线程交换数据遇到成对数据交换场景才用。这里面没有“谁更高级”的说法只有“匹不匹配场景”的区别。6.3 关于这对双子星我的三点实操心得第一用CountDownLatch前先想清楚“谁数数、谁等待”这件事必须在设计阶段定死。计数归零的时机、异常分支、超时策略都要提前安排好不要写完代码再修补。第二用CyclicBarrier前先想清楚“broken了怎么办”。很多线上事故都是一轮线程异常后其他线程收到BrokenBarrierException然后乱作一团。我的习惯是在catch里先判断isBroken()再决定是继续等待还是退出如果退出一定要把错误信息带上现场状态方便排查是谁在哪个阶段掉队。第三工具类解决的是“线程怎么等”但解决不了“业务数据怎么共享”。await()之后多个线程同时被唤醒它们之间的执行顺序依然是不确定的对共享变量做写操作仍需加锁或用原子类。有一次我把阶段结果放在一个普通HashMap里觉得有CyclicBarrier同步就安全了结果在并发环境里出现数据错乱。为什么因为CyclicBarrier只保证“到达屏障”的顺序不保证“屏障之后拿到锁”的顺序。这一点千万记住。最后再分享一个小技巧写并发协作代码时无论用CountDownLatch还是CyclicBarrier都要在关键节点保留日志。一次await()结束、一次countDown()执行、一次屏障触发都值得打一条debug日志。这些日志平时看着啰嗦但线上出问题时它们就是你调试jstack最直接的证据。毕竟并发问题不怕难怕的是你连问题出在哪个环节都不知道。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询