尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

CountDownLatch与CyclicBarrier实战:Java并发协作工具深度对比

CountDownLatch与CyclicBarrier实战:Java并发协作工具深度对比 去年我在做一个订单聚合查询接口时遇到一个很典型的等待问题接口需要同时调用户服务、库存服务、优惠服务三个下游拿到全部结果后才能拼装返回。一开始用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的底层是AQSAbstractQueuedSynchronizer走的是共享锁模式。初始化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(); MapString, Object result demo.aggregate(order-2024-0001); System.out.println(result); pool.shutdown(); } public MapString, Object aggregate(String orderId) throws InterruptedException { MapString, 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里再在最后遍历。如果多个线程同时addArrayList会出并发问题。我上面用的是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进finallyawait必须带超时这两个准则我在前面已经反复强调这里还是要专门列出来因为线上踩过的坑太深刻了。某次生产事故排查中我们发现一个网关接口偶尔会无响应线程池队列持续堆积。查了很久才发现某个调用链路上用了CountDownLatch等两个外部服务返回其中一个服务会因为数据问题抛出NullPointerException而代码里countDown放在业务逻辑之后异常一抛countDown被跳过计数器永远停在1。主线程await(10, TimeUnit.SECONDS)超时后返回但整个接口响应时间已经不可接受。修复方式就是两行代码的事情把countDown移到finally里。但这行代码没写好影响的却是整个接口的可用性。说实话这类Bug在本地测试很难被发现。本地测试下游服务基本都正常只有到了线上各种异常路径触发时才会暴露。所以我的建议是从写第一行Code开始就养成“countDown进finally、await带超时”的肌肉记忆不要抱侥幸心理。5.3 踩坑点二CyclicBarrier的reset与BrokenBarrierExceptionCyclicBarrier的坑比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本身并不复杂复杂的是你所在业务的并发模型。把等待语义理清楚再把这两个工具该用的地方用对你的并发代码至少能少踩一半的坑。
返回列表