☰
CountDownLatch与CyclicBarrier实战:Java并发协作工具深度对比
2026/10/14 13:08:11 网站建设 项目流程

去年我在做一个订单聚合查询接口时,遇到一个很典型的等待问题:接口需要同时调用户服务、库存服务、优惠服务三个下游,拿到全部结果后才能拼装返回。一开始用Future.get()串行等待,结果接口总耗时几乎等于三个服务里最慢的那个耗时,某个服务一抖动,整个接口就跟着超时。后来把协作逻辑改成CountDownLatch,一批任务并行跑完,统一放行,问题才真正解决。趁着整理Java并发编程系列的机会,我把CountDownLatch和CyclicBarrier这对“线程协作双子星”好好掰开揉碎讲一遍,看完你不仅能分清两者的区别,还能直接照着代码落地。

1. 一次线上聚合接口的等待事故:为什么必须引入协作工具

1.1 事故现场:三个下游服务,一份超时账单

那个接口的逻辑本身不复杂:根据订单ID查出订单基础信息,然后同时去用户服务拿买家昵称、去库存服务拿当前库存状态、去优惠服务拿可用优惠券,最后把这几块数据整合成一个视图模型返回给前端。

最初的写法很“朴实”——先查用户,再查库存,最后查优惠,每一步都是同步远程调用。结果就是接口耗时等于三个服务耗时之和。用户服务响应快一点,平均150ms;库存服务慢一些,平均250ms;优惠服务最不稳定,偶尔会跑到600ms。串行调用时,接口平均耗时就在1000ms上下,高峰期经常突破2秒。

后来优化成并行提交到线程池,三个任务同时开跑。理论上总耗时应该接近最慢的那个服务,也就是600ms左右。但实际测试发现,接口性能虽然改善了,代码却变得特别别扭。每个任务都要用Future接收,向下游发起调用后,主线程要依次对每个Future调用get()方法获取结果。问题在于,get()是阻塞式的——不管任务完成得有多快,只要有一个服务慢,主线程就会被卡在对应的get()上。而且为了防超时,还得给每个Future单独设置timeout参数,再分别判断是否超时、是否抛出ExecutionException、要不要取消任务。三个任务还能忍,如果下游服务变成八个甚至十个,这种写法基本没法维护。

1.2 从轮询到CountDownLatch:等待语义的转变

多线程编程里,除了大家熟知的锁竞争之外,还有一类很重要的需求叫“协作”:某个线程需要等其他线程完成特定动作后才能继续。处理这类需求,常见的方案有这么几种:Thread.join()等待指定线程终止、Future.get()等待任务返回结果、轮询一个volatile状态变量判断任务是否都结束了。

Thread.join()的问题在于它只适合等待“线程终止”,如果线程池复用了核心线程,线程本身并不会终止,join就失效了。轮询状态变量看起来简单,实际上要自己维护一个计数,还要保证线程间的可见性,稍不注意就出并发Bug。Future.get()则是“一对一”地等待单个任务,多个任务要写多个get,代码很碎。

CountDownLatch做的事其实很纯粹:把“等N个事件完成”这件事封装成一个线程安全的计数器。你提前设定好N,每个子任务跑完就倒一次数,等到计数器归零,正在等待的线程被统一放行。

一个很贴切的类比是考试收卷:一个考场有40名学生,监考老师必须等40张答题卡全部交齐才能离开。每个学生交卷就算一次countDown,计数器从40减到0的那一刻,老师才被放行。不管学生交卷的顺序怎么乱,只要计数没归零,老师就得一直等着——这正是CountDownLatch帮我们解决的等待语义。

2. 底层同步原理解剖:同样是AQS派,协作方式却完全不同

2.1 CountDownLatch:一次性倒数门闩的工作过程

CountDownLatch的底层是AQS(AbstractQueuedSynchronizer),走的是共享锁模式。初始化CountDownLatch(count)时,AQS里的state字段被直接设置成count。你没有看错,就是AQS的state,同一个字段在ReentrantLock里表示“重入次数”,在CountDownLatch里表示“剩余需要等待的事件数”。

当线程调用await()方法时,如果state不为0,当前线程就会被包装成共享模式节点放入AQS的同步等待队列,然后挂起。当某个线程调用countDown()方法时,内部会用CAS操作把state减1。这里有个关键点:只有当CAS操作把state从1减到0的那一刻,这个线程才会去唤醒等待队列里的所有线程。如果你连续调用countDown()直到state变成负数?不会。CAS操作发现state已经是0了,线程就不会继续减下去了,计数不会变成负数。

这里顺便说一下共享模式和独占模式的区别。ReentrantLock用的是独占模式,一个线程持有锁时,其他线程排队等候,锁释放后只唤醒等待队列里的下一个节点,典型的一对一交接。CountDownLatch用的是共享模式,锁状态归零时,一次性唤醒所有等待节点上的线程,是一对多广播。这也是为什么CountDownLatch能“放行”一批等待线程的根本原因。

还有一点必须记住:整个生命周期里,计数器只能从初始值倒到0,减到0后不能再重置。如果你想让同一批逻辑跑完一轮之后再来一轮,那它做不到。想复用,只能重新new一个CountDownLatch。

2.2 CyclicBarrier:可重置的屏障与“人齐发车”机制

CyclicBarrier的内部结构跟CountDownLatch差别很大。它底层组合了ReentrantLock和Condition,维护着一个内部类Generation(代)来管理屏障的轮次。

初始化CyclicBarrier(parties)时,设定一个“需要凑齐的线程数”。每个线程调用await(),就代表“我到达屏障了,先歇会儿,等其他人”。当第parties个线程到达时,屏障被打破——请注意这个“打破”的含义,它不是说屏障坏了,而是说这一轮条件满足了。此时如果有设置barrierAction,最后一个到达的线程会去执行这个动作(比如做汇总、打日志),然后屏障自动进入下一代,并唤醒当前代上所有等待的线程。

我说的“人齐发车”,对应的就是旅游大巴的例子。一个团30个人去景区,导游要求人齐才发车。先到的人在集合点等着,最后一个赶上大巴的人触发“发车”信号,大巴开走。到了下一个景区,大家又各自去玩,再次集合,依然是人齐了才发车。CyclicBarrier就是为这种“周期性等待”设计的——每一轮结束后自动重置,无需手动重建。

还有一个参数很容易被忽略:CyclicBarrier(int parties, Runnable barrierAction)。barrierAction会在每一轮屏障被打破时执行且只执行一次,由最后一个到达的线程负责调用。这个参数用好了能做很多事情,比如在批量计算任务中,所有分片算完一轮后,触发一次全局汇总。

2.3 一句话说清两者本质区别

我在给别人讲这两个工具时,最喜欢用一句话总结:

CountDownLatch是“N个事件等齐后放行”,等待方往往是主线程或管理者线程,适合一次性任务;CyclicBarrier是“N个线程互相等待,到齐后一起继续”,而且可以循环使用,适合多阶段协作。

如果你发现业务场景里“某一方”在等待“其余几方”完成,那就优先想到CountDownLatch。如果业务场景里“每个线程都需要等其他人到齐,谁都不能先跑”,并且这种等待要重复多轮,那就优先想到CyclicBarrier。这个判断标准比死记硬背API有效得多。

3. 实战一:用CountDownLatch并行拉取多个服务结果并聚合

3.1 完整代码:一个可运行的聚合查询Demo

说了这么多原理,直接上代码。以下是一个订单聚合查询的简化版Demo,三个下游任务并行执行,主线程等待全部结束后统一返回结果。

import java.util.Map; import java.util.concurrent.*; public class OrderAggregationDemo { private static final ExecutorService pool = Executors.newFixedThreadPool(8); public static void main(String[] args) throws InterruptedException { OrderAggregationDemo demo = new OrderAggregationDemo(); Map<String, Object> result = demo.aggregate("order-2024-0001"); System.out.println(result); pool.shutdown(); } public Map<String, Object> aggregate(String orderId) throws InterruptedException { Map<String, Object> result = new ConcurrentHashMap<>(); CountDownLatch latch = new CountDownLatch(3); pool.submit(() -> { try { result.put("userInfo", queryUserInfo(orderId)); } catch (Exception e) { result.put("userError", e.getMessage()); } finally { latch.countDown(); } }); pool.submit(() -> { try { result.put("stockInfo", queryStockInfo(orderId)); } catch (Exception e) { result.put("stockError", e.getMessage()); } finally { latch.countDown(); } }); pool.submit(() -> { try { result.put("promotionInfo", queryPromotionInfo(orderId)); } catch (Exception e) { result.put("promotionError", e.getMessage()); } finally { latch.countDown(); } }); // 主线程等待三个任务全部countDown,最多等3秒 if (!latch.await(3, TimeUnit.SECONDS)) { System.out.println("警告:有下游服务未在3秒内返回,当前结果为部分数据"); } return result; } private String queryUserInfo(String orderId) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(200); return "user:" + orderId; } private String queryStockInfo(String orderId) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(300); return "stock:" + orderId; } private String queryPromotionInfo(String orderId) throws InterruptedException { TimeUnit.MILLISECONDS.sleep(150); return "promotion:" + orderId; } }

代码逻辑不复杂,但我建议你盯着两个地方看:第一,result用的是ConcurrentHashMap而不是HashMap,因为三个子任务线程会同时往这个Map里put内容,普通HashMap在多线程写入时可能丢数据甚至导致CPU飙升。第二,latch.await()带了超时时间,这是一个职业习惯问题,下面会展开讲。

实际运行一下会发现,三个模拟服务分别耗时200ms、300ms、150ms,串行执行总耗时是650ms,而使用这个Demo后接口总耗时只比最慢的那个服务多一点点,也就是300ms上下。这就是并行的意义。

3.2 代码里的三个关键细节:countDown位置、异常兜底、结果收集

细节一:countDown必须放进finally。

这是CountDownLatch使用中最常见也最致命的坑。如果某个子任务在执行业务逻辑时抛出异常,比如下游服务连接超时导致RuntimeException,而你的countDown写在了try块的最后一行,没有走finally,那么这个计数就永远不减。其他任务都正常完成了,计数器还剩1,主线程就永远阻塞在await()上。线上出现接口无响应、线程池耗尽,很多时候就是这种低级问题。

正确的做法是把countDown放在finally块里。不管任务正常结束还是异常退出,计数器最终都会被减掉。上面Demo里我是把异常捕获后放入Map再countDown,这其实已经兼顾了“异常不丢”和“计数不减”两个需求。

细节二:await必须带超时。

生产环境不像写Demo,你不能假设下游永远可靠。latch.await()无参版本一旦遇到计数不归零,就会一直挂起,这种阻塞在分布式系统里几乎是事故的代名词。我推荐所有给人用的线程等待代码都加上超时,比如latch.await(3, TimeUnit.SECONDS)。如果超时返回false,说明还有任务没完成,你可以把当前已经收集到的部分结果返回,同时配合日志或监控体系告警。

这里有个很实用的技巧:超时返回后,如果你还需要再次等待,可以重新调用await(timeout)循环等待,但最多不能超过你设定的总体超时预算。如果你调用了第一次await(3s)超时后,又调用了第二次await(3s),那么接口总耗时就是6秒,这个预算要在设计阶段就想清楚。

细节三:结果收集用线程安全的容器,不要用ArrayList。

很多人习惯先把任务结果add到ArrayList里,再在最后遍历。如果多个线程同时add,ArrayList会出并发问题。我上面用的是ConcurrentHashMap,按业务字段名区分结果,实际上也可以用CopyOnWriteArrayList,或者每个任务持有一个独立的Future,最后统一遍历Future获取。但那样又回到了Future.get()的碎片化问题。用ConcurrentHashMap配CountDownLatch,是最省心、最直观的组合。

3.3 一个易错点:不要在子任务里调用await()

我在代码评审时看到过一种奇怪用法:主线程new了一个CountDownLatch(1),然后提交多个子任务,子任务内部执行完自己的事情后,还要调用latch.await()等“主线程的某个动作”。这种反向协作不是不能用,但很容易把代码绕晕。记住一点,CountDownLatch的await和countDown可以由不同线程执行,你不需要拘泥于“哪个线程负责等待”。但是请保持语义清晰:谁是需要结果的线程,谁就负责await;谁在贡献结果,谁就负责countDown。如果你发现一个任务里既await又countDown,那十有八九是该用CyclicBarrier而不是CountDownLatch。

4. 实战二:用CyclicBarrier搭建多阶段分片计算流水线

4.1 场景设定:为什么分片计算必须“等齐一班,再开下一班”

再来看一个更复杂一点的场景。假设有个批量任务,需要对一批数据做分片处理。每个分片要经过清洗、计算、落库三个阶段的流水线。这里有个硬性要求:同一批的所有分片必须都完成当前阶段,才能进入下一阶段。

道理很简单,计算阶段可能需要用到清洗阶段的全局统计量,比如“全量数据里有多少条缺失值”。你如果让第1个分片跑得快,先进入计算阶段,却发现清洗阶段的统计结果还没出来,这不就乱套了?所以必须设置一个屏障,每次等全部分片到达后,统一推进。

CountDownLatch能解决吗?能,但很别扭。每一轮结束你都要重新new一个CountDownLatch,还要在代码里小心维护“当前是第几轮、该用第几个Latch”的状态。一旦轮次多了,代码又臭又长。CyclicBarrier天生就是干这个的:parties固定为分片数,每轮结束后屏障自动重置,线程接着跑到下一个屏障,继续等齐再推进。

4.2 完整代码:CyclicBarrier实现的分批合并流程

下面这个Demo模拟4个分片线程,每轮执行清洗、计算两个阶段,共循环3轮。每轮结束时,最后一个到达屏障的线程会触发barrierAction做一次汇总。

import java.util.concurrent.BrokenBarrierException; import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; public class ShardComputeDemo { private static final int SHARD_COUNT = 4; public static void main(String[] args) { CyclicBarrier barrier = new CyclicBarrier(SHARD_COUNT, () -> System.out.println(">>> 本轮全部分片已到达屏障,开始执行汇总动作 <<<") ); for (int i = 0; i < SHARD_COUNT; i++) { final int shardId = i; new Thread(() -> handleShard(shardId, barrier)).start(); } } private static void handleShard(int shardId, CyclicBarrier barrier) { for (int round = 1; round <= 3; round++) { try { // 阶段一:清洗 System.out.println("分片[" + shardId + "] 第" + round + "轮清洗开始"); doClean(shardId, round); barrier.await(); System.out.println("分片[" + shardId + "] 第" + round + "轮清洗结束,进入计算阶段"); // 阶段二:计算 System.out.println("分片[" + shardId + "] 第" + round + "轮计算开始"); doCompute(shardId, round); barrier.await(); System.out.println("分片[" + shardId + "] 第" + round + "轮计算结束"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.out.println("分片[" + shardId + "] 被中断,退出"); return; } catch (BrokenBarrierException e) { System.out.println("分片[" + shardId + "] 感知到屏障断裂,退出"); return; } } } private static void doClean(int shardId, int round) throws InterruptedException { TimeUnit.MILLISECONDS.sleep((shardId + 1) * 50L); } private static void doCompute(int shardId, int round) throws InterruptedException { TimeUnit.MILLISECONDS.sleep((shardId + 1) * 30L); } }

运行这段代码,你会看到很有意思的输出。4个分片线程各自执行清洗,快慢不一,但最终会在第一个barrier.await()处停下,直到第4个(最慢的)分片到达,屏障才打开。然后barrierAction在某个线程里执行一次,打印汇总日志。紧接着4个线程一起继续往下跑计算阶段。

注意输出里“清洗结束,进入计算阶段”这行日志,一定是紧跟着那条汇总日志一起出现的,而且4个线程几乎是同时打印的。这就是屏障的效果:在你await返回之后执行的代码,已经是“全组线程都到达过屏障”之后了。

4.3 barrierAction、broken状态与generation的相互作用

先说barrierAction的坑。

barrierAction由最后一个到达的线程执行,如果这个Runnable内部抛了异常,屏障的当前代会直接变成broken状态,其他所有正在等待的线程都会收到BrokenBarrierException。所以写barrierAction时,务必把可能抛异常的业务代码用try-catch包起来,至少保证屏障本身不被搞坏。

再说broken状态。

CyclicBarrier引入了一个很独特的异常模型。如果某个线程在await期间被中断,它会抛出InterruptedException,同时屏障会进入broken状态,其他所有正在等待的线程会被唤醒,并抛出BrokenBarrierException。这跟CountDownLatch不一样,CountDownLatch里一个线程被中断只会影响它自己,不会连累别人。

这意味着使用CyclicBarrier时,catch块要同时处理两种异常。处理InterruptedException时一定要恢复中断标记,也就是调用Thread.currentThread().interrupt()。处理BrokenBarrierException时,通常意味着这一轮已经没戏了,线程应当安全退出或者重置整个屏障状态。

最后说generation。

CyclicBarrier内部维护着一个Generation对象,每成功打破一次屏障就换一个新代。reset()方法的作用是强行把当前代标记为broken并开启新代。注意,reset()调用时如果有线程还在等待,这些线程会立即被唤醒并抛出BrokenBarrierException。所以不要在业务代码里随意调用reset(),除非你真的要“中断当前这一轮,重新开始”。正常流水线场景下,你什么都不用做,屏障会自然进入下一代继续使用。

5. 选型决策表与踩坑实录:生产环境如何不翻车

5.1 一张表说清选型逻辑

很多人在面试或写代码时容易把这两个工具搞混,我直接列一张对比表,方便你贴在工位上。

对比维度CountDownLatchCyclicBarrier
可重用性一次性,计数归零后不可重置可循环使用,每轮自动重置
等待方通常是主线程等待N个子任务完成N个线程互相等待,没有绝对的“主”
计数规则从初始值向下递减到0从0向上递增到parties
内建触发动作无支持barrierAction,最后一线程触发
故障模型无broken概念,一个线程中断不影响其他有broken状态,一个线程异常会破坏整道屏障
典型场景并行聚合查询、等待服务启动完成分片计算流水线、多阶段协同任务
底层核心AQS共享锁ReentrantLock + Condition + Generation

选型不用纠结太久。业务是“一次性等齐”还是“多轮等齐”,是这个决策的核心分水岭。一次性,选CountDownLatch;多轮循环,选CyclicBarrier。

5.2 踩坑点一:countDown进finally,await必须带超时

这两个准则我在前面已经反复强调,这里还是要专门列出来,因为线上踩过的坑太深刻了。

某次生产事故排查中,我们发现一个网关接口偶尔会无响应,线程池队列持续堆积。查了很久才发现,某个调用链路上用了CountDownLatch等两个外部服务返回,其中一个服务会因为数据问题抛出NullPointerException,而代码里countDown放在业务逻辑之后,异常一抛,countDown被跳过,计数器永远停在1。主线程await(10, TimeUnit.SECONDS)超时后返回,但整个接口响应时间已经不可接受。修复方式就是两行代码的事情:把countDown移到finally里。但这行代码没写好,影响的却是整个接口的可用性。

说实话,这类Bug在本地测试很难被发现。本地测试下游服务基本都正常,只有到了线上,各种异常路径触发时才会暴露。所以我的建议是,从写第一行Code开始就养成“countDown进finally、await带超时”的肌肉记忆,不要抱侥幸心理。

5.3 踩坑点二:CyclicBarrier的reset与BrokenBarrierException

CyclicBarrier的坑比CountDownLatch隐蔽得多。

一是reset的滥用。有些同学觉得CyclicBarrier既然可循环,那我每轮结束手动reset一下似乎也没什么问题。实际上如果某轮还有线程没到齐,你忙不迭地调用reset(),那些正在等待的线程就会收到BrokenBarrierException。如果你的业务逻辑没有捕获这个异常,线程就挂了,下一轮永远凑不齐parties,形成新的事故。正确的做法是:让屏障自然进入下一轮,不要手动干预。

二是超时与broken的关系。如果某个线程调用await(5, TimeUnit.SECONDS)超时返回,屏障会进入broken状态,其他线程都会收到BrokenBarrierException。这在业务上其实是个好设计——超时说明肯定有人掉队了,与其让其他线程无意义地等待,不如直接告诉所有人“这轮出现了异常”。你捕获到BrokenBarrierException后,应当记录异常上下文,并考虑整体重试或终止。

三是分区线程数过大时,barrier的唤醒开销会明显上升。每个线程到达屏障时都要做一次condition.signalAll,唤醒所有等待线程。如果你的parties设置成几千甚至几万,这个唤醒风暴会拖垮性能。我的经验是,分片粒度应该结合CPU核心数和业务复杂度来定,通常8到32个分片足够用。线程满天飞只会放大上下文切换的损耗。

5.4 踩坑点三:线程池容量与Latch计数不一致导致永久阻塞

这个坑非常隐蔽,我甚至见过很多有一定工作年限的开发也中招。

场景是这样的:线程池核心线程数设置成2,但业务代码里提交了3个子任务,每个子任务都绑定一个CountDownLatch计数。如果这3个任务全部能进入线程池执行,问题不大,还没执行的任务会在队列里排队,最终计数会归零。真正的坑在任务内部存在依赖关系时:第1个任务执行过程中,需要等第3个任务产生某个中间结果,而第3个任务排在队列里根本还没开始执行。这时候线程池里跑着的任务在等待队列里的任务,队列里的任务因为线程被占满又永远得不到执行——经典的“线程饥饿死锁”。

如果你在代码里发现CountDownLatch的计数和线程池提交的任务数不一致,或者某个子任务内部又嵌了另一个CountDownLatch的await,就一定停下来想一想,这里是不是有循环等待的风险。保险措施永远只有一条:await带超时,超时兜底不能省。

5.5 我对这两个工具的使用心得

用多了之后,我觉得它们就像两个性格完全不同的工具人。CountDownLatch是个“一次性的信号旗”,适合把“一堆独立的活干完之后再收口”这种简单需求处理掉;CyclicBarrier则像个“多回合的哨兵”,适合处理分阶段、循环推进的业务流水线。

从性能上看,CountDownLatch的countDown只是CAS减数,大部分时间没有锁竞争,非常轻量。CyclicBarrier因为每次到达都要signalAll,重量级得多。尤其在并发量大的场景下,一个随时可能被多线程争抢的信号量计数器和一道必须反复集结的屏障,前者的开销几乎可以忽略,后者的开销要按业务量级单独评估。

另外,这两个工具都不适合当作锁来用。它们解决的是“协调”问题,不是“互斥”问题。如果你发现自己是在控制“同一时刻只有一个线程能进入临界区”,那应该老老实实去用synchronized或者ReentrantLock,别拿CountDownLatch凑合。

最后分享一个小习惯

我在实际项目里写多线程协作代码时,有一个坚持了很久的习惯:动手之前,先在注释里用三句话写清楚“谁在等谁、要等几个才放行、放行之后谁去干什么”。比如:

// 等待语义:主线程等待所有子任务完成 // 计数规则:3个子任务各countDown一次,归零后放行 // 放行动作:主线程组装视图模型返回给前端

这个习惯看起来不起眼,但在代码规模变大之后非常有用。你回头排查一个几个月前写的并发方法,面对一坨await和countDown,这几十个字的注释能帮你省下大量回忆时间。合作过的同事也跟我说,看到这种注释心里会特别踏实,至少知道作者在写的时候是真正想清楚了协作关系的。

CountDownLatch和CyclicBarrier本身并不复杂,复杂的是你所在业务的并发模型。把等待语义理清楚,再把这两个工具该用的地方用对,你的并发代码至少能少踩一半的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询