19、Java并发编程:CountDownLatch和CyclicBarrier:如何让多线程步调一致?

写了十几年代码,我发现一个规律:真正让人加班到凌晨的,往往不是业务逻辑太难,而是线程之间"没对齐"。
一个商品详情页接口,你开了四个线程分别去查商品、库存、价格、评论,代码写完只花十分钟,上线后却天天收到"价格那块偶尔是空的";一个服务启动脚本写着 start success,注册中心连接池其实还在建,第一批流量进来直接 500;一批分库分表的数据迁移,阶段二已经开跑,阶段一的最后一个分片还没写完,对账报表差了三十万条。
这些问题的共同点只有一个:某个线程在别人还没准备好的时候,自己先走了。
CountDownLatch 和 CyclicBarrier 就是 JDK 给这个场景准备的两把"指挥棒"。它们不解决业务,只解决一件事——让多线程的步调一致。这篇文章把它俩从痛点、定义、演进、源码、用法、企业落地一直讲到面试题,力求让没有并发基础的同学也能照着上手。
文章目录
一、痛点场景:三个真实翻车现场

在讲工具之前,先把"病"看清楚。下面三个场景,是我在真实项目里见过、在面试里被候选人复述过、也在技术群里被反复求助过的。
1.1 场景一:主线程提前交卷
电商详情页的聚合查询是最典型的例子。商品、库存、价格、评论四个服务各自独立,串行调用要四百毫秒,并发调用理论上只要一百毫秒出头。于是很多同学写出了这样的代码:
// 反例:以为"提交完"就等于"做完了"
public ProductDetailVO detail(long skuId) {
ProductDetailVO vo = new ProductDetailVO();
pool.execute(() -> vo.setProduct(productClient.get(skuId)));
pool.execute(() -> vo.setStock(stockClient.get(skuId)));
pool.execute(() -> vo.setPrice(priceClient.get(skuId)));
pool.execute(() -> vo.setComment(commentClient.page(skuId)));
return vo; // 四个线程可能连方法都还没进
}
这段代码不会报错,也不会抛异常,它只会稳定地"偶尔缺字段"。因为 execute 只是把任务交给线程池,方法立刻返回。主线程拿着一个还没被填充的对象去序列化成 JSON,前端看到的就是 price: null。
更隐蔽的是:本地测一百次都正常,因为本地机器快、线程池空闲、任务几乎是同步完成的;一上生产,线程池排队、RPC 抖动,缺字段的概率从百分之一涨到百分之三十。
大白话:你点了三份外卖,付完钱就坐到餐桌前开吃,桌上当然什么都没有。你缺的不是外卖,是"等一会儿"这个动作。
1.2 场景二:服务启动了,其实没就绪
第二个场景发生在微服务冷启动。启动流程里有三件异步初始化的事:拉取配置中心的开关、预热本地缓存、初始化注册中心的客户端连接池。有人为了让启动快一点,把这三件事丢给线程池并行做,然后主流程继续往下走,日志打出"started on port 8080"。
注册中心还没连上,网关已经把流量放进来了。第一个请求打到 @Reference 的 Dubbo 调用上,直接抛出"无可用 provider",整条链路 500,链路监控瞬间飘红,运维半夜打电话问你是不是发布回滚没做干净。
大白话:饭还没熟你就喊"开饭",全家坐上来只能干看着。启动完成不等于初始化完成。
1.3 场景三:多阶段任务步伐错位
第三个场景在批处理里最致命。分库分表迁移、多阶段对账、离线跑批,往往有明确的三段:先写主表、再校验、最后建索引。三个阶段之间有严格的先后关系,而每个阶段内部是分片并行的。
如果只是"分片并行提交完就接着跑下一阶段",那么快的分片已经进了阶段二,慢的分片还卡在阶段一的网络 IO 上。阶段二读到的数据一半是新的一半是旧的,最后对账报表差了三十万条,查了三天才定位到是"阶段之间没对齐"。
大白话:接力赛里,上一棒还没把棒递到你手里,你就先跑了,成绩当然无效。这里要的不是"谁快谁先走",而是"到齐了再一起走"。
1.4 痛点归纳
| 现象 | 真正的根因 | 线上后果 | 正确的姿势 |
|---|---|---|---|
| 聚合结果偶尔缺字段 | 主线程没有等待点,异步提交即返回 | 客诉、转化率下降、难以复现 | 主线程等 N 个子任务完成(CountDownLatch) |
| 启动成功但接口 500 | 初始化没有完成信号 | 发布窗口故障、回滚 | 初始化完成前阻塞主流程(CountDownLatch) |
| 多阶段数据错位 | 阶段之间没有屏障 | 数据不一致、对账不平 | 每个阶段结束设一道屏障(CyclicBarrier) |
| 等待没有上限 | await() 没有超时 | 线程悬挂、线程池被拖死 | 一律使用 await(timeout, unit) |
| 某个子任务异常后永久卡死 | countDown() 没写在 finally | 接口整体超时雪崩 | 计数必须放在 finally |
看清楚这张表你会发现:这些问题没有一个是"业务逻辑写错了",全都是"同步语义没设计"。而 JDK 在 java.util.concurrent 包里,早就给了标准的答案。
二、痛点的解决方案:把"等待"交给专门的工具

先记住两句人话,这两句话是全文的骨架:
CountDownLatch:一个线程等另外 N 个线程把事做完,做完就放行,且只放行一次。CyclicBarrier:N 个线程互相等,人到齐了一起走,走完还能再来一轮。
再补一句选型口诀:"等别人"用 Latch,"等彼此"用 Barrier。
所谓"步调一致",拆开其实是四件事:
- 同时起跑:所有线程在同一条起跑线上,发令枪一响一起冲。
- 等全部完成:主线程必须等到所有子任务都结束,才能汇总。
- 分阶段齐步:每一阶段结束,所有参与者到齐后才能进入下一阶段。
- 超时兜底:等待必须有上限,不能无限悬挂。
CountDownLatch 天然覆盖第 1、2 件事,CyclicBarrier 覆盖第 3 件事,而第 4 件事两者都提供了带超时的 await 重载。把这三个能力组合起来,上面那三类翻车现场就都有了标准解法。
三、是什么:专业解释、大白话、生活案例
3.1 CountDownLatch:倒计时门闩
专业解释
JDK 官方对它的定义是:A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(一种同步辅助工具,允许一个或多个线程等待,直到其他线程中执行的一组操作完成。)
它内部维护一个计数器,构造时指定初始值 count。调用 await() 的线程会被阻塞,直到计数器因为其他线程调用 countDown() 而归零;归零之后,所有等待线程被释放,之后再调用 await() 会立刻返回。这是一次性现象,计数器无法重置。
底层它继承自 AQS(AbstractQueuedSynchronizer)的共享模式,计数值就存在 AQS 的 state 字段里,countDown() 用 CAS 把 state 减一,减到零时唤醒队列里的等待线程。
大白话
它就是一个"倒计时门闩"。门闩上挂着 N 把锁,每完成一件事就摘掉一把,N 把全摘完,门才打开;门一旦打开,就再也关不上了。
生活案例
公司团建包了一辆大巴。导游说"人到齐了才发车",于是先到的人坐在车上等,每来一个人就在名单上划掉一个名字,名单划完,司机才启动。注意两个细节:第一,先到的人不会自己开车走,他们必须等;第二,这辆车今天只发这一趟,明天再想用,得重新包一辆——这正是"不能重置"的含义。
三个典型用法
JDK 注释里其实已经把用法写清楚了,一共三种:
| 用法 | 计数设置 | 场景 |
|---|---|---|
| 开关 / 发令枪 | new CountDownLatch(1) | 所有线程在门口等一个信号,信号一到同时开跑 |
| 一等 N | new CountDownLatch(N) | 主线程等 N 个子任务全部完成 |
| N 等一 | new CountDownLatch(1) | N 个工作线程等一个"总开关"打开 |
核心 API
| 方法 | 作用 | 注意点 |
|---|---|---|
await() | 阻塞直到计数归零 | 无超时,线上禁用 |
await(timeout, unit) | 带超时等待,返回是否归零 | 生产环境首选 |
countDown() | 计数减一,减到零唤醒全部等待者 | 必须放在 finally |
getCount() | 返回当前计数 | 官方说明主要用于调试和测试 |
JDK 还明确写了一条内存语义:在计数归零之前,某个线程调用 countDown() 之前的操作,happen-before 另一个线程从 await() 成功返回之后的操作。这句话听起来拗口,翻译成人话就是:子线程写的数据,主线程 await 返回后一定看得见,不需要你再加任何 volatile。这是很多人忽略的价值点。
3.2 CyclicBarrier:循环栅栏
专业解释
JDK 官方定义是:A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(一种同步辅助工具,允许一组线程全部互相等待,直到到达一个公共屏障点。)因为栅栏在释放等待线程后可以被重用,所以叫"循环(Cyclic)"栅栏。
它的内部结构和 CountDownLatch 完全不同:用的是 ReentrantLock + Condition,而不是 AQS 的共享模式。核心字段有三个:parties 是参与者总数,count 是剩余未到达数,generation 是"代",用来标识当前是哪一轮。
每个线程调用 await() 时,会在锁保护下把 count 减一。最后一个到达的线程(count 减到 0)会做三件事:执行可选的 barrierCommand、唤醒所有等待线程、调用 nextGeneration() 复位计数并换代。于是同一批线程可以接着进入下一轮。
大白话
它是一道"人齐了才开的门"。门上有 N 个座位,来一个人坐一个,坐满 N 个,门自动打开,所有人一起走出去;门随即复位,下一轮还能用。
生活案例
大学小组讨论。老师规定"人到齐了才开始",先到的三个人只能刷手机等,第四个人一坐下,讨论立刻开始,谁也不能提前发言。讨论完这一轮,大家去查资料,回来再讨论第二轮——还是那四个人,还是那道门,这就是"循环"。
核心 API
| 方法 | 作用 | 注意点 |
|---|---|---|
await() | 到达屏障并等待其他人 | 返回"到达索引",parties-1 表示第一个到 |
await(timeout, unit) | 带超时等待 | 超时会打破屏障,抛 TimeoutException |
reset() | 重置到初始状态 | 正在等待的线程会收到 BrokenBarrierException |
isBroken() | 屏障是否已损坏 | 排查问题很有用 |
getNumberWaiting() | 当前已等待的线程数 | 监控、日志 |
getParties() | 参与者总数 | 常用于校验配置 |
它是怎么"循环"起来的

generation 这个设计很妙。每一轮是一个 Generation 对象,里面只有一个 boolean broken 标记。当最后一个线程到达时,nextGeneration() 会做三件事:signalAll() 唤醒所有等待者、把 count 复位成 parties、新建一个 Generation。等待中的线程醒来后,通过比较手里的 g 和当前的 generation 是否相同,就能判断"我是被正常放行的,还是这一代已经作废了"。
这也解释了一个高频问题:为什么 CyclicBarrier 能复用,而 CountDownLatch 不能。因为栅栏的状态是"每代独立"的,换代即重置;而 Latch 的 state 一旦归零就永久停在那里,JDK 根本没有提供"再加回去"的入口。
它最容易被忽略的能力:barrierAction
构造函数支持传入一个 Runnable,它会在最后一个线程到达时、唤醒其他人之前执行,而且是由最后一个到达的线程来跑。这个位置适合做"阶段汇总":比如每一轮分片计算结束后,在这里把各分片的中间结果合并,再一起进入下一轮。反过来也要注意:这个 Runnable 里千万不要做耗时操作或阻塞等待,它跑得越久,整组线程就被拖得越久;一旦它抛异常,整代屏障直接作废,所有人收到 BrokenBarrierException。
3.3 本质区别:一句话说透
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 谁在等谁 | 一个(或几个)线程等另外 N 个线程 | N 个线程互相等,谁也不能先走 |
| 计数方向 | 只减不增,归零即终止 | 减到零后自动复位,可反复使用 |
| 可复用 | 一次性 | 循环使用 |
| 底层实现 | AQS 共享模式(state 计数) | ReentrantLock + Condition + generation |
| 典型语义 | “等事情做完” | “等人到齐” |
| 异常语义 | 中断抛 InterruptedException | 中断、超时、reset 都会"打破"整代屏障 |
| 线程角色 | 等待者和执行者可以是不同线程 | 到达者和被放行者是同一批线程 |
| 一句话 | 裁判等所有选手跑完 | 选手互相等,到齐了一起跑 |
如果你只记一句话:CountDownLatch 是"事件计数",CyclicBarrier 是"人员集合"。
四、为什么用:它到底替我们省了什么
有人会问:不就是等几个线程吗,我自己写个 AtomicInteger 加 while 自旋不就行了?下面这张表是我压测过的真实差距。
| 方案 | 编码复杂度 | 超时能力 | 异常处理 | 复用性 | CPU 开销 | 可读性 |
|---|---|---|---|---|---|---|
while (count.get() > 0) {} 自旋 | 低 | 需要自己算 | 差 | 无 | 极高,空转烧 CPU | 差 |
Thread.join() | 低 | 只有 join(ms) | 无法感知子线程异常 | 无 | 低 | 中 |
wait() / notifyAll() | 高,必须配锁 | 需要自己算 | 容易漏唤醒 | 需自己维护状态 | 低 | 差 |
Future.get() | 中 | 有 | 抛 ExecutionException | 无 | 低 | 中 |
| CountDownLatch | 低 | 内置 | 中断语义清晰 | 无 | 极低(park) | 好 |
| CyclicBarrier | 低 | 内置 | 打破屏障语义统一 | 有 | 极低 | 好 |
省下的其实是三样东西:
第一,省掉了自己维护同步状态的风险。手写 wait/notify 最经典的 bug 就是"通知早于等待"——子线程先 notifyAll(),主线程才 wait(),然后永远等下去。Latch 的计数器是"先扣分再检查",天然不存在这个时序陷阱,因为 await() 会先看 state 是不是 0,是 0 就直接返回。
第二,省掉了可见性问题。前面提过,countDown 到 await 之间存在 happen-before 关系。你自己用 AtomicInteger 等,虽然计数是可见的,但子线程往 HashMap 里写的业务数据未必可见,还得再加一层同步。用 Latch,这层语义是 JDK 帮你兜住的。
第三,省掉了"等待"这件事对业务代码的污染。用 Latch 的时候,业务代码里只多了一行 countDown() 和一行 await(),业务逻辑本身不被锁、条件变量、循环判断切碎。这一点在 code review 时特别明显:一看 await(300, MILLISECONDS) 就知道这里有超时预算,一看 countDown() 在 finally 里就知道作者踩过坑。
当然,它也不是万能的。它不传递结果,只传递"完成"这个信号;它不传播异常,子线程抛了什么,主线程完全不知道,必须自己用容器收集;它不做重试,超时了就是超时了。这三点决定了它只能做"同步原语",不能做"任务编排框架"——这也是后面要讲的 CompletableFuture 的生存空间。
五、它是怎么演进过来的

理解一个工具,最好的方式是看它取代了什么。并发同步这件事,Java 走了二十多年,大致是五步。
第一步,JDK 1.0:Thread.join() 与 wait/notify。 想等一个线程结束,就 t.join();想等一个条件成立,就 synchronized + wait/notify。问题是:join 只能一对一,等五个线程就要 join 五次,而且是串行 join,总耗时等于最慢那个加上循环开销;wait/notify 更是把锁、条件、状态机全压在业务代码上,稍不注意就是死锁或者丢通知。
第二步,JDK 1.5:JSR-166 与 JUC 三件套。 2004 年,Doug Lea 把一套并发工具捐给 JCP,形成了 java.util.concurrent。CountDownLatch、CyclicBarrier、Semaphore 就是这一批进来的,同时带来了 AQS 这个统一抽象。这是关键的一步:同步语义第一次从"线程 API"变成了"可组合的同步器"。你不用再关心锁怎么排、队列怎么挂,只需要关心"我要等几个"和"我什么时候算完成"。
第三步,JDK 1.7:Phaser 与 Fork/Join。 CyclicBarrier 有一个硬约束——参与者数量在构造时就固定了。真实的分片任务经常是"分片数由数据量决定",执行中还想动态注册。于是 Phaser 出现了,它把"阶段"和"参与者"都做成了可动态调整的,可以理解为"可插拔的、多阶段的 CyclicBarrier"。同年 Fork/Join 带来了工作窃取,解决的是"任务怎么切",而不是"任务怎么等"。
第四步,JDK 1.8:CompletableFuture。 大家发现,光是"等完成"不够,还要"拿到结果"“串起来”“失败降级”“多个里取最快”。CompletableFuture 把这些编排能力做成了一套函数式 API,allOf 直接就是 N 等全部,anyOf 是取最快。从这一刻起,CountDownLatch 的定位被重新划清:它负责"同步",不负责"编排"。
第五步,JDK 19 到 JDK 25:虚拟线程与结构化并发。 虚拟线程(JDK 21 正式)让"一个请求一个线程"重新成为可行方案,阻塞的代价从平台线程的几 MB 栈变成几百字节的续体。而结构化并发(StructuredTaskScope,JDK 25 的 JEP 505 重写了 API,JDK 26、JDK 27 仍以预览形式在演进,JEP 525、JEP 533)试图从语言层面保证"子任务一定在父任务的作用域内开始和结束"——它想取代的,恰恰是我们用 CountDownLatch 手工维护的那种"父子任务生命周期"。
但请注意:结构化并发到现在仍然是预览特性,而 CountDownLatch 和 CyclicBarrier 从 JDK 5 一直稳定服役到今天。这也意味着,未来五年内,它们仍然是你代码里最常见的两个同步器。
顺带说一个和虚拟线程有关的细节:CountDownLatch.await() 底层是 LockSupport.park(),虚拟线程 park 时会卸载(unmount)载体线程,不会把平台线程钉住;这一点和 synchronized 在虚拟线程里可能 pin 住载体线程的情况不同。所以"虚拟线程 + CountDownLatch"在 JDK 21 之后是安全的组合。真正需要警惕的是另一件事:如果你把大量虚拟线程丢进一个固定大小的平台线程池,那虚拟线程的优势会被线程池直接抹平——问题不在 Latch,在池。
六、怎么用:可直接抄的代码
这一节全部是可运行的 Java 代码,从最小例子到企业级模板,按"能直接抄进项目"的标准写。
6.1 最小可用模板
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class LatchBasicDemo {
public static void main(String[] args) throws InterruptedException {
int workers = 3;
CountDownLatch done = new CountDownLatch(workers);
ExecutorService pool = Executors.newFixedThreadPool(workers);
for (int i = 1; i <= workers; i++) {
final int no = i;
pool.submit(() -> {
try {
Thread.sleep(200L * no); // 模拟耗时不同的子任务
System.out.println("子任务 " + no + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标记,不要吞掉
} finally {
done.countDown(); // 无论如何都要减一
}
});
}
boolean finished = done.await(2, TimeUnit.SECONDS);
System.out.println("是否全部完成:" + finished + ",剩余计数:" + done.getCount());
pool.shutdown();
}
}
三个细节值得停一下:
countDown()放在finally,因为子任务抛异常也必须让计数器前进,否则主线程永远等不到零。await带超时,因为线上没有"无限等待"这种奢侈。InterruptedException要恢复中断标记,而不是打印一下就继续,否则上层的中断语义会丢失。
6.2 企业级模板:结果收集 + 超时降级 + 异常隔离
真实项目里,子任务是要返回值的,也是会失败的。下面这个工具类可以直接放进 common 包:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Map;
import java.util.concurrent.*;
public final class ParallelInvoker {
private static final Logger log = LoggerFactory.getLogger(ParallelInvoker.class);
private ParallelInvoker() {
}
/**
* 并行执行一组有名字的任务,收集成功结果,失败与超时只降级不阻断。
*
* @param tasks 任务名 -> 任务
* @param timeoutMillis 总预算
* @return 已成功返回的结果,key 为任务名
*/
public static <T> Map<String, T> invokeAll(Map<String, Callable<T>> tasks,
long timeoutMillis,
ExecutorService pool) {
CountDownLatch latch = new CountDownLatch(tasks.size());
Map<String, T> results = new ConcurrentHashMap<>(Math.max(4, tasks.size() * 2));
tasks.forEach((name, task) -> pool.execute(() -> {
try {
results.put(name, task.call());
} catch (Exception e) {
// 单个子任务失败不影响整体,记录后降级
log.warn("子任务 {} 执行失败,已降级", name, e);
} finally {
latch.countDown();
}
}));
try {
if (!latch.await(timeoutMillis, TimeUnit.MILLISECONDS)) {
log.warn("聚合等待超时 {} ms,已完成 {}/{} 个子任务",
timeoutMillis, results.size(), tasks.size());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("聚合等待被中断,返回已完成结果 {}/{}", results.size(), tasks.size());
}
return results;
}
}
这段代码里有四个工程化决定,值得在 review 时逐条讲清楚:
- 用
ConcurrentHashMap收结果,而不是HashMap,因为多个子线程并发写;同时它也避免了"主线程读不到"的可见性问题。 - 异常在内层吞掉并记录,因为聚合场景的语义是"尽力而为",一个评论服务挂了不该让整个详情页挂掉。
- 超时只打日志不抛异常,把"降级"这个决策留给业务层,工具类不越权。
- 线程池由调用方传入,工具类不自己
new线程池,避免线程泄漏和无法统一治理。
6.3 实战:商品详情页四路聚合

把上面的工具类落到一个 Spring Boot 的服务里,代码大概长这样:
// 省略 import:AtomicReference、Consumer、Supplier、List 等
@Service
public class ProductDetailService {
private static final Logger log = LoggerFactory.getLogger(ProductDetailService.class);
/** 聚合预算:整页 300 毫秒,超过就降级返回 */
private static final long BUDGET_MS = 300L;
@Resource
private ExecutorService detailAggregatePool;
@Resource
private ProductClient productClient;
@Resource
private StockClient stockClient;
@Resource
private PriceClient priceClient;
@Resource
private CommentClient commentClient;
public ProductDetailVO detail(long skuId) {
CountDownLatch latch = new CountDownLatch(4);
AtomicReference<Product> product = new AtomicReference<>();
AtomicReference<Stock> stock = new AtomicReference<>();
AtomicReference<Price> price = new AtomicReference<>();
AtomicReference<List<Comment>> comments = new AtomicReference<>();
submit(latch, product::set, () -> productClient.get(skuId));
submit(latch, stock::set, () -> stockClient.get(skuId));
submit(latch, price::set, () -> priceClient.get(skuId));
submit(latch, comments::set, () -> commentClient.page(skuId));
try {
if (!latch.await(BUDGET_MS, TimeUnit.MILLISECONDS)) {
log.warn("详情页聚合超时 skuId={},剩余计数={}", skuId, latch.getCount());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
ProductDetailVO vo = new ProductDetailVO();
vo.setProduct(product.get());
vo.setStock(stock.get());
vo.setPrice(price.get());
vo.setComments(comments.get() == null ? List.of() : comments.get());
vo.setDegraded(product.get() == null || price.get() == null);
return vo;
}
/** 统一包装:异常隔离 + 必然计数 */
private <T> void submit(CountDownLatch latch, Consumer<T> setter, Supplier<T> supplier) {
detailAggregatePool.execute(() -> {
try {
setter.accept(supplier.get());
} catch (Exception e) {
log.warn("详情页子调用失败,降级处理", e);
} finally {
latch.countDown();
}
});
}
}
这段代码之所以"敢上线",靠的是三个设计:
- 总预算 300ms 而不是每个调用各设 300ms。四个调用各自设超时,最坏情况是串行叠加;只有总预算才能守住接口 SLA。
degraded标记。降级不是悄悄地降,要显式告诉上游"这一屏是降级页",前端才能决定是否展示"价格加载中"。- 线程池隔离。聚合调用单独一个池,不能和主业务共用,否则慢调用会把整个应用的线程池拖垮。
配套的线程池配置建议放在 application.yml 里,而不是硬编码在代码里:
async:
detail-aggregate:
core-size: 8 # 按 QPS × 平均耗时 估算,约等于并发数
max-size: 16
queue-capacity: 200 # 有界队列,绝不允许无界
keep-alive: 60s
reject-policy: CALLER_RUNS # 满了让调用者自己跑,形成天然背压
await-timeout-ms: 300
6.4 起跑线模式:压测与并发测试
CountDownLatch(1) 最经典的用法是"发令枪"。测并发问题的关键,是让所有线程在同一毫秒冲出去,否则你测的其实是串行。
public class StartGunDemo {
public static void main(String[] args) throws InterruptedException {
int threads = 50;
CountDownLatch ready = new CountDownLatch(threads); // 大家都准备好了吗
CountDownLatch start = new CountDownLatch(1); // 发令枪
CountDownLatch done = new CountDownLatch(threads); // 都跑完了吗
ExecutorService pool = Executors.newFixedThreadPool(threads);
for (int i = 0; i < threads; i++) {
pool.submit(() -> {
ready.countDown(); // 我先举手:我准备好了
try {
start.await(); // 等枪响
business(); // 真正要压的代码
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
// 等所有线程就位,避免"枪响了还有人没上车";即使超时也继续,保证压测能出结果
boolean allReady = ready.await(5, TimeUnit.SECONDS);
if (!allReady) {
System.out.println("警告:仍有线程未就位,本次压测并发度不准确");
}
long begin = System.nanoTime();
start.countDown(); // 发令
done.await(30, TimeUnit.SECONDS); // 等全部跑完
long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);
System.out.println("并发度 " + threads + ",总耗时 " + cost + " ms");
pool.shutdown();
}
private static void business() {
// 这里放你要验证的临界区
}
}
注意 ready.await() 这一句很多人会漏。如果不等"就位"就直接发令,先启动的线程已经跑完了,后启动的还没进临界区,你测出来的并发度是假的。
6.5 CyclicBarrier 基础:三阶段任务
import java.util.concurrent.*;
public class BarrierStageDemo {
public static void main(String[] args) {
int parties = 3;
AtomicInteger stage = new AtomicInteger(1);
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
// 由最后一个到达的线程执行,适合做阶段汇总
System.out.println("[汇总] 第 " + stage.getAndIncrement() + " 阶段全员到齐");
});
ExecutorService pool = Executors.newFixedThreadPool(parties);
for (int i = 1; i <= parties; i++) {
final int worker = i;
pool.submit(() -> {
try {
for (int round = 1; round <= 3; round++) {
doStage(worker, round);
int index = barrier.await(5, TimeUnit.SECONDS);
// index == parties - 1 表示第一个到,index == 0 表示最后一个到
System.out.println("线程 " + worker + " 第 " + round
+ " 轮通过屏障,到达序号 " + index);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (BrokenBarrierException | TimeoutException e) {
// 只要有一个线程超时或被中断,整代屏障报废,所有人都走这里
System.err.println("线程 " + worker + " 屏障被打破,任务终止:" + e);
}
});
}
pool.shutdown();
}
private static void doStage(int worker, int round) throws InterruptedException {
Thread.sleep(100L * worker); // 让每个线程耗时不同,更能看出屏障的效果
}
}
运行结果会非常整齐:不管三个线程各自快慢,每一轮的"阶段全员到齐"一定出现在三个线程都 await 之后。这就是"齐步走"。
6.6 企业实战:分片导入的三阶段同步
批量导入是 CyclicBarrier 最典型的落地场景:分片大小由数据量决定,分片之间必须阶段对齐,且阶段之间有汇总动作。
public class ShardImportService {
private static final Logger log = LoggerFactory.getLogger(ShardImportService.class);
public void importAll(List<Row> rows, int shardSize, ExecutorService pool) {
List<List<Row>> shards = partition(rows, shardSize);
int parties = shards.size();
AtomicInteger stage = new AtomicInteger(0);
CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
int current = stage.incrementAndGet();
log.info("阶段 {} 全部分片完成,共 {} 片", current, parties);
if (current == 2) {
// 第二阶段结束:做一次全量校验汇总,再放行第三阶段
validateSummary();
}
});
CountDownLatch allDone = new CountDownLatch(parties);
for (List<Row> shard : shards) {
pool.execute(() -> {
try {
writeMainTable(shard); // 阶段一:写主表
barrier.await(60, TimeUnit.SECONDS);
verify(shard); // 阶段二:逐片校验
barrier.await(60, TimeUnit.SECONDS);
buildIndex(shard); // 阶段三:建索引
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (BrokenBarrierException | TimeoutException e) {
log.error("分片导入阶段同步失败,整批任务终止", e);
} finally {
allDone.countDown();
}
});
}
try {
if (!allDone.await(5, TimeUnit.MINUTES)) {
log.error("分片导入整体超时,请检查慢分片");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// partition / writeMainTable / verify / buildIndex / validateSummary 省略
}
这里同时用了两个工具,分工非常清楚:CyclicBarrier 负责"阶段之间对齐",CountDownLatch 负责"整批任务结束"。很多同学只会用其中一个,结果要么阶段错位,要么主线程等不到全部完成。两个一起用,才是完整的企业级写法。
6.7 代码规范清单:这十条能帮你躲过绝大多数事故
countDown()必须写在finally,任何"提前 return"都不能绕过它。- 生产代码禁止裸
await(),一律await(timeout, unit)。 await返回false不是异常,是"预算用完了",必须有降级分支。InterruptedException必须Thread.currentThread().interrupt(),不要吞。CountDownLatch不要复用,每一批任务new一个,成本极低。CyclicBarrier的parties必须等于实际参与await的线程数,差一个就永久卡住。barrierAction里不要做 RPC、不要 sleep、不要阻塞等待别的屏障。- 捕获
BrokenBarrierException,因为只要有一个线程中断或超时,其他线程都会收到它。 - 线程池大小必须大于等于
parties,否则线程互相等待会直接死锁(这个坑下面会专门讲)。 - 所有等待都要打点:等待开始时间、等待时长、剩余计数,出问题时这是唯一的线索。
七、源码层面:它们到底怎么实现的
7.1 CountDownLatch:一个 AQS 共享模式的外壳

CountDownLatch 本身的代码非常短,核心就是一个内部类 Sync 继承 AQS,然后把"计数"这件事映射到 AQS 的 state 上:
// 基于 JDK 8 的 CountDownLatch.Sync,语义与后续版本一致
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) {
setState(count); // 计数就是 AQS 的 state
}
int getCount() {
return getState();
}
/** 共享模式获取:state == 0 才算"拿到资源",返回 1 表示成功且无剩余 */
@Override
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
/** 共享模式释放:CAS 递减,减到 0 时返回 true,触发唤醒后继等待者 */
@Override
protected boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0) {
return false; // 已经归零,重复 countDown 无副作用
}
int nextc = c - 1;
if (compareAndSetState(c, nextc)) {
return nextc == 0; // 只有减到 0 的那一次才"释放成功"
}
}
}
}
看懂这二十行,你就能回答好几个面试题:
- 为什么
countDown()调用多了也不会出错?因为c == 0时直接返回false,状态不再变化。 - 为什么不能重置?因为 AQS 没有任何入口能把
state加回去,CountDownLatch也没暴露setState。 - 为什么归零之后
await立刻返回?因为tryAcquireShared一看到state == 0就返回 1,压根不会入队。 - 为什么是"共享模式"?因为多个等待线程可以同时被放行,而不是像独占锁那样一个个来。
7.2 CyclicBarrier:锁 + 条件队列 + 分代
CyclicBarrier 的核心方法是 dowait,它的逻辑可以浓缩成五步(源码较长,这里保留关键结构):
private int dowait(boolean timed, long nanos)
throws InterruptedException, BrokenBarrierException, TimeoutException {
final ReentrantLock lock = this.lock;
lock.lock();
try {
final Generation g = generation;
if (g.broken) {
throw new BrokenBarrierException(); // 这一代已经作废
}
if (Thread.interrupted()) {
breakBarrier(); // 中断即打破整代
throw new InterruptedException();
}
int index = --count; // 到达一人,减一
if (index == 0) { // 最后一个到达
boolean ranAction = false;
try {
final Runnable command = barrierCommand;
if (command != null) {
command.run(); // 先执行阶段汇总
}
ranAction = true;
nextGeneration(); // 唤醒全体 + 复位 + 换代
return 0;
} finally {
if (!ranAction) {
breakBarrier(); // 汇总逻辑抛异常,整代报废
}
}
}
for (;;) {
try {
if (!timed) {
trip.await(); // 挂在 Condition 上
} 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.broken) {
throw new BrokenBarrierException();
}
if (g != generation) {
return index; // 正常放行,返回到达序号
}
if (timed && nanos <= 0L) {
breakBarrier();
throw new TimeoutException(); // 超时也打破整代
}
}
} finally {
lock.unlock();
}
}
对照代码看三个结论:
第一,"循环"不是重置计数器,而是换了一代。nextGeneration() 里 count = parties 只是复位,真正让别人知道"我确实被正常放行"的是 generation 换了一个新对象。
第二,它比 CountDownLatch 更"脆"。中断、超时、reset()、barrierAction 抛异常,任何一种都会 breakBarrier(),把整代标记为 broken 并 signalAll(),让所有等待者收到 BrokenBarrierException。这是设计上的取舍:栅栏的语义是"要么一起走,要么一起失败",不允许有人偷偷先走。
第三,它是独占锁保护下的条件等待,所以 getNumberWaiting()、isBroken() 这些方法都必须先拿锁。这也意味着 CyclicBarrier 在高并发、超多参与者的场景下会有锁竞争,通常几十个参与者以内完全无感。
八、竞品对比:七个工具横向打分
选型这件事,最怕"手里只有一把锤子"。所以把同一个战场上的七个工具摆在一起看:
| 工具 | 核心语义 | 可复用 | 超时 | 结果返回 | 异常传播 | 动态参与者 | 典型场景 |
|---|---|---|---|---|---|---|---|
CountDownLatch | 等 N 件事做完 | 否 | 支持 | 不返回 | 不传播 | 不支持 | 聚合查询、启动预热、压测发令 |
CyclicBarrier | N 个线程互相等 | 是 | 支持(但会打破屏障) | 只返回到达序号 | 统一 BrokenBarrier | 不支持 | 多阶段批处理、分片同步 |
Semaphore | 限流,控制并发数 | 是 | 支持 tryAcquire | 不返回 | 不传播 | 支持(可增减许可) | 接口限流、资源池、下载并发控制 |
Phaser | 多阶段、可动态注册 | 是 | 支持 | 返回当前阶段号 | 有 onAdvance 钩子 | 支持 | 分片数不定的批处理、多阶段流水线 |
CompletableFuture.allOf | 异步编排与结果聚合 | 是(每次新建) | 支持 orTimeout | 返回 CompletableFuture | 传播(CompletionException) | 支持(动态追加) | 服务编排、并行调用、异步链路 |
Thread.join | 等某个线程结束 | 否 | 支持 join(ms) | 不返回 | 不传播 | 不支持 | 极简场景、单测 |
StructuredTaskScope | 结构化并发,作用域内收敛 | 是 | 支持 | 返回 Supplier 结果 | 传播并取消兄弟任务 | 支持 | JDK 25+ 预览,新项目探索 |
逐个说优劣,避免只看表格下结论:
CountDownLatch 的优势是"极简且极其可靠"。 它只有三个方法,任何水平的同学看一眼就懂,出问题的姿势也就那么几种(忘 countDown、裸 await),排查成本极低。劣势是"一次性 + 不返回结果 + 不传播异常",一旦你需要"拿到结果并处理失败",就得自己搭容器,代码会迅速变丑。
CyclicBarrier 的优势是"阶段语义天然贴合业务"。 多阶段批处理、分片对齐这种场景,用它写出来的代码几乎就是业务流程图本身。劣势是"脆":参与者固定、任何异常都整代报废、await 超时之后屏障就废了,必须由上层重建,不能"超时了继续跑"。
Semaphore 和它们不是一个战场。 它管的是"同时允许多少个进来",不是"什么时候一起走"。但很多人会拿它当 Latch 用(acquire(N) 再 release(N)),这是误用,语义不清且容易泄漏许可。
Phaser 是 CyclicBarrier 的进阶版。 分片数不固定、阶段要动态注册、需要 onAdvance 钩子,这些场景 CyclicBarrier 都做不到。劣势是 API 更复杂,arriveAndAwaitAdvance、arriveAndDeregister 这些方法名字就劝退一半人,团队不熟时容易写错阶段号。
CompletableFuture 是"编排层"的工具。 它能返回值、能串行、能异常降级、能取最快,功能上几乎全面碾压 Latch。但它的问题是"太重":一个简单的"等三个任务完成",用 allOf 要写一串 supplyAsync,还要处理 thenApply 的线程切换,读起来比 Latch 累。判断标准很简单:只需要"完成信号"就用 Latch,需要"结果和失败处理"就用 CompletableFuture。
Thread.join 只适合单测和玩具项目。 它等的是"线程对象结束",而在线程池里线程根本不会结束,join 会直接把你的服务挂死。
StructuredTaskScope 是未来。 它把"子任务不能活过父任务"这件事变成编译期和运行期的强约束,天然支持失败取消兄弟任务,配合虚拟线程非常优雅。但它到 JDK 27 仍是预览特性,API 还在改,生产项目现在押注为时尚早。

一张决策图收个尾:先问"要不要等",再问"等的是事还是人",最后问"要不要结果"。
九、常用场景教学:企业项目到底怎么用

理论讲完,接下来是最容易被面试官追问的部分:你在项目里到底怎么用的。 下面六个场景,覆盖了绝大部分真实需求。
9.1 场景 A:多接口聚合(CountDownLatch)
这是最常见的落地。要点不在于"会不会用",而在于"预算怎么分配"。企业级做法通常是三层预算:接口总预算 300ms、单次 RPC 预算 80ms、Latch 等待预算等于总预算减去序列化与组装耗时。这样即使某个下游慢,也只是局部降级,不会击穿整页。
配套要有监控:latch.getCount() 在超时那一刻的值,就是"有几个下游没回来"的直接证据,把它打进日志和猫眼/鹰眼这类监控里,比事后猜有用得多。
9.2 场景 B:批量导入分片(CyclicBarrier)
数据量决定分片数,分片之间要阶段对齐。核心是三段式:写、校验、建索引,每段结束 await 一次。注意两个工程细节:一是 barrier.await 一定要带超时,因为慢分片可能卡在数据库连接上;二是任何一片失败都要让整批终止并触发补偿任务,不能让"一半成功一半失败"的数据留在库里。
9.3 场景 C:多阶段对账(CyclicBarrier + CountDownLatch)
对账的语义是"这一天的账,所有渠道都拉回来、都解析完,才能开始比对"。用一个 CyclicBarrier 卡住"拉取完成"和"解析完成"两个阶段,再用一个 CountDownLatch 等所有渠道的比对结果收齐,最后由主线程出差异报表。这套组合几乎是离线对账系统的标准骨架。
9.4 场景 D:压测与并发缺陷复现(CountDownLatch(1))
前面 6.4 已经给了完整代码。工程上还要补两点:一是压测线程数要等于目标并发度,不能用线程池复用;二是要采集 P99 而不只是平均值,因为并发问题往往体现在尾部延迟。
9.5 场景 E:服务预热与优雅启动(CountDownLatch)
@Component
public class WarmUpRunner implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(WarmUpRunner.class);
@Resource
private ExecutorService warmUpPool;
@Resource
private ConfigClient configClient;
@Resource
private LocalCache localCache;
@Resource
private RegistryClient registryClient;
@Override
public void run(ApplicationArguments args) throws Exception {
CountDownLatch latch = new CountDownLatch(3);
AtomicReference<Throwable> fatal = new AtomicReference<>();
run(latch, fatal, "配置中心", configClient::preload);
run(latch, fatal, "本地缓存", localCache::warmUp);
run(latch, fatal, "注册中心", registryClient::connect);
// 10 秒还没预热完,直接启动失败,让发布流水线红掉,而不是带病接流量
if (!latch.await(10, TimeUnit.SECONDS) || fatal.get() != null) {
throw new IllegalStateException("服务预热失败,拒绝启动", fatal.get());
}
log.info("服务预热完成,开始对外提供服务");
}
private void run(CountDownLatch latch, AtomicReference<Throwable> fatal, String name, Runnable task) {
warmUpPool.execute(() -> {
try {
task.run();
log.info("预热项 {} 完成", name);
} catch (Throwable t) {
fatal.compareAndSet(null, t);
log.error("预热项 {} 失败", name, t);
} finally {
latch.countDown();
}
});
}
}
这个场景的价值在于:把"启动成功"从一句日志,变成一个有语义的状态。 预热没完成就不接流量,是可用性治理里性价比最高的一招。
9.6 场景 F:单元测试等待异步结果(CountDownLatch)
异步代码最难测的是"我怎么知道它跑完了"。用 Latch 做测试同步,比 Thread.sleep(1000) 可靠一百倍:
@Test
void shouldInvokeCallbackAfterAsyncSubmit() throws Exception {
CountDownLatch called = new CountDownLatch(1);
AtomicReference<String> payload = new AtomicReference<>();
orderService.submitAsync(new Order("NO-1"), result -> {
payload.set(result);
called.countDown();
});
assertTrue(called.await(3, TimeUnit.SECONDS), "回调 3 秒内未触发");
assertEquals("SUCCESS", payload.get());
}
注意断言里那句"3 秒内未触发"——测试失败时它会直接告诉你"是超时"而不是"值是 null",排查效率完全不同。
十、面试官高频面试题
这一节按"面试官真正会问的顺序"排,每题给出答题骨架,不背原文也能答得像回事。
1. CountDownLatch 和 CyclicBarrier 有什么区别?
先给一句话结论:一个等事件,一个等人。然后展开三个维度:谁等谁(一方等另一方 vs 互相等)、能否复用(一次性 vs 循环)、底层实现(AQS 共享模式 vs ReentrantLock + Condition)。最后补一句场景:聚合查询用 Latch,多阶段对齐用 Barrier。
2. CountDownLatch 底层怎么实现的?
AQS 共享模式。state 就是计数,countDown() 走 tryReleaseShared 做 CAS 递减,减到 0 返回 true 触发唤醒;await() 走 acquireSharedInterruptibly,tryAcquireShared 在 state == 0 时返回 1,否则返回 -1 并入队挂起。
3. 为什么 CountDownLatch 不能复用?想复用怎么办?
因为 state 只能减不能加,且没有任何公开入口能重置。想复用就换 CyclicBarrier(固定参与者)或 Phaser(动态参与者),或者干脆每批 new 一个 Latch——对象很轻,创建成本可以忽略。
4. CyclicBarrier 的"循环"是怎么实现的?
靠 generation。每轮是一个 Generation 对象,最后一个线程到达时执行 nextGeneration():signalAll() 唤醒全体、count 复位为 parties、新建 Generation。等待线程醒来后比对 g != generation 判断自己是被正常放行还是这一代已作废。
5. BrokenBarrierException 什么时候抛?
四种情况:屏障已被打破(g.broken == true)时再 await;某个等待线程被中断;某个线程 await 超时;有人调用了 reset()。本质是"这一代作废了",所有人一起失败。
6. await 超时之后屏障会怎样?
dowait 里检测到 timed && nanos <= 0 就 breakBarrier(),把整代标记为 broken 并唤醒所有人,然后抛 TimeoutException。也就是说超时不是"我自己退出",而是"整代报废",这是 CyclicBarrier 和 CountDownLatch 在异常语义上最大的差异。
7. 忘记在 finally 里 countDown 会怎样?
计数器永远到不了 0,所有 await 的线程永久阻塞(或等到超时)。如果这些线程来自业务线程池,池会被逐步占满,最终整个服务不可用。这是 Latch 最常见的一类生产事故。
8. await 的线程被中断,两者表现一样吗?
不一样。CountDownLatch 抛 InterruptedException,只影响当前线程,计数状态不变;CyclicBarrier 除了抛 InterruptedException,还会先 breakBarrier(),让所有其他等待者收到 BrokenBarrierException。
9. 能不能给 CountDownLatch 加计数?
不能。JDK 没有提供任何增加计数的 API。如果业务上确实需要"动态增加待完成项",说明你该用 Phaser(支持 register)或者干脆用 CompletableFuture 动态追加任务。
10. 内存可见性怎么保证的?
AQS 的 state 是 volatile,CAS 自带内存屏障;JDK 文档还明确声明了 happen-before:countDown() 之前的操作对 await() 成功返回之后的代码可见。所以子线程写入的结果,主线程 await 返回后能安全读到。
11. 线程池只有 4 个线程,CyclicBarrier 的 parties 设成 5,会怎样?
永久死锁。第 5 个线程永远排不进池,前 4 个永远等不到第 5 个到达。这类问题的排查特征是:线程栈里全是 WAITING 在 Condition.await,且 getNumberWaiting() 一直停在 4。规则很简单:池的最大线程数必须大于等于 parties。
12. 在 Tomcat 工作线程里 await 会有什么问题?
会把容器线程占住。Tomcat 的 maxThreads 默认 200,如果每个请求都 await 300ms,QPS 上限就是 200/0.3 ≈ 666,再高就开始排队。解法有三种:缩短预算、把聚合放到独立线程池并用异步 Servlet / DeferredResult 释放容器线程、或者上虚拟线程。
13. 虚拟线程下用这两个工具要注意什么?
CountDownLatch.await 和 CyclicBarrier.await 底层都是 LockSupport.park,虚拟线程 park 会卸载载体线程,不会 pin 住平台线程,所以是安全的。真正要注意的是:不要让大量虚拟线程挤进一个固定大小的平台线程池,否则虚拟线程退化成平台线程,等待成本又回来了。
14. 和 CompletableFuture.allOf 怎么选?
看你要不要"结果"和"失败处理"。只要完成信号,用 Latch,代码最短;要结果、要串行、要异常降级、要取最快,用 CompletableFuture。工程上常见组合是:CompletableFuture 负责编排,内部个别需要严格同步的点用 Latch 兜底。
15. 手写一个简化版 CountDownLatch,你怎么写?
答题骨架:用一个 volatile int state 加 CAS 递减;await 用 LockSupport.park,countDown 减到 0 时 unpark 所有等待线程;等待队列用 ConcurrentLinkedQueue 存 Thread。然后主动指出简化版的问题:无法处理中断、无法超时、有竞态(park 前需二次检查 state),所以生产必须用 JDK 实现。
16. 场景题:四个下游接口,总预算 300ms,怎么设计?
答题骨架:CountDownLatch(4) + 独立线程池 + await(300ms);每个子任务内 try/catch 隔离异常、finally 计数;结果放 ConcurrentHashMap 或 AtomicReference;超时后按"已返回结果 + 默认值"降级,并在响应里打 degraded 标记;同时上报监控(剩余计数即未返回数)。最后补一句:如果下游有强依赖顺序,就不是聚合问题,而是编排问题,应该换 CompletableFuture。
十一、一页纸速查
| 你的需求 | 用哪个 | 关键写法 |
|---|---|---|
| 主线程等 N 个子任务完成 | CountDownLatch(N) | 子任务 finally { latch.countDown(); },主线程 await(timeout) |
| 所有线程等一个信号同时开跑 | CountDownLatch(1) | 先 ready.await() 再 start.countDown() |
| N 个线程分阶段齐步走 | CyclicBarrier(parties) | 每阶段末 barrier.await(timeout) |
| 阶段结束要汇总 | CyclicBarrier(parties, action) | action 里只做轻量汇总,不阻塞 |
| 参与者数量动态变化 | Phaser | register / arriveAndAwaitAdvance |
| 要拿结果、要异常降级 | CompletableFuture.allOf | orTimeout + handle |
| 控制同时并发的数量 | Semaphore | tryAcquire(timeout) + finally release |
上手检查清单(照着勾一遍就能上线):
- 计数递减是否在
finally。 - 是否所有
await都带超时。 - 超时分支是否有降级返回值。
- 中断是否恢复标记。
- 线程池大小是否大于等于参与者数。
- 是否用了有界队列和明确的拒绝策略。
- 结果容器是否线程安全。
- 是否打了等待耗时与剩余计数的日志。
CyclicBarrier的异常是否被BrokenBarrierException兜住。- 是否为这批同步逻辑写了并发单测。
十二、写在最后
CountDownLatch 和 CyclicBarrier 这两个类,代码加起来不到一千行,却解决了并发编程里最基础也最容易出错的一件事:让线程按你期望的顺序发生。
我把它们的关系总结成三句话,你可以直接记:
- CountDownLatch 是"事件的倒计时":谁做完了就减一,减到零,等的人全部放行,且只放行一次。
- CyclicBarrier 是"人员的集合点":谁到了就登记,人到齐,一起走,走完还能再来一轮。
- 两个都不是"编排框架":它们只传递"完成"和"到齐",不传递结果,也不传播异常。要结果和失败处理,请交给
CompletableFuture。
最后提醒一句:并发工具从来不是"用了就高并发",它只是把串行等待变成了可控等待。真正决定系统吞吐的,永远是你对预算的分配、对线程池的治理、对降级的取舍。工具只是让这些决策有一个干净的落地方式。
本文为原创文章,如需转载,请联系作者获得授权,并注明出处。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/zxc18344522713/article/details/170560018




