并发工具类这题,答对定义的人很多,答对默认行为的人很少。面试官很少问"它是什么",问的都是"计数没归零会怎样""屏障人数对不上会怎样""await 会不会一直阻塞"。这些才是真实项目里最容易出事的地方,也是这题的采分点。
先把结论放在前面:CountDownLatch 和 CyclicBarrier 都基于 AQS 的共享模式实现,但语义完全不同。CountDownLatch 是一次性的事件门闩——计数只减不增,归零后持续保持打开状态,重复 await 立刻返回,适合"等若干个任务完成"或"等某个信号到来"。 CyclicBarrier 是可复用的集合点——所有参与方 await 到齐后一起通过,随后计数自动重置进入下一轮,适合"分阶段并行计算"。而 Semaphore 走的是独占模式的 AQS,用许可数控制同时访问某个资源的线程数量,典型用途是限流与资源池。
机制拆解
讲清默认行为与边界,这题就成了一半。CountDownLatch 的计数只减不增,所以它无法重置;await() 不带参数版本会无限期阻塞,一旦某个计数任务抛异常提前退出,等待方就永久挂起,这是生产环境里最难查的一类卡死。带超时参数版本是必须的习惯。 CyclicBarrier 则是全体到齐才放行:只要有一个参与方超时退出或抛异常,其余线程都会收到 BrokenBarrierException,屏障进入损坏状态且不可恢复,只能重建;所以参与方数量必须与实际会调用 await 的线程数严格一致,而且放行前执行的栅栏动作若抛异常,同样会打破屏障。
这些坑的正确绕法
最常见的坑是 latch 计数没归零前任务抛异常导致 await 永久阻塞,必须设置超时兜底。 典型场景是"等三个接口都返回再渲染",其中一个接口超时抛异常,countDown 没执行,主线程就卡在 await 上,界面停在空白。 修法有两层:代码层把 countDown 放在 finally 里保证执行;接口层用带超时的 await(timeout, unit),即使没齐也走降级逻辑继续渲染。
其次是屏障等待人数与实际参与线程数不一致,所有线程卡死在栅栏上。 最常见的原因是参与方数按固定值写死,但代码里有条件分支导致某些线程没走到 await;或者用的是带超时的重载,超时后线程走了异常分支,剩下的人却还在等。屏障的正确用法是让所有参与方无条件走同一个 await 出口,超时与异常都要在到达屏障之前处理干净。
还有一个更隐蔽的坑:把 Semaphore 当限流用,却把许可数设成远大于实际能承受的并发。 限流的前提是"许可数就是资源能同时承受的量",比如数据库连接数是 20,许可数就该是 20 附近;设成 1000 等于没有限流,只是把压力原样推给下游。 另一个常被忽略的点是 acquire() 同样默认无限期阻塞,且 release() 应写在 finally 里——许可被吞掉后,后面所有线程会一起卡死。
给 CountDownLatch 与 CyclicBarrier 建一份边界清单,注释里写清楚,单测覆盖边界与异常路径。 落成几条:所有 await 一律带超时;countDown 与 release 一律放 finally;CyclicBarrier 的参与方数量集中定义为一处常量;栅栏动作里不做可能抛异常的业务逻辑。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// latch 一次性门闩;barrier 可复用集合点
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
new Thread(() -> {
try { work(); } finally { latch.countDown(); } // 放 finally 防泄漏
}).start();
}
latch.await(5, TimeUnit.SECONDS); // 必须带超时,避免永久阻塞
CyclicBarrier barrier = new CyclicBarrier(3, () -> allReady()); // 栅栏动作
barrier.await(); // 互相等,可循环用于分阶段同步
// 计数只减不增 vs 全体到齐放行后重置
这段代码值得盯三处:第一处,countDown 放在 finally 里,是防止计数泄漏的关键;第二处,await 带超时参数,避免异常路径把调用方永久挂起;第三处,CyclicBarrier 的第三个参数是栅栏动作,在全员到齐后执行一次。面试讲到这一层,基本就稳了。
这题在面试里怎么问、怎么答
"请简单介绍一下 CountDownLatch 与 CyclicBarrier,它在 Android 开发中起什么作用?"先各给一句定位:latch 是"等若干件事发生",barrier 是"等若干方到齐再一起走"。再补场景:latch 用于"等三个初始化任务完成再刷新界面",barrier 用于"分批并行处理数据、每批处理完汇总一次"。 再落一个细节:两者都基于 AQS 共享模式,理解 AQS 的状态量就同时看懂了这两个工具,加上独占模式的 Semaphore 就是并发工具的完整脉络。
"CountDownLatch 与 CyclicBarrier 的底层原理是什么?能不能详细说一下? "从 AQS 入手:CountDownLatch 用一个 volatile 状态表示剩余计数,countDown 走 CAS 减一,减到 0 时在 AQS 队列上唤醒等待的共享节点;CyclicBarrier 则用一个 ReentrantLock 加两个计数器( parties 记录总数、count 记录已到达数),await 里先自旋判断自己是否到齐、是否为首个到达者,再由首个到达者执行栅栏动作并重置计数。 两者状态变量都依赖 volatile 与 CAS 保证可见性与原子性。讲到这个层面,这题基本可以给高分。
"在使用 CountDownLatch 与 CyclicBarrier 时遇到过什么问题?"拿真实案例。一个典型案例:首页多个模块并行加载,用 barrier 汇总,但其中一个模块在低配设备上走了不同的降级分支、没执行 await,结果整个屏障超时,所有模块的进度条一起卡住;定位到是参与方路径不一致;修复为把降级分支也统一走到屏障出口,并把超时时间与降级渲染解耦;验证是低端机不再出现等待卡死。
"和相关的替代方案相比,有什么优劣?"对比 Future 的 get、join、CompletableFuture 三条线。结论落在场景:只等"整体完成"用 join 或 latch 更轻;要收集多个结果并处理异常用 CompletableFuture;要分阶段同步且可复用用 barrier。选型时把"计数泄漏导致永久阻塞"这类代价摆到台面上,再决定是否引入。
再补一个工程上值得讲清的点:为什么 latch 的计数不能重置,以及这个限制带来的实际影响。CountDownLatch 的语义就是"一次性的开门信号",AQS 的状态量只支持减到 0,归零后所有 await 立刻返回、共享节点被摘出队列,没有任何机制把它恢复成非零。所以同一个 latch 不能用于"第二轮栅栏"——第二轮一开始所有 await 都会直接放行,等于没有等。 要支持多轮同步,就得重建一个新的 latch,或者改用 CyclicBarrier(它会在栅栏动作后自动重置计数)、或者干脆换成 Phaser(支持动态注册参与方与多阶段推进)。面试里主动讲清这个限制并给出替代方案,通常比只背定义高一个层次。
给正在准备面试的你
把并发工具画成一张对照表:工具、状态量、能否重用、等待方失败时如何、AQS 模式(共享/独占)、典型用途六个维度各填一行。面试中关于并发工具的问题,关键在于能够从原理、应用、踩坑三个层面给出有深度的回答。
再补工程案例与踩坑——应用落点是排查一个"首页偶发加载不完成"的线上问题:加日志确认哪个模块没走 await,把 await 全改为带超时、把 countDown 移进 finally,并补一个"参与方抛异常"的单测用例。
复习时别孤立刷题:线程池七参数——并发工具常与线程池搭配,任务编排的边界要一起划清。
划两句重点:latch 计数只减不增、归零后永久打开,await 一律带超时且 countDown 放 finally;barrier 要全员到齐才放行、有人超时或抛异常会整体 BrokenBarrierException,参与方数量必须与实际调用数严格一致。
下一篇聊 CAS 与原子类:无锁编程的第一课——沿着今天这条主线继续往前走。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:线程池七参数:ThreadPoolExecutor-从配置到调优
下一篇预告:CAS-与原子类:无锁编程的第一课
有任何问题欢迎在评论区留言交流。