面试被问到 Java 并发工具时,CyclicBarrier 出现的频率非常高。很多人知道它和 CountDownLatch 有点像,都能让线程等一等,但真要问“它凭什么能循环复用”“内部是怎么实现的”“超时之后为什么其他线程也全挂了”,现场能答清楚的人并不多。实际项目里“一组线程同时跑到某个点,互相等齐了再一起往下走”这类需求其实很常见,CyclicBarrier 正是为这种场景设计的同步屏障工具:它让 N 个线程互相等待,直到全部到达屏障点,然后一起放行,并且这个屏障可以反复使用。
这篇内容会从使用场景、源码原理、实战案例到踩坑经验完整过一遍。无论你是准备面试,还是想在真实项目里用它优化多线程任务流程,都能直接拿去做参考。我尽量把“为什么这样做”讲透,而不是只贴 API 用法。
1. CyclicBarrier 到底是干什么的
1.1 一个场景逼出这个工具
想象一下,三个人分工处理一份大表格:A 负责整理客户信息,B 负责计算订单金额,C 负责核对库存。三个人各干各的,但每处理完一版就必须碰一次头,核对结果,确认没问题再继续处理下一版。这种“阶段性汇合,然后继续下一阶段”的协作方式,在并发编程里就是 CyclicBarrier 的典型模型。
而 CountDownLatch 更像是“老板等所有员工下班”:员工们干完活就走,不需要互相等,老板在门口数人头,五个都出来了就锁门。如果员工干完一批活儿还要等同事一起干下一批,CountDownLatch 就不合适了,因为计数器不能重置。CyclicBarrier 则不同,它的设计目标就是多线程之间互相等待,到达屏障点后集体放行,自动进入下一轮。
更深一层理解:CountDownLatch 的语义是“一个或一组线程等待其他线程完成事件”,而 CyclicBarrier 的语义是“一组线程彼此等待,直到所有参与者都就绪”。前者是”等别人“,后者是”大家互等“,这个角色差异决定了它们的适用场景完全不同。
1.2 最小可运行示例
先看一个最简单的用法,把概念落地:
public class CyclicBarrierDemo { public static void main(String[] args) { int parties = 3; CyclicBarrier barrier = new CyclicBarrier(parties, () -> System.out.println(Thread.currentThread().getName() + " 触发 barrierAction:三个线程都到齐了,开始核对数据")); for (int i = 0; i < parties; i++) { new Thread(() -> { try { // 模拟每个线程处理自己的任务,耗时不同 int sleepTime = new Random().nextInt(3000) + 1000; Thread.sleep(sleepTime); System.out.println(Thread.currentThread().getName() + " 处理完自己的任务,耗时 " + sleepTime + "ms,等待其他线程..."); barrier.await(); System.out.println(Thread.currentThread().getName() + " 所有线程都到齐了,继续后续工作"); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } }, "线程-" + (i + 1)).start(); } } }代码逻辑很简单:创建了 3 个线程,各自模拟不同耗时的任务。每个线程执行完自己的部分后调用barrier.await()等待。当三个线程全部调用await()后,屏障打开,其中一个线程会执行构造时传入的barrierAction(实际是最后一个到达的线程执行),随后三个线程从await()返回,继续往下走。
运行结果大致是这样:
线程-2 处理完自己的任务,耗时 1732ms,等待其他线程... 线程-1 处理完自己的任务,耗时 2108ms,等待其他线程... 线程-3 处理完自己的任务,耗时 2765ms,等待其他线程... 线程-3 触发 barrierAction:三个线程都到齐了,开始核对数据 线程-1 所有线程都到齐了,继续后续工作 线程-2 所有线程都到齐了,继续后续工作 线程-3 所有线程都到齐了,继续后续工作注意输出顺序:线程-3 最后一个到达,它触发了barrierAction,然后所有线程同时被释放。这里有个细节值得留意——**到底哪个线程执行barrierAction?**答案是最后一个调用await()的线程。这个特性在实战中很重要,后面会展开讲。
1.3 核心 API 一览
CyclicBarrier的 API 非常精简,常用的就这几个:
| 方法签名 | 作用 |
|---|---|
CyclicBarrier(int parties) | 创建屏障,指定参与线程数 |
CyclicBarrier(int parties, Runnable barrierAction) | 创建屏障,同时指定所有线程到达后执行的动作 |
int await() | 等待所有线程到达,返回当前线程的到达索引 |
int await(long timeout, TimeUnit unit) | 带超时的等待,超时会抛TimeoutException |
void reset() | 将屏障重置为初始状态 |
boolean isBroken() | 查询屏障是否处于损坏状态 |
int getNumberWaiting() | 获取当前正在等待的线程数 |
await()方法的返回值也是一个很容易被忽略的知识点:它返回的是当前线程的到达序号,第一个到达的线程返回parties - 1,最后一个到达的线程返回 0。这个返回值可以在某些场景下用来挑选“领导者”线程执行特殊任务,比随机选一个线程更稳妥。
2. 从源码看 CyclicBarrier:它凭什么能循环
2.1 核心成员:ReentrantLock + Condition + Generation
CyclicBarrier内部结构并不复杂,核心就三个东西:一把锁、一个条件变量、一个“代”对象。
public class CyclicBarrier { private static class Generation { boolean broken = false; } /** The lock for guarding barrier entry */ private final ReentrantLock lock = new ReentrantLock(); /** Condition to wait on until tripped */ private final Condition trip = lock.newCondition(); /** The number of parties */ private final int parties; /** The command to run when tripped */ private final Runnable barrierCommand; /** The current generation */ private Generation generation = new Generation(); private int count; }用生活化的方式理解这三个成员:
lock是会议室的“门锁”,所有线程在操作计数器之前必须先拿锁,保证count的增减是线程安全的。trip是“叫醒服务”,先到线程发现人没齐,就调用trip.await()把自己挂起;最后一个线程到达后,调用trip.signalAll()把所有人唤醒。generation是“第几轮会议”的标记。屏障每成功打开一次,generation就换一个新的,用来区分不同轮次的等待。这是 CyclicBarrier 能循环复用的灵魂。
count是剩余未到达的线程数,初始值等于parties。每有一个线程await(),count就减 1,减到 0 就触发屏障打开。
2.2 await() 方法的完整执行流程
await()内部调用的是dowait方法,核心逻辑如下(省略部分边界判断):
private int dowait(boolean timed, long nanos) throws InterruptedException, BrokenBarrierException, TimeoutException { final ReentrantLock lock = this.lock; lock.lock(); try { final Generation g = generation; // 如果当前这一代已经被标记为 broken,直接抛异常 if (g.broken) { throw new BrokenBarrierException(); } // 线程被中断:把屏障标记为 broken,唤醒所有等待线程 if (Thread.interrupted()) { breakBarrier(); throw new InterruptedException(); } int index = --count; // 如果 count 减到 0,说明所有线程都到了,触发开闸 if (index == 0) { boolean ranAction = false; try { final Runnable command = barrierCommand; if (command != null) { command.run(); // 这里执行 barrierAction } ranAction = true; nextGeneration(); // 开启新一代,唤醒所有等待线程 return 0; } finally { if (!ranAction) { breakBarrier(); // barrierAction 执行异常时,标记屏障损坏 } } } // count 还没到 0,当前线程挂起等待 for (;;) { try { if (!timed) { trip.await(); } else if (nanos > 0L) { nanos = trip.awaitNanos(nanos); } } catch (InterruptedException ie) { if (g == generation && !g.broken) { breakBarrier(); throw ie; } else { Thread.currentThread().interrupt(); } } if (g != generation) { return index; // 新一代开启,说明本线程等到了放行 } if (g.broken) { throw new BrokenBarrierException(); } if (timed && nanos <= 0L) { breakBarrier(); throw new TimeoutException(); } } } finally { lock.unlock(); } }这段代码信息量很大,我从头到尾拆解一遍:
第一,所有对count的操作都在lock保护下进行,这是线程安全的基础。不要以为 Barrier 里还有什么神秘的黑魔法,它就是一把锁加一个条件变量。
第二,屏障打开的关键是最后一个到达的线程。它把count从 1 减到 0,发现“人齐了”,于是执行barrierAction,然后调用nextGeneration()开启新一代:重置count = parties,新建Generation对象,最后signalAll()唤醒所有在trip上等待的线程。
private void nextGeneration() { trip.signalAll(); count = parties; generation = new Generation(); }这里注意顺序:先唤醒,再重置count和generation。signalAll之后,等待线程并不会立刻返回,它们要等当前线程释放lock之后才能重新获得锁并依次返回。所以generation的替换不会干扰其他线程的判断。
第三,挂起等待的循环里有一个关键判断:g != generation。每个线程在进入dowait时把自己的generation记录为g,当条件变量被唤醒后,如果发现自己记录的那一代已经过期(说明屏障已经被打开进入了新一轮),就正常返回。如果发现g.broken为 true,说明屏障被打断了,抛BrokenBarrierException。
2.3 关键设计:新一代(Generation)是怎么换的
理解了Generation,就等于理解了 CyclicBarrier 的灵魂。为什么用“代”而不是简单地把count重置就完事?因为线程被唤醒后需要区分“我是被正常放行的,还是因为屏障被重置/损坏而被迫醒来的”。
场景一:三个线程正常等待,最后一个到达后调用nextGeneration(),此时旧generation被替换成新对象。三个线程醒来,发现自己记录的是旧generation对象,跟当前generation不相等,于是走return index,正常继续执行。
场景二:某个线程等了太久,调用了reset()。reset()里会调用breakBarrier():
private void breakBarrier() { generation.broken = true; count = parties; trip.signalAll(); }注意区别:这里只是把generation.broken标记为 true,并没有替换generation对象。所有还在等待的线程被唤醒后,发现g == generation(对象没变)且g.broken == true,于是统一抛出BrokenBarrierException。这个设计保证了:只有正常开闸才会进入新一代,任何异常中断都不会让旧线程“蒙混过关”。
所以说,“循环使用”并不是简单地把计数器重置,而是通过换新generation来实现。旧一代的线程要么正常返回,要么被标记为 broken,绝不会出现上一轮残留线程混进下一轮的情况。这套机制非常优雅。
2.4 为什么由最后一个线程执行 barrierAction
源码里可以看到,barrierAction是在lock仍然被当前线程持有的情况下执行的。最后一个线程把count减到 0 后,它还没释放锁,先执行command.run(),然后再调用nextGeneration()。
这带来两个比较重要的结论:
第一,barrierAction本质上是在“最后一个到达的线程”的上下文中执行的。如果这个线程是线程池里的某个工作线程,那barrierAction会占用这个工作线程的时间。如果你在barrierAction里写了个很耗时的操作,这个工作线程就被拖住了。
第二,如果barrierAction抛异常,这个屏障会被标记为 broken。源码里ranAction就是干这个的,只要command.run()抛了任何RuntimeException,最后都会走breakBarrier(),导致所有等待的线程全部收到BrokenBarrierException。这一点极容易被忽略,生产环境里我见过因为barrierAction里一个空指针,导致整个批量任务全线崩溃的案例。
3. 实战:一个能直接抄的批量并行任务
3.1 需求与方案设计
讲完原理,来一个真实可复用的场景:假设你手头有 100 万条数据需要处理,每条数据的处理逻辑比较复杂,单线程跑太慢,你决定用 4 个线程并行处理。但这 4 个线程不是完全独立的——每处理完一批(比如每人处理 100 条),就需要把所有线程的结果汇总一次,计算出一个中间指标,然后拿这个指标去处理下一批数据。
这种“每轮同步一次,然后继续下一轮”的模型,用Thread.join()做不了,因为线程的join()只能等线程结束,结束后线程不能重新开始,无法形成循环。手动用CountDownLatch也可以做,但每次循环都得新建一个CountDownLatch,代码非常啰嗦。CyclicBarrier天生就是干这个的。
3.2 代码实现与逐步解读
public class BatchProcessTask { private static final int THREAD_COUNT = 4; private static final int BATCH_SIZE = 100; private static final int TOTAL_ROUNDS = 5; // 模拟数据源 private static final List<Integer> DATA = new ArrayList<>(); static { for (int i = 0; i < 2000; i++) { DATA.add(i); } } // 记录每个线程的批次处理结果 private static final Map<String, List<Integer>> ROUND_RESULT = new ConcurrentHashMap<>(); private static volatile int round = 0; public static void main(String[] args) { ExecutorService executor = Executors.newFixedThreadPool(THREAD_COUNT); // 每轮所有线程都到达屏障后,执行汇总动作 CyclicBarrier barrier = new CyclicBarrier(THREAD_COUNT, () -> { System.out.println("第 " + (round + 1) + " 轮汇总: " + ROUND_RESULT); // 汇总完后进入下一轮,清空结果 ROUND_RESULT.clear(); round++; }); for (int t = 0; t < THREAD_COUNT; t++) { final int threadId = t; executor.submit(() -> { try { int start = threadId * BATCH_SIZE; while (round < TOTAL_ROUNDS) { // 每个线程处理自己负责的那一批数据 int end = start + BATCH_SIZE; // 这里是模拟的业务处理:累加数据片段 int sum = 0; for (int i = start; i < end; i++) { sum += DATA.get(i); } ROUND_RESULT.put(Thread.currentThread().getName(), sum); // 等待所有线程完成本批次 barrier.await(); // 注意:这里需要重新计算下一轮的数据起点 // 真实场景中可能从某个队列取任务,但这里为了保证示例简单 // 让每轮数据起点向后移动 BATCH_SIZE * THREAD_COUNT if (round < TOTAL_ROUNDS) { start = round * BATCH_SIZE * THREAD_COUNT + threadId * BATCH_SIZE; } } } catch (InterruptedException | BrokenBarrierException e) { Thread.currentThread().interrupt(); e.printStackTrace(); } }); } executor.shutdown(); } }这里有个关键点需要说明:代码里用了round变量来控制循环轮次,barrierAction里会更新round并清空结果集合。由于barrierAction执行时,所有业务线程还处于await()挂起状态,所以ROUND_RESULT的读写在这个时刻不会产生竞争,安全性上是可以保证的。但要注意,ROUND_RESULT本身还是用了ConcurrentHashMap,因为不同线程在await()之前各自put自己的结果,这个阶段是并发的,必须用并发安全的集合。
3.3 一个重要问题:barrierAction 里的耗时操作
如果你在barrierAction里做太多事,会拖慢整个流程。原因前面分析过:barrierAction是在最后一个到达线程的上下文里执行的,而且它在持有锁的状态下运行。其他线程虽然在trip.signalAll()之后被唤醒了,但还需要等锁释放才能真正返回。
实测下来,如果barrierAction耗时 1 秒,那每个批次之间的间隔就会增加 1 秒,而且这个时间只算在最后一个到达线程头上,看起来就是“那个线程被卡住了”。所以建议是:barrierAction里只做轻量级的汇总和状态更新,重量级操作(比如写库、通知外部系统)放到普通线程的await()返回之后自行处理。
如果需要更精细的控制,比如汇总之后的下一轮任务分配,可以在barrierAction里只准备状态,让各个线程await()返回后自己判断“现在该做什么”。这样barrierAction保持轻量,线程的职责也更清晰。
3.4 与 CountDownLatch 的选型对照
很多人分不清什么时候用CyclicBarrier、什么时候用CountDownLatch,这里给一个对照表,建议收藏:
| 对比维度 | CyclicBarrier | CountDownLatch |
|---|---|---|
| 核心语义 | 一组线程互相等待,都到达后一起放行 | 一个或多个线程等待其他线程完成事件 |
| 是否可复用 | 可以循环使用 | 不可复用,计数器归零后失效 |
| 参与线程角色 | 所有线程都是对等的参与者 | 等待线程和被等待线程角色不同 |
| 是否支持回调 | 支持 barrierAction,可在开闸时执行动作 | 不支持 |
| 实现基础 | ReentrantLock + Condition + Generation | AQS 同步状态 |
| 异常处理 | 有 BrokenBarrierException 机制 | 没有“损坏”概念 |
| 适用场景 | 多阶段任务、并行迭代、回合制流程 | 服务启动等待多个组件就绪、等待多个任务完成 |
选型时可以记两条经验:如果需求是“等一组线程都做完了就继续”,而且只发生一次,优先CountDownLatch;如果需求是“多个线程每到一个阶段都要同步一下,还要继续往下跑”,直接上CyclicBarrier。另外还有一个细节:CountDownLatch 等待方和被等待方不是同一批线程,而 CyclicBarrier 里所有线程既是等待方也是被等待方,这个语义差异往往决定了选型方向。
4. CyclicBarrier 避坑手册:这些问题你早晚会遇到
4.1 等待超时后,整个屏障就“废”了
实战中最常见的坑,就是某个线程的await()设置了超时时间,结果超时之后整个屏障被打上 broken 标记,其他所有线程跟着遭殃:
CyclicBarrier barrier = new CyclicBarrier(3); // 线程 A barrier.await(1, TimeUnit.SECONDS); // 可能抛 TimeoutException // 线程 B 和 C barrier.await(); // 会收到 BrokenBarrierException,而不是继续等待源码里已经看得很清楚:线程 A 超时后,会走breakBarrier()把 generation 标记为 broken,然后唤醒所有等待线程。线程 B 和 C 醒来后发现generation.broken == true,直接抛BrokenBarrierException。
在实际业务中,这意味着:如果你给await()加了超时,那么超时处理不能只考虑当前线程,必须考虑所有线程的状态。推荐的兜底做法是捕获异常后调用barrier.reset(),让整个团队重新来一轮,同时做好失败补偿:
try { barrier.await(5, TimeUnit.SECONDS); } catch (TimeoutException e) { barrier.reset(); // 重置屏障,让其他线程也能恢复 // 记录日志,重试或降级 } catch (BrokenBarrierException e) { // 说明有其他线程超时或被打断 }另外想提醒一点:不要把超时时间设得太“极限”。生产环境里线程的实际执行时间很容易受 GC、IO 波动影响,我见过因为 GC 停顿导致某个线程超时,进而引发连锁 broken 的案例。设置超时时要把这些因素算进去,留足缓冲。
4.2 reset() 之前,想清楚谁还在等待
reset()方法的作用是“回到初始状态”,但它的实现方式是调用breakBarrier()再nextGeneration():“先打断旧的,再开新的”。这意味着,如果reset()被调用时还有线程正在await(),这些线程会收到BrokenBarrierException,而不是在下一次开闸时自动进入新一轮。
看源码里reset()的实现:
public void reset() { final ReentrantLock lock = this.lock; lock.lock(); try { breakBarrier(); // 打断当前代 nextGeneration(); // 开启新一代 } finally { lock.unlock(); } }所以在编码时要有一个明确约定:reset()只能在确认没有线程处于等待状态时调用,或者调用前先通过getNumberWaiting()检查等待数。如果线程还在等待,你强行 reset,本质上相当于“告诉所有等待线程:这轮不算了,你们自己想办法处理”。
正确的安全重置模板:
private void safeReset(CyclicBarrier barrier) { if (barrier.getNumberWaiting() > 0) { // 还有线程在等待,不能直接 reset // 策略 1:等待一段时间再检查 // 策略 2:调用 reset 并让等待线程捕获 BrokenBarrierException 后重试 } }但说实话,实战中我很少主动调reset()。因为一旦reset()被错误调用,线上问题非常隐蔽,日志里只会看到大量BrokenBarrierException,排查起来很费劲。更好的做法是:从设计上避免需要 reset 的场景,把每一轮任务设计成“要么全部成功,要么整体重试”。
4.3 一个线程中断,所有线程“陪葬”
InterruptedException的处理逻辑前面源码里也看到了:如果某个线程在trip.await()中被中断,它会调用breakBarrier()把屏障标记为 broken,然后抛出InterruptedException。其他线程被唤醒后抛BrokenBarrierException。
这个设计其实是有意为之:CyclicBarrier 追求的是“集体一致性”,任何一个成员异常退出,整个屏障都视为失效,不允许出现“部分线程继续跑,部分线程被中断”的割裂状态。这符合它的使用场景——需要团队协作的流程必须要保证所有参与者的状态一致。
但作为开发者要理解一个衍生问题:BrokenBarrierException和InterruptedException是两种不同的异常。前者表示屏障损坏,后者表示线程被中断。异常捕获时不要简单地打成一行日志,要区分场景:
catch (InterruptedException e) { // 当前线程被外部中断,处理线程中断状态 Thread.currentThread().interrupt(); // 屏障已经 broken,其他线程会陆续收到 BrokenBarrierException } catch (BrokenBarrierException e) { // 屏障被打破,需要决定:重试?重置?还是放弃本轮? }4.4 线程池与屏障不匹配:核心线程数小于 parties 会死锁
这个坑非常隐蔽,我第一次踩到的时候排查了整整一个小时。场景是这样的:
ExecutorService executor = Executors.newFixedThreadPool(2); // 只有 2 个线程 CyclicBarrier barrier = new CyclicBarrier(4); // 需要 4 个线程到达 for (int i = 0; i < 4; i++) { executor.submit(() -> { try { barrier.await(); } catch (Exception e) { e.printStackTrace(); } }); }你提交了 4 个任务,但线程池只有 2 个工作线程。前两个任务占用了线程,它们调用barrier.await()后挂起等待;但线程池里已经没有空闲线程去执行剩下的两个任务,于是两个线程永远在await()中等待,剩下的任务永远排不到,整个程序卡死。
这不是 CyclicBarrier 本身的问题,而是线程池工作线程数量必须大于等于parties的基本约束(严格说还要考虑任务队列的阻塞情况)。如果使用无界队列的FixedThreadPool,连前两个线程都不一定能跑起来,因为任务全进队列了,只有核心线程忙完一个才能取一个新任务,而核心线程又都在await()里挂着,形成了死锁。
解决方案有三种:一是保证线程池核心线程数 ≥parties;二是用SynchronousQueue或ArrayBlockingQueue配合CallerRunsPolicy之类的拒绝策略;三是直接用new Thread启动,不使用线程池。第三种方式简单粗暴,但对线程生命周期不做管理,适合很快结束的任务。我一般推荐第一种,同时给线程池设置合理的拒绝策略和监控告警。
4.5 barrierAction 异常导致的连锁崩溃
前面源码解读时提到,barrierAction抛异常会导致breakBarrier(),所有等待线程收到BrokenBarrierException。实战中这个点很容易被忽视,因为大家通常只在业务代码里加 try-catch,忘了barrierAction也算业务代码。
这里给一个非常实际的建议:barrierAction里的任何逻辑都要包 try-catch,尽量把异常吞掉或记录日志后继续。因为barrierAction的主要目的是做轻量级的“集体状态更新”,如果因为它自身的失败而导致整个团队崩溃,代价太大。当然,如果业务上确实需要“汇总失败就整体重试”,那保持默认行为也不失为一种方案,只是要有兜底逻辑。
4.6 问题速查速用表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 程序卡死,线程全部 BLOCKED | 线程池线程数 < parties | 调整线程池核心线程数 |
| await() 抛 TimeoutException | 某个线程执行太慢 | 增大超时时间;捕获后处理 broken 状态 |
| 其他线程抛 BrokenBarrierException | 某个线程超时/中断/异常 | 捕获后 reset 或整体重试 |
| 某轮结果异常,下一轮数据错乱 | barrierAction 里状态更新不及时 | 把重量操作移出 barrierAction |
| barrierAction 抛异常导致全崩 | action 内部未捕获异常 | 给 barrierAction 加 try-catch |
| 线程被中断后任务全废 | 中断打破屏障,整体失效 | 区分中断源,管理线程中断状态 |
5. 从面试角度看 CyclicBarrier:能答到什么深度
5.1 原理解答的层次感
面试里被问到 CyclicBarrier 时,大部分人的回答停留在“它是一个循环屏障,可以让多个线程互相等待”。这个回答只能说及格。如果想表现得更专业,可以从三个层次递进回答:
第一层是“是什么”,介绍它的核心语义和基本用法,跟 CountDownLatch 的区别。
第二层是“怎么做到的”,提一下内部基于 ReentrantLock 和 Condition 实现,每个await()会让count减一,减到零时触发barrierAction并开启新一代Generation。
第三层是“为什么这么设计”,重点讲Generation的作用——它是区分正常放行和异常打断的关键,reset()和breakBarrier()的区别就在这里。能讲到这一层,基本能看出你对源码是真的读过且理解了。
我自己在面试别人的时候,通常还会追问一句“barrierAction 在哪个线程执行”。很多人答不上来,或者只会说“某个线程”,但说不出是“最后一个到达的线程”。这个细节虽然小,但能看出候选人是否认真思考过框架代码的执行上下文。
5.2 和 AQS、Condition 的关系
聊到源码层面,一个自然的延伸是 CyclicBarrier 和 AQS 的关系。注意,CyclicBarrier 本身并没有继承 AQS,它是组合使用 ReentrantLock 和 Condition 实现的。而CountDownLatch内部是通过 AQS 的共享锁机制实现的。同样是“线程等待”的工具,底层的实现路径完全不同。
ReentrantLock 持有 Condition,trip.await()本质是释放锁并挂起线程,trip.signalAll()本质是唤醒等待中的线程并把它们重新放回锁竞争队列。CyclicBarrier 的精妙之处在于,它在锁的保护下维护了一个“剩余线程数”的计数器,配合 Condition 的等待/唤醒机制,实现了一个“可重复使用的闭锁”。理解 Condition 的“等待队列”和“同步队列”切换,对读懂这套源码非常有帮助。
5.3 扩展思考:如果让我自己实现一个 Barrier
面试官如果问“不用 CyclicBarrier,你怎么实现同步屏障”,这是在考察对并发原语的理解深度。最简单的方案是:用一把 ReentrantLock + 一个 Condition,维护一个 count,每来一个线程 count—,count 到零就 signalAll,然后重置 count 进入下一轮。这其实就是 CyclicBarrier 的简化版。
进一步考虑:如果要求“避免使用锁”呢?那可以用 CAS 来更新 count,实现一个自旋屏障。不过要注意,自旋屏障在高并发下会消耗大量 CPU,需要权衡。把这些思路理一理,面试时的知识体系会显得很立体。
6. 写在最后的个人体会
用 CyclicBarrier 这几年,最大的体会就是:并发工具没有银弹,每一种都有明确的适用边界。CyclicBarrier 是“团队协作”语义的典型代表,它强调的是所有参与者步调一致、集体行动,也正因为这种强一致性,它才设计了broken机制来应对任何成员的异常。这个取舍在很多并发设计里都能看到影子:要么大家一起成功,要么大家一起失败。
如果非要说一条最值得记住的经验,那就是——使用 CyclicBarrier 之前,先明确你的业务是否真的需要“全员对齐”。如果你的需求只是“发一个开始信号,大家各自干活,干完各自结束”,那 CountDownLatch 甚至什么都不用,直接线程池加 Future 就够用了。硬上 CyclicBarrier 反而可能因为某个线程卡顿,拖垮整个团队。搞清楚并发模型之间的语义差异,比多记住几个 API 有用得多。
最后再分享一个小技巧:调试 CyclicBarrier 问题时,jstack是你的好朋友。线程处于trip.await()挂起状态时,dump 出来会显示java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(),一眼就能看出是卡在屏障等待上。再结合getNumberWaiting()观察等待数,就能快速定位是不是线程池配置问题导致永不到齐。这套排查方法,比我当年靠日志猜来猜去高效太多了。