1. CountDownLatch 是什么,以及为什么你需要它
最早接触 CountDownLatch,是一次要在压测环境里复现多线程并发抢购的竞态问题。当时所有请求都是异步发出去的,主线程发完就结束了,根本没法判断“最后一笔订单到底是在哪个线程里写入的”。后来查日志、加埋点,折腾了大半天,问题还是没有定位清楚。实在没辙,某位前辈顺手写了个 CountDownLatch,把计数设成请求数,每个线程处理完就countDown(),主线程在await()上等着,等所有请求都到了“结束点”再统一检查结果。那一瞬间思路就完全打开了:原来让主线程等子线程这件事,可以做得这么干净利落。
CountDownLatch 是纯 Java 并发包里一个极其经典的同步工具,从命名就能猜出它的全部含义:允许一个或者多个线程一直阻塞,直到一组在其他线程中执行的操作全部完成。它内部维护一个计数器,初始值由构造器传入,每次调用countDown()减一,当计数减到 0,所有因调用await()而阻塞的线程会立即恢复执行。一句话总结:它就是个“倒数闸门”,倒计时结束,闸门打开,之前拦住的线程全部放行。
这个工具尤其适合主线程等待多个子任务完成后再统一处理结果的场景。不管是批量接口测试、并行加载多个数据源、多服务启动前置检查,还是控制并发流程的阶段切换,只要你能抽象成“一批任务齐了才往下走”,CountDownLatch 基本都是最直接的解。它的 API 非常少,核心就countDown()、await()、带超时的await(long, TimeUnit),三个方法就能覆盖大部分并发协调需求。对于刚接触并发编程的同学来说,CountDownLatch 是第一块完全值得拆开揉碎吃透的基石;对于已经写了两三年并发代码的朋友,可能平时都在用,但很多细节——比如计数清零前为什么要await()而不是Thread.sleep()、为什么不能复用、为什么子线程抛异常的时候要特别注意——未必都想过。
2. 核心机制与 API 透底:一个计数器如何撑起一个闸门
2.1 构造器里的“计数”到底存到哪里了
先说一个最容易被误解的点。很多人用new CountDownLatch(3),会下意识觉得这个 3 是普通类的 int 字段。实际上 CountDownLatch 内部直接使用了AbstractQueuedSynchronizer(简称 AQS)来做同步控制,它在构造方法里调用的是sync.acquireSharedInterruptibly(1)对应的那一套状态管理,而计数器本身被放在了 AQS 的state字段里。
public CountDownLatch(int count) { if (count < 0) throw new IllegalArgumentException("count < 0"); this.sync = new Sync(count); } Sync(int count) { setState(count); }state是一个 volatile 修饰的 int,这也是为什么 CountDownLatch 自身没有任何显式加锁逻辑,却天然线程安全。所有countDown()对 state 的减一操作,都是在 AQS 的 CAS 循环里完成的,不会出现多线程同时减一导致计数错乱的问题。搞懂这一层,你就能知道:计数精度是有保障的,不需要自己额外加锁。
2.2 countDown() 的一减一放
countDown()的语义看起来很简单:计数器减 1,减到 0 的时候唤醒所有等待线程。但内部实现是通过 AQS 的releaseShared(int arg)走下来的。
当计数未归零时,每次调用只是执行CAS(state - 1),线程立即返回,不会阻塞。这一点很多人没有意识到:countDown()本身就是个非阻塞方法,调用它不会卡住当前线程。只有当最后一次调用把计数减到 0 时,才会触发doReleaseShared(),把所有挂起的线程全部释放。
public void countDown() { sync.releaseShared(1); }这里的“释放”不是释放一个线程,而是广播式地释放所有等待线程。因为 CountDownLatch 的语义是“闸门打开”,一旦打开,所有等着的线程都可以通过,不需要像信号量那样一个一个发许可证。这个差异很关键,决定了你不能把 CountDownLatch 当限流工具来用。
2.3 await() 到底在等什么
await()对应 AQS 的acquireSharedInterruptibly(1)。线程调用await()时,系统先判断state是否为 0,如果是 0 就直接返回,说明闸门已经是打开状态;如果不是 0,当前线程会进入 AQS 的等待队列,通过LockSupport.park()挂起自己。
等计数归零那一下,doReleaseShared()会遍历队列里的等待节点,逐个执行LockSupport.unpark(),把这些线程全部唤醒。如果你传了超时时间,比如await(5, TimeUnit.SECONDS),内部会在 park 的时候设置超时阈值,超过时间还没被唤醒,线程返回false,你可以基于这个返回值做“超时降级”处理。
这一套机制下来,整个 CountDownLatch 的核心逻辑就非常清晰了:
- 初始化计数器(state = N)
- 工作线程完成任务后调用 countDown()(state -= 1)
- 主线程调用 await() 挂起,直到 state == 0
- 计数归零瞬间,所有 await 线程被一次性唤醒
2.4 三个小细节:中断、超时、可重用性
细节一,await()是会响应中断的。如果等待期间的线程被interrupt(),会立刻抛出InterruptedException,而不是继续傻等。这其实非常合理:如果你主线程都已经被人要求停止工作了,没理由还在那儿等子线程。所以在实际业务里,await()一定要处理InterruptedException,可以抛出,也可以恢复中断标记,但绝不能直接吞掉。
细节二,带超时的await(long, TimeUnit)返回值是 boolean,表示是否在超时之前等到了计数归零。如果返回 false,你就要自己决定后续怎么做:是重试、降级、还是记录日志然后继续往下走。
细节三,CountDownLatch 的计数器是一次性的,不能在归零后reset到初始值继续用。如果你需要循环使用“开始闸门”的语义,应选择CyclicBarrier。关于两者区别,后面会专门展开。
3. 实战拆解:从最基础的用法到稍复杂的落地场景
3.1 场景一:主线程等待多个子线程完成并发检查
最基础的用法网上一搜一大把:一个主线程拆 N 个子线程,子线程各自干活,主线程等大家干完统一继续。这个模式在“并行预加载缓存”、“并发请求外部服务收集结果”之类的场景里非常常见。
public class LatchDemo { public static void main(String[] args) throws InterruptedException { int taskCount = 5; CountDownLatch latch = new CountDownLatch(taskCount); List<String> results = new CopyOnWriteArrayList<>(); for (int i = 0; i < taskCount; i++) { int taskId = i; new Thread(() -> { try { TimeUnit.MILLISECONDS.sleep((long) (Math.random() * 500)); results.add("任务" + taskId + "完成"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); // 无论成功失败都要减一 } }).start(); } latch.await(); System.out.println("所有任务执行完毕,结果数量:" + results.size()); results.forEach(System.out::println); } }这里有两个要点。第一,results用了并发安全的集合,因为多个线程并发写入共享数据,普通ArrayList会出现线程安全问题;第二,countDown()放进finally,保证任务就算中途抛异常,也不会让主线程永远等下去。
3.2 场景二:线程池 + CountDownLatch,批量任务编排
实际项目中很少会直接new Thread(),一般都用线程池。但这个组合有一个很容易踩的坑:线程池的任务丢进去之后,execute()返回,主线程先跑了,如果你要在主线程里等所有池内任务执行完,必须用CountDownLatch控制,而不是依赖线程池的什么“等待方法”。
ExecutorService pool = Executors.newFixedThreadPool(3); int taskCount = 10; CountDownLatch latch = new CountDownLatch(taskCount); for (int i = 0; i < taskCount; i++) { int taskId = i; pool.execute(() -> { try { System.out.println("任务-" + taskId + "开始执行"); TimeUnit.MILLISECONDS.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }); } latch.await(3, TimeUnit.SECONDS); // 最多等3秒 System.out.println("任务提交完成,已执行:" + (taskCount - latch.getCount())); pool.shutdown();这里有个值得注意的点:latch.getCount()返回剩余计数,用初始任务数减去它就能得到已完成的个数。这个信息在调试阶段用处极大,可以快速定位“是不是有任务没有执行到 countDown”。
3.3 场景三:通知所有子线程同时开始执行
很多人以为 CountDownLatch 只能“主线程等子线程”,其实它还可以用来“子线程等主线程发令”。只要把await()放在子线程里、countDown()放在主线程里,就能实现所有子线程同时起跑的“比赛开始”效果。
CountDownLatch startGate = new CountDownLatch(1); CountDownLatch endGate = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { new Thread(() -> { try { startGate.await(); // 所有人先在这儿等发令枪 System.out.println(System.currentTimeMillis() + " 线程开始执行"); // 模拟业务逻辑 TimeUnit.MILLISECONDS.sleep((long) (Math.random() * 300)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endGate.countDown(); } }).start(); } System.out.println("准备就绪,准备发令"); TimeUnit.MILLISECONDS.sleep(200); startGate.countDown(); // 发令枪响 endGate.await(); // 等所有线程结束 System.out.println("全部线程已执行完毕");这个“双闸门”模式我在并发压测中用的非常多。startGate 保证所有线程尽量同时进入业务代码,减小线程启动时间带来的误差;endGate 保证压测请求全部返回之后再统计结果。如果你做接口并发测试时感觉“并发度不真实”,往往就是少了 startGate 这个统一发令的动作。
4. 为什么不用 Thread.join 和 CyclicBarrier?选型分析
4.1 CountDownLatch 与 Thread.join 的本质区别
网上讲 CountDownLatch 的帖子都免不了和Thread.join()对比。join()也能让主线程等子线程结束,但它针对的是“等待某个具体线程完成”,语义是面向线程对象的。而且join()还有一个限制:如果子线程使用了线程池,你根本没有一个可以直接join()的线程对象——线程池里的线程是复用的,你拿到的只是Future,future.get()只能单一等待一个任务。
CountDownLatch 则完全面向“事件/计数”,计数减 1 是一个动作,和哪个线程执行没有必然关系。你可以让任意代码在任何时机调用countDown(),甚至可以在一个线程里多次调用。这种解耦让它在复杂流程编排里更灵活。
4.2 与 CyclicBarrier 的区别:一次性 vs 循环使用
CyclicBarrier 字面意思是“可循环使用的屏障”,它允许一组线程相互等待,直到所有线程都到达一个屏障点,然后同时继续执行。CountDownLatch 是一次性的,闸门一旦打开就再也关不上;CyclicBarrier 可以重置后重新使用,适合分段执行多轮任务。
另外一点:CyclicBarrier 是“线程和线程互相等”,参与方是所有到达屏障的线程;CountDownLatch 是“一个或一组线程等另一组线程的操作完成”,参与方和等待方是解耦的。如果你只是“等一件事做完再做下一件”,用 CountDownLatch;如果要做“多轮齐头并进”,CyclicBarrier 更合适。
4.3 和 Semaphore 的区别:别把闸门当锁
Semaphore 管理的是许可证数量,允许多少个线程同时进入临界区,每个线程进入前要acquire(),退出时release(),目的是控制并发度。CountDownLatch 管理的是“还有多少事没做完”,目的是协调先后顺序。两者一个管横向数量,一个管纵向时序,完全不同的维度。硬要把 CountDownLatch 当限流工具用,就会出现“一开始 10 个线程全放进来,计数归零后新来的线程也都放进来”的混乱情况,因为闸门打开后就没有拦的意义了。
4.4 与 CompletableFuture 的取舍
严格来说,CompletableFuture 的大部分“等一批任务完成”场景也能做到,比如allOf().join()。如果项目里已经大量使用 CompletableFuture,确实可以用它替代。但 CountDownLatch 的优势在于极其轻量、语义简单、没有链式调用的心智负担,并且在线程池外部也能随意构造。有些场景你只是需要一个“事件完成信号”,根本不需要 Future 那样复杂的回调结构,这时候 CountDownLatch 更直接。
选型速查表: 1. 只等一批异步任务完成:CountDownLatch 2. 有多个阶段、多轮复用:CyclicBarrier 3. 限制同时执行线程数:Semaphore 4. 阻塞等待某个异步结果:Future / CompletableFuture 5. 在多个资源间协调加锁:Lock / synchronized5. 源码深挖:AQS 里那些默默干活的逻辑
5.1 为什么 CountDownLatch 能保证线程安全
前面提过,计数存在 AQS 的state字段中。state是 int 类型的 volatile 变量,volatile 保证了多线程之间的可见性:一个线程修改了 state,另一个线程能立刻看到最新的值。但 volatile 不能保证复合操作的原子性,所以 AQS 内部通过compareAndSetState(int, int)(CAS)来保证修改的原子性。
countDown()内部循环里不断执行 CAS,如果 CAS 失败(其他线程先改了),就重试,直到成功减去 1。这个循环是轻量级的,不加锁、不阻塞,所以countDown()在高并发下依然性能很好。
5.2 await() 在 AQS 里的完整路径
调用await()时走的是acquireSharedInterruptibly(int arg)。简要代码如下:
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) throw new InterruptedException(); if (tryAcquireShared(arg) < 0) { doAcquireSharedInterruptibly(arg); } }这里tryAcquireShared在 CountDownLatch 内部实现是:
protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }返回 1 表示当前计数已经归零,线程可以直接返回;返回 -1 表示还需要等待,线程会进入doAcquireSharedInterruptibly,把自己封装成节点加入等待队列,然后LockSupport.park()挂起。
当最后一个countDown()把 state 减成 0 时,releaseShared(1)触发,最终调用doReleaseShared()。它会从等待队列的头节点开始,把每个节点对应的线程唤醒。因为是迭代遍历,所以可能存在多个等待线程被唤醒的场景,它们醒来之后发现getState()==0`,直接返回,继续执行后续代码。
5.3 中断与超时在 AQS 层的处理
await(long, TimeUnit)对应的是tryAcquireSharedNanos(int arg, long nanosTimeout)。它先处理超时时间,如果等待时间到了还没被唤醒,直接返回false。如果等待期间被中断,会抛出InterruptedException,而且这个中断信号不会静默吞掉——这是 AQS 框架里比较严谨的设计,保证了线程的生命周期可以被外部干预。
从源码视角看,CountDownLatch 其实只做了一件简单的事:把“线程挂起 + 计数器原子递减 + 全部唤醒”这几个系统级操作封装成一个极其简洁的 API。它的源码总体不超过两百行,但整个设计可以当成并发工具类的教科书,理解了它,后面的 Semaphore、CyclicBarrier 源码都能顺着摸下去。
6. 典型使用场景:从初始化编排到压测基座
6.1 多服务前置检查:并行加载,统一确认
在一块业务系统启动时,往往要并行加载配置中心、缓存预热、拉取远程 Redis 数据等。但如果每块代码都写“执行完毕就返回”,主启动流程会被拖长,可靠性也难保证。
用 CountDownLatch 可以把这些准备工作并行调度起来:
CountDownLatch readyLatch = new CountDownLatch(3); ExecutorService initPool = Executors.newFixedThreadPool(3); initPool.execute(() -> { try { loadConfig(); } finally { readyLatch.countDown(); } }); initPool.execute(() -> { try { warmUpCache(); } finally { readyLatch.countDown(); } }); initPool.execute(() -> { try { refreshRemoteData(); } finally { readyLatch.countDown(); } }); if (readyLatch.await(10, TimeUnit.SECONDS)) { System.out.println("系统初始化完成"); } else { System.out.println("初始化超时,仍有组件未就绪"); }这套逻辑的精髓在于:每个准备任务只关心自己是否完成,主流程只关心“全部完成还是超时”。两者不需要直接依赖对象引用,这是 CountDownLatch 解耦思想的典型体现。
6.2 接口并发压测:先发令,后收尾
接口并发测试的场景稍微复杂一点,因为你既要尽量保证并发请求同时打出去,又要等所有请求返回后再统计。前面的“双闸门”模式在这时就非常有用了。我在实际压测中会把startGate的计数设为 1,等所有工作线程都运行到startGate.await()之后,主线程再startGate.countDown()发令,这样能极大减少线程调度带来的时间差。然后endGate.await()等待全部返回,最后统一汇总耗时分布、错误码、QPS 等指标。
这里有个最容易犯的错误:把countDown()放在业务代码后面的普通代码路径里,而不是放在 finally 里。一旦业务代码里的某一步抛出异常,countDown()根本没执行,endGate永远计数大于 0,压测脚本直接卡死,最后只能靠超时退出。所以凡是 CountDownLatch 用于多线程执行尾部收口的场景,countDown()一律放 finally,没有例外。
6.3 多阶段任务协调:CountDownLatch 串起流水线
如果要协调一个批量文件处理流程:第一批线程读取文件,第二批线程转换格式,第三批线程写入目标库。每一批结束,主线程都用 latch 汇总一下进度;也可以在每个阶段入口放一个await(),达到“流水线闸门”的效果。
CountDownLatch phase1 = new CountDownLatch(readCount); CountDownLatch phase2 = new CountDownLatch(convertCount); // 读取阶段 for (int i = 0; i < readCount; i++) { pool.execute(() -> { try { readFile(); } finally { phase1.countDown(); } }); } phase1.await(); // 转换阶段 for (int i = 0; i < convertCount; i++) { pool.execute(() -> { try { convertData(); } finally { phase2.countDown(); } }); } phase2.await(); System.out.println("批量流水线处理完成");这种串行分阶段的形式比所有任务一股脑丢进线程池好把控得多:你能精确知道每个阶段花了多久,也方便在阶段之间做检查、补偿。如果发现性能瓶颈,也能很快定位到是读取慢还是转换慢。
7. 常见问题与排查技巧实录
7.1 常见问题速查表
| 问题 | 表现 | 原因 | 解决办法 |
|---|---|---|---|
| 任务永远卡在 await() | 没有任何日志输出,线程一直阻塞 | 某个 countDown() 没执行,或者计数设置错了 | 检查 finally 是否覆盖每条路径;打印 getCount() 当前值 |
| 计数器归零了还在等 | 业务逻辑走完了但主线程卡住 | 等待的不是同一个 latch 实例 | 检查是否把 countDown 的 latch 传错对象 |
| 超时后任务还在跑 | await(5s) 返回,但后续还有任务打印日志 | 超时不代表任务已停止,只是“不再等待” | 超时后要主动 shutdown 线程池或者发送中断 |
| 重复使用同一 latch | 第二次使用时计数不能重置 | CountDownLatch 是一次性工具 | 换成 CyclicBarrier 或重新 new 一个 |
| 高并发下计数不匹配 | 最后统计结果比预期少 | 子线程异常导致提前返回,countDown 没走 | 将 countDown 放 finally,并加上 exception 日志 |
7.2 实战排查:一条卡死的压测任务
我在一次压测里完整踩过这个坑。当时压测脚本是 50 个并发线程,每个线程里先执行登录,再执行下单,最后执行支付,endGate 计数设的是 150。脚本跑起来后,前 90% 的请求都正常返回,最后 10 个请求直接卡住,主脚本超时终止。
排查的第一步:在脚本主流程里打印了endGate.getCount(),发现最终停在 3 上,说明有 3 个线程的countDown()没有执行。第二步:给每个子任务加了一个 finally 日志,把异常打印出来。结果发现其中 3 个线程在登录阶段抛了业务异常,直接跳出任务方法体,finally里的countDown()被注释掉了——当时写脚本的同事为了图省事没有把 countDown 放在 finally 里,于是出异常就漏了。
解决办法就一句话:把所有countDown()挪到finally块,并且接受业务异常、统一记日志,不在任务方法里抛出去打断其他线程。改完之后脚本稳定跑通。这个案例特别典型,建议所有用 CountDownLatch 收尾的场景都提前把这条规矩立好。
7.3 排查技巧:利用 getCount() 观察进度
getCount()是 CountDownLatch 里被忽略最多的方法,它返回当前剩余计数,不会改变状态。在调试阶段特别有用,可以直接在主线程等待循环里打印:
while (latch.getCount() > 0 && System.currentTimeMillis() < timeout) { System.out.println("剩余任务数:" + latch.getCount()); Thread.sleep(100); }虽然这种做法不适用于线上环境,但定位“到底还有几个任务没执行完、卡在哪个环节”是非常高效的。配合日志,很快能确认是某个任务根本没有被提交,还是提交了却没有执行。
7.4 避免死锁的黄金法则
用 CountDownLatch 最怕的一个场景是:在子任务里调用了await(),而该任务的countDown()又依赖这个await()返回之后执行。这就形成互相等待的循环依赖,谁都释放不了谁。
// 错误示例 pool.execute(() -> { startGate.await(); // 等主线程发令 businessLogic(); // 业务逻辑抛异常,countDown没执行 endGate.countDown(); });黄金法则就是两条:第一,countDown()永远放 finally;第二,不要在同一个 latch 的等待回调里再去调用同一个 latch 的countDown()(除非你知道自己在做双闸门的双向控制)。
7.5 关于超时参数:别设它很大
很多人在生产代码里写await(60, TimeUnit.SECONDS)或干脆不写超时,觉得等久一点没关系。实际经验告诉我,凡是可能涉及线程池、外部服务调用、批量任务的场景,超时时间都应该尽量偏小,并且超时后的降级逻辑要清晰。等待时间越短,故障恢复越快;一旦某几个子任务因为外部依赖卡死了,主线程最多撑几秒钟就能走降级流程,而不是让整个系统干脆挂死 60 秒。就我自己的体会,线上初始化场景一般 5~10 秒足够了,压测场景看具体耗时分布,一般取正常耗时的 3~5 倍作为上限。
8. 我的实操心得与扩展建议
经常有人问我,CountDownLatch 都出来这么多年了,还有必要专门研究吗?我的回答是:正因为太多人以为自己会用,才会在真正的并发场景里栽跟头。它 API 看起来简单,难点全在场次设计、线程解耦、异常兜底上。
如果让我给出一份学习的路径建议,我会建议你先亲手搭一个双闸门压测脚本,把 100 个并发线程同时打到一个接口上,用 startGate 和 endGate 控制开始和结束;然后故意在一个任务里抛出异常,看看脚本会不会卡死;接着给所有countDown()加上 finally,再重新跑一遍,对比两次的差异。这一套走完,你对主线程等待子线程的直觉和代码习惯能有一个质的提升。
从扩展角度看,等你完全吃透 CountDownLatch 之后,建议继续啃 Semaphore 和 CyclicBarrier,三者底层都是 AQS,接口风格完全不同,但思考逻辑高度一致。你甚至可以在自己项目里写一个语义更丰富的TaskBarrier工具类,把“失败重试”“超时告警”“并发度限制”“分阶段统计”全部揉进去。到这一步,你对 Java 并发工具类的理解就不只是某个类的 API 了,而是能自主组合设计同步原语的能力。
最后分享一个小细节:CountDownLatch 的计数器初始值默认是大于等于 1 的,但如果你是动态获取任务数量来 new latch 的,拿到的任务数可能是 0。如果计数恰恰是 0,await()调用会立刻返回,因为tryAcquireShared()直接返回 1,所有后续逻辑照常执行。这个特性有时是好用的降级,但有时会让“等待任务”形同虚设。写代码前先把空列表和零计数的情况理清楚,能少踩一个隐形坑。