CountDownLatch从入门到源码:Java并发等待机制详解

发布时间:2026/10/7 5:10:49
CountDownLatch从入门到源码:Java并发等待机制详解 Java 并发编程里头有一个工具名字取得特别形象CountDownLatch中文一般翻译成“倒计时门闩”。你可以直接把它理解成一扇门一开始门闩插得死死的所有想进门的线程都被挡在外面谁也过不去等倒计时归零的那一瞬间门闩自动抽开早就聚在门口的一堆线程哗啦一下全被放行。这个东西在业务代码里不一定会天天用但在高并发框架和面试题里出场率极高尤其适合处理“多个线程干活等齐了再继续”这类需求。这篇文章准备把它的定位、用法、底层原理和常见坑一次讲透适合刚接触并发编程的新人也适合面试前临时抱佛脚的 Java 工程师。1. 一把“倒计时门闩”到底解决了什么问题1.1 并发协作里最麻烦的“等”写一个下单接口后端要同时拉取用户信息、商品信息、库存状态、营销活动四份数据互相独立各自耗时还不一样。很多新手的第一版代码是这样写的先查用户再查商品再查库存最后查营销四个远程调用串行执行假设每个平均 200ms整体耗时就是 800ms。如果让这四个调用并发跑主线程等四份结果都齐了再汇总返回理想情况下整体耗时只等于最慢的那个接口大概是 200ms 出头性能差距肉眼可见。但问题来了并行跑起来之后主线程怎么知道四个子任务都结束了这个“等”字听起来简单实际上是并发编程里最容易出错的地方。最朴素的做法是 Thread.join()在子线程上调用 join()让主线程等待子线程终止。可 join() 有一个硬伤——它只能等“线程结束”。如果你的任务是丢进线程池执行的任务跑完线程并不会退出而是回到线程池继续待命join() 根本等不到你想要的信号。更别提 join() 没有超时机制也没有任何方式告诉你“还剩几个任务没完成”。所以需要一种更灵活的等待机制不是等待某个线程死亡而是等待某个“次数”归零。CountDownLatch 就是专门干这件事的。它和你约定的不是“人没了”而是“事办完了”。1.2 门闩动作拆解计数、放行与一次性设计CountDownLatch 在构造的时候接收一个正整数 count这个 count 就是倒计数的总数。整个工具只有两类动作。countDown() 负责把计数减一谁调用谁执行不关心是哪个线程调的更不关心调用的顺序。await() 负责把当前线程挂起直到计数归零也可以给它传一个超时时间等太久就直接放弃避免永久卡死。打个比方系统上线工单需要三个审批人全部点“同意”才能放行。只要还有一个人没点工单就卡在流程里三个人都点了流程立刻往下走。三个审批人之间不需要认识谁先点谁后点无所谓关键是那个“三票通过”的总开关。CountDownLatch 就是那把总开关countDown() 就是点“同意”await() 就是等待工单放行的流程节点。这里面有一个很容易忽略的设计门闩是一次性的。计数归零之后这个 CountDownLatch 就永久处于“开门”状态后面再有人调用 await() 会直接放行你不能再往里面塞计数、重新锁门。想要“重复使用的门”是另一回事得找 CyclicBarrier这个后面会详细对比。一次性这个特性注定了 CountDownLatch 适合“只等一次”的场景比如启动一批任务后等它们全部完成而不要试图让它承担多轮任务同步的职责。1.3 和 join()、CyclicBarrier 的定位对比面试里最经典的问题就是“CountDownLatch 和 CyclicBarrier 有什么区别”很多人的回答停留在“一个递减一个递增”这种表面层面。我习惯用下面这张表给自己理思路也推荐你们保存下来对比维度CountDownLatchThread.join()CyclicBarrier等待的对象计数器归零相当于一个“事件”线程终止参与线程互相等待到齐后一起放行可重用性一次性计数归零后不可 reset一次性线程死了不可复生可重置循环使用是否支持超时await 支持超时不支持超时await 支持超时是否支持中断支持中断支持中断支持中断具体场景一组任务完成后继续单个线程结束后继续多线程回合式同步比如分批处理CountDownLatch 关注的是“事件发生的次数”比如要等 4 个远程调用都结束计数设成 4。CyclicBarrier 关注的是“参与者的数量”比如 4 个线程约好在屏障点碰头谁先到谁先等直到 4 个都到了才一起走。一个是广播式的“信号灯”一个是汇合式的“集合点”语义完全不一样用混了代码就会出现诡异的时序问题。2. 三个实战场景从入门到进阶2.1 场景一多服务并行聚合这是最高频的用法回到下单接口的例子用 CountDownLatch 实现多服务并行聚合。这里我给出一个可以复制的骨架代码核心思路是先在线程池里把所有任务都提交出去让它们并行执行然后主线程 await() 统一等待全部完成后汇总数据。ExecutorService pool Executors.newFixedThreadPool(4); CountDownLatch latch new CountDownLatch(4); MapString, Object result new ConcurrentHashMap(); try { pool.execute(() - { try { result.put(user, userClient.getUser()); } catch (Exception e) { log.error(查询用户失败, e); } finally { latch.countDown(); } }); pool.execute(() - { try { result.put(goods, goodsClient.getGoods()); } catch (Exception e) { log.error(查询商品失败, e); } finally { latch.countDown(); } }); pool.execute(() - { try { result.put(stock, stockClient.getStock()); } catch (Exception e) { log.error(查询库存失败, e); } finally { latch.countDown(); } }); pool.execute(() - { try { result.put(promotion, promotionClient.getPromotion()); } catch (Exception e) { log.error(查询营销失败, e); } finally { latch.countDown(); } }); boolean finished latch.await(5, TimeUnit.SECONDS); if (!finished) { log.warn(还有 {} 个服务未返回先返回部分数据, latch.getCount()); } } finally { pool.shutdown(); }这段代码里有几个细节必须解释清楚。第一为什么结果集合用 ConcurrentHashMap 而不是 HashMap四个子线程会同时往 Map 里写数据普通 HashMap 在高并发 put 的时候会丢数据JDK 7 年代还出过扩容死循环的问题用 ConcurrentHashMap 是最稳妥的选择。第二为什么 countDown() 要放在 finally 里如果其中一个远程调用抛了异常而你没有执行 countDown()计数就少了一次主线程的 await() 会一直等下去接口直接挂死。把 countDown() 放在 finally 里意味着业务回调正常结束、异常结束都会执行计数一定减一这是 CountDownLatch 使用中最重要的一条铁律。还有一个很多人没想明白的点为什么不直接用 FutureFuture.get() 确实能拿到子线程的计算结果但它是阻塞方法四个 get() 如果按顺序调用第一个还没返回之前后面的结果根本拿不到。虽然线程池里四个任务已经在并行跑了get() 的逐个阻塞还是会让整体耗时长于“等齐再汇总”的理想值。CountDownLatch 的价值在于把“提交任务”和“等待完成”彻底拆开先一股脑提交再统一等待。2.2 场景二双 latch 发令枪压测并发的利器第二个场景是我在实际压测中用过很多次的模拟 N 个用户同时抢购、同时发起请求。最粗糙的做法是 for 循环里 new Thread(...).start()每个线程 run() 里直接写业务逻辑。问题是线程创建本身有开销先创建的线程可能已经跑完业务了后创建的线程才刚启动结果你压的根本不是“同时到达”而是“先后到达”测试出的并发效果失真严重。用两个 CountDownLatch 就能把“同时开始”这件事做得非常精准业内管这个叫双 latch 发令枪模式int users 50; CountDownLatch ready new CountDownLatch(users); CountDownLatch start new CountDownLatch(1); for (int i 0; i users; i) { new Thread(() - { ready.countDown(); // 报告我就位了 try { start.await(); // 等待发令枪 // 这里写真正的抢购逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }).start(); } ready.await(); // 主线程等待全部线程就位 long begin System.nanoTime(); start.countDown(); // 扣动扳机放行这里 ready 和 start 是两个不同作用的门闩。ready 是让主线程知道“50 个线程都创建好、都执行到等待位置了”start 是让所有子线程同时开跑。第一个门闩解决“线程没准备好”的问题第二个门闩解决“启动不同步”的问题。主线程在 ready.await() 返回后立刻调用 start.countDown()因为 start 的计数是 1一次就归零所有等待 start 的线程几乎在同一时刻苏醒。用 System.nanoTime() 记录开始时间误差能控制到微秒级比在代码里 sleep(1000) 硬等线程启动靠谱得多。这种双 latch 模式在自研压测工具、JUnit 并发测试、多线程基准测试里都很常见面试如果被问到“你怎么模拟高并发”这是一个很加分的答案。2.3 场景三服务启动前置条件检查再讲一个生产环境里用得上的场景服务启动时要等数据库连接池初始化、Redis 连接打通、配置中心拉取完成这三个前置条件都满足后才能对外提供服务。有些人喜欢在启动方法里硬编码 sleep(5000)赌它 5 秒内一定能连上这种方案太脆弱了网络抖动一下 5 秒不够机器空闲时 5 秒又白白浪费。用 CountDownLatch 可以做更优雅的启动门闩。后台起几个初始化线程每个线程负责一个资源初始化完成后调用 latch.countDown()主启动流程调用 latch.await(30, TimeUnit.SECONDS)等三个资源都就绪。如果 30 秒还没齐latch.getCount() 会告诉你还剩几个没就绪方便打印日志定位问题然后再决定是重试还是标记启动失败。这个场景也呼应了前面说的一次性特性。服务从启动到对外提供服务只需要“等一次”资源就绪之后就再也不会遇到这道门了。即使服务每天发布多次每次启动 new 一个 CountDownLatch 就行旧的已经被 GC 回收没有任何历史包袱。3. 源码级原理state、CAS 与 CLH 等待队列3.1 为什么 CountDownLatch 背靠 AQS 的共享锁从面试角度讲能说出来“CountDownLatch 的底层是 AQS”这只是及格还得把逻辑链路讲清楚。AQS 是 AbstractQueuedSynchronizer 的缩写Java 并发包的基石它维护了一个 volatile 修饰的 int state 变量和一套 CLH 线程等待队列。ReentrantLock、Semaphore、CountDownLatch 这些工具本质都是在这套骨架上做定制。CountDownLatch 内部定义了一个继承 AQS 的内部类 Sync它把 AQS 的 state 直接当作自己的计数器。构造方法 new CountDownLatch(4) 传入 4底层就是 setState(4)把 state 初始化成 4。之后每一次 countDown() 都是对 state 做减一操作每一次 await() 都是在等 state 变成 0。这里要理解一个关键认知CountDownLatch 其实是一把“共享锁”。共享锁的特点是可以被多个线程同时持有而 CountDownLatch 正好符合这个特性——state 大于 0 的时候任何线程来“抢锁”都抢不到老老实实去排队state 等于 0 的时候所有正在等待的线程都能同时“抢到锁”全部放行。这种“要么全放、要么全不放”的语义和共享锁的模式天然吻合所以说它基于 AQS 共享模式实现不是设计者的偶然选择而是逻辑推演下的必然结果。3.2 countDown() 和 await() 内部到底走了什么流程CountDownLatch 加锁和释放锁的模板流程由 AQS 提供但关键的策略方法由 Sync 自己实现。我直接贴 JDK 8 里最具代表性的源码片段你们感受一下这个设计的精妙protected int tryAcquireShared(int acquires) { return (getState() 0) ? 1 : -1; } protected boolean tryReleaseShared(int releases) { for (;;) { int c getState(); if (c 0) return false; int nextc c - 1; if (compareAndSetState(c, nextc)) return nextc 0; } }先看 tryAcquireShared它只判断 state 是否等于 0。等于 0 返回正数表示“锁拿到了放行”不等于 0 返回负数表示“锁还没拿到去排队”。所以 await() 的本质听起来很直接——调用 sync.acquireSharedInterruptibly(1)如果 state 是 0 就直接通过否则当前线程会被包装成一个共享节点挂到 CLH 队列尾部通过 LockSupport.park() 进入阻塞状态等待被唤醒。再看 tryReleaseShared这是一个 CAS 死循环。每次进来先读当前 state如果已经是 0 就返回 false因为计数不能再减了减成负数没有意义。如果 state 不是 0就计算出 nextc c - 1然后用 CAS 尝试把 state 从 c 改成 nextc。CAS 失败说明有别的线程正在同时修改 state就继续自旋重试直到成功为止。当 CAS 成功且 nextc 等于 0表示这次减一恰好把计数减到 0返回 true。返回 true 之后AQS 的 releaseShared 会调用 doReleaseShared唤醒队列中处于等待状态的后继节点。因为这是共享模式唤醒之后还会一级一级往后传播把排队等待的所有线程全部唤醒。这也是为什么最后一次 countDown() 调用会让所有等待线程“几乎同时”醒过来而不是逐个蹑手蹑脚地醒来。3.3 内存可见性被大多数人忽略的 happens-before讲完源码我还想补充一个面试里很多人答不上来的点CountDownLatch 可以建立线程间的内存可见性保证。AQS 的 state 是 volatile 的Java 内存模型里的 volatile 读写天然满足 happens-before 规则。具体到 CountDownLatch线程 A 在 countDown() 之前对共享数据的所有写入在线程 B 从 await() 返回之后都是可见的。再举一个具体的例子用户服务线程把订单信息写入 ConcurrentHashMap写完之后调用 latch.countDown()主线程 await() 返回后从这个 Map 里读取订单信息一定读得到完整内容。这不是碰巧“读到了”而是 JMM 规范的硬性保证。很多并发 bug 表现为数据丢失、读到的数据还是旧值往往是团队里有人绕过了同步工具自己用裸变量在多个线程间传数据。用 CountDownLatch 做线程间的“汇合点”既能控制执行时序又能保证数据可见性一举两得。4. 踩坑记录与高频面试题4.1 坑一countDown() 没执行 主线程永久阻塞这是我职业生涯里亲眼见过的事故。当时一个多线程聚合查询接口四个线上服务并发调用其中一个服务偶发超时抛了 RuntimeException子线程直接终结但 finally 里没有 countDown()计数永远少一次主线程的 await() 又没有设置超时整个接口就那样挂住了。后来排查半天才发现不是服务没返回而是 latch 的计数没归零。从那之后我给自己定了一条规矩任何 CountDownLatch 的使用代码countDown() 必须放在 finally 块里任何 await() 必须带超时参数。await(5, TimeUnit.SECONDS) 返回 false 的时候用 latch.getCount() 查看剩余计数打印 WARN 日志再按业务做降级兜底而不是让主线程傻傻等一辈子。这条经验放到生产环境里就是“接口偶发超时”和“接口永久卡死”的区别。4.2 坑二计数与任务数量不匹配门闩提前或延后放行CountDownLatch 的构造函数参数叫 count它必须和实际要等待的任务数量严格一致。实际代码里经常出现两种错误。一种是少写任务你构造了一个计数为 5 的 latch但只提交了 4 个任务结果计数永远到不了 0主线程等到超时触发才算完。另一种是任务数量超过计数一个本来计数为 4 的 latch代码里某个任务执行了两次 countDown()计数提前归零门闩提前打开还有任务没跑完就把主流程放过去了最后拿到的聚合数据缺胳膊少腿。我的建议很朴素不要手写常量。如果任务集合是 List就 new CountDownLatch(tasks.size())如果任务数量来自配置就先用日志把配置值打出来肉眼确认一遍再使用。计数不一致的 bug 往往很难通过单测发现因为单测里任务少、速度快、放行时机的影响不明显一定要在压测场景下专门验证。4.3 坑三把 CountDownLatch 和 CyclicBarrier 混用的语义混乱面试高频题“区别”如果只在嘴上背代码里很容易写出四不像。比如有人为了让 CountDownLatch 可以重用在循环外面 new 了一次然后每一轮任务结束都重新构造一个对象——代码逻辑上没错但语义已经扭曲了。该用 CyclicBarrier 的场景偏偏用 CountDownLatch 硬凑还得手动管理对象的生命周期平白多出一堆 bug 隐患。我习惯用一个简化口诀来区分这两个工具CountDownLatch 是“一个大喇叭喊所有人出发”CyclicBarrier 是“一群人在集合点等齐了再一起走”。前者是事件驱动的广播后者是参与者驱动的汇合。如果业务是“所有前置任务完成后统一执行下一步”选 CountDownLatch如果业务是“多个线程分轮次协同每轮都要重新同步”选 CyclicBarrier。这俩的关系不是替代是互补。再补充几个面试延伸问题你们可以直接拿去当复习清单面试问题参考回答要点CountDownLatch 底层原理基于 AQS 共享锁用 state 做计数CLH 队列做等待和 CyclicBarrier 的区别一次性 vs 可重用事件等待 vs 线程汇聚关注计数 vs 关注人数计数为 0 后继续调用 countDown() 会发生什么返回 false不会抛异常计数不会变负数计数为 0 后继续调用 await() 会发生什么直接放行不会阻塞await() 超时返回 false 应该怎么处理看 getCount() 的剩余值按部分失败做兜底4.4 排查实录从 jstack 到 getCount() 的一整套定位方法如果生产环境真的遇到 CountDownLatch 卡住的问题别慌建议按下面的顺序排查。第一步用 jstack 导出线程堆栈你会看到主线程卡在 LockSupport.parkNanos 或者 AbstractQueuedSynchronizer 相关方法上等待的对象指向 CountDownLatch$Sync这就确认了卡点。第二步在代码里临时加日志打印 latch.getCount() 的剩余值如果剩余值一直大于 0说明有任务没有执行 countDown()或者数量本身就不对。第三步核对所有任务代码确认 countDown() 是否都写在 finally 里确认有没有任务重复执行了 countDown()。我的个人习惯是在构造 latch 的地方打一行日志记录预期计数在每次 countDown() 之后打一行 debug 日志记录剩余计数在 await() 返回后打一行日志记录超时状态和剩余计数。这样即使出了问题日志就能给出完整的链路不需要翻着源码猜。这套排查套路我传给过团队里好几个新人都比翻文档效率高得多。最后再说几句个人体会。我见过不少项目在启动流程里滥用 CountDownLatch动不动就 await(60, TimeUnit.SECONDS) 等一堆资源结果某个环节悄悄挂掉服务就傻傻等满一分钟才报错。我现在做方案的习惯是优先考虑 CompletableFuture 这类现代并发 API 来做异步编排能用则用确实需要精确控制“等齐再放行”的语义时再用 CountDownLatch而且一定同步评估好三件事——超时时间、失败兜底、计数器一致性。它是一把很好用的门闩但门闩用不对卡住的不是并发而是你的整个服务。

关于本文作者

来自尧图内容编辑团队

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

尧图内容编辑团队

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

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

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

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

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

网站改版的5个关键决策

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

获取专属建站方案

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

立即免费咨询