图 1:本文封面,点明两个同步器的对比主题。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。CountDownLatch 和 CyclicBarrier 都在 java.util.concurrent 包里,名字都绕口,用法都像「等一等」,于是成了并发面试的高频对比题。
面试现场,面试官通常先让你各说一个使用场景,再补一刀:「这俩能不能互换?」
核心结论一句话:Latch 等事,Barrier 等人;Latch 一次性,Barrier 可循环。
先拆 CountDownLatch:一个或多个线程,等一组「事件」完成。
JDK 官方文档的定义是:允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。它用一个计数初始化,countDown 减一,await 阻塞到计数归零。文档特意强调两点:这是一次性的(one-shot),计数不能重置,要可重置请考虑 CyclicBarrier;调用 countDown 的线程不需要停下来等,它只是发个信号。典型画面:服务启动前,主线程等三个初始化任务——加载特征、预热模型、加载词表——全部完成再放行流量。计数归零,门永久打开。
再拆 CyclicBarrier:一组线程互相等待,凑齐了一起走。
文档定义:允许一组线程全部彼此等待,到达共同的栅栏点;它叫 cyclic,是因为等待线程被放行后栅栏可以重复使用。每个线程到栅栏都 await,最后到达的那个触发全部放行——还可以附一个 barrier action,在最后一人到达后、放行前执行,常用于汇总本轮结果。栅栏还支持 reset。典型画面:三路并行各算一块数据,每轮都在栅栏对齐,barrier action 把三块结果合并,然后一起进入下一轮。
用时间线看差异最直观。
Latch 的画面:子任务各自在不同时刻完成,等待方在最后一个完成的那一瞬间单点放行——等待者和完成者是两拨角色。Barrier 的画面:所有线程被最慢的那个拖到同一时刻,一起继续——参与者是同一拨人,互相等。换句话说,Latch 的计数器倒数的是「事」,事可以由任何线程完成;Barrier 数的是「人」,凑齐固定人数才开门。
可运行的语义模拟。
下面用 Node.js 22.15.0 按各自语义模拟这两个同步器(node latch-barrier.js),直观感受「一次性放行」和「循环汇合」:
'use strict';
// CountDownLatch 语义:等「事」全部完成,计数归零后一次性放行,不可重置
class Latch {
constructor(count) {
this.count = count;
this.waiters = [];
}
countDown() {
this.count -= 1;
if (this.count === 0) this.waiters.forEach((w) => w());
}
await() {
if (this.count === 0) return Promise.resolve();
return new Promise((resolve) => this.waiters.push(resolve));
}
}
// CyclicBarrier 语义:一组线程互相等待,凑齐一起出发,栅栏自动复位可复用
class Barrier {
constructor(parties) {
this.parties = parties;
this.arrived = 0;
this.waiters = [];
}
await() {
return new Promise((resolve) => {
this.arrived += 1;
this.waiters.push(resolve);
if (this.arrived === this.parties) {
this.arrived = 0; // 凑齐后自动复位,这就是 cyclic
this.waiters.splice(0).forEach((w) => w());
}
});
}
}
async function demoLatch() {
const latch = new Latch(3);
const work = (name, ms) =>
new Promise((r) => setTimeout(() => {
console.log('子任务完成:' + name);
latch.countDown();
r();
}, ms));
work('特征加载', 60);
work('模型预热', 120);
work('词表加载', 90);
await latch.await();
console.log('Latch 放行:依赖全部就绪,服务启动');
}
async function demoBarrier() {
const barrier = new Barrier(3);
const runner = async (name, base) => {
for (let lap = 1; lap <= 2; lap++) {
// 每轮先各自干活,再到栅栏汇合;凑齐三人后一起进入下一轮
await new Promise((r) => setTimeout(r, base * lap));
await barrier.await();
console.log(name + ' 第 ' + lap + ' 轮:汇合后一起出发');
}
};
runner('线程A', 40);
runner('线程B', 70);
runner('线程C', 100);
return new Promise((r) => setTimeout(r, 500));
}
(async () => {
await demoLatch();
console.log('---');
await demoBarrier();
})();
实测输出:
子任务完成:特征加载
子任务完成:词表加载
子任务完成:模型预热
Latch 放行:依赖全部就绪,服务启动
---
线程A 第 1 轮:汇合后一起出发
线程B 第 1 轮:汇合后一起出发
线程C 第 1 轮:汇合后一起出发
线程A 第 2 轮:汇合后一起出发
线程B 第 2 轮:汇合后一起出发
线程C 第 2 轮:汇合后一起出发
上半段是 Latch:三个子任务先后完成,主等待方在最后一个完成后一次性放行。下半段是 Barrier:三个线程每轮都被最慢的拖到同一时刻,一起打印、一起进入下一轮——栅栏自动复位,第二轮无需新建。
图 2:两个同步器的语义差异与适用场景。
可能的追问。
「能不能互换?」勉强能,但语义错位:用 Latch 模拟汇合要等所有人都 countDown 再各自 await,多绕一层;用 Barrier 模拟「等事件」则要求完成者亲自到栅栏报到。「Barrier 有人中途挂了怎么办?」它采用全有或全无的破裂模型:一个线程被中断或超时,其余等待线程全部抛 BrokenBarrierException,栅栏进入破裂状态——不会死等,但要处理异常。「countDown 的线程要等吗?」不等,它发完信号可以继续干活,这是 Latch 和 Barrier 又一个容易被忽略的区别。
易错点清单。
一,把两者都记成「等所有线程完成」,说不出等事与等人的差别。二,忘了 Latch 一次性,在循环里复用导致第二轮直接穿过。三,忘了 Barrier 的破裂传播,一个线程超时拖出一片异常却没有处理。四,只会背定义,给不出「启动等待依赖就绪」「多路并行每轮对齐」这种真实场景。
回到面试现场,答题节奏建议:先各给一句官方定义,再画两种时间线,点破「等事 vs 等人」「一次性 vs 可循环」两对差异,最后各配一个真实场景收尾。对比题的高分在语义差异,不在 API 列表。
你在项目里真正用过这两个同步器吗?用的哪个,场景是什么?欢迎分享。