秒杀活动零点开始之前,系统通常要做一轮"预热":库存服务要把商品库存加载进 Redis,优惠券服务要初始化好发券规则,风控服务要拉取好黑名单------主线程必须等这几个子系统都准备好,才能把"允许下单"的开关打开 。这是一个典型的"等待多个任务全部完成"的协作场景,靠前面几篇讲的锁或者 BlockingQueue 都不太顺手,因为这里的诉求不是互斥、也不是传递数据,而是线程之间的节奏协调。
JDK 在 java.util.concurrent 包里专门准备了一组"同步工具类"来解决这类节奏协调问题:CountDownLatch、CyclicBarrier、Semaphore、Phaser、Exchanger。它们和第 5、6 篇讲的锁不是一回事------锁解决的是"同一时刻谁能进临界区",这几个工具解决的是"线程之间谁等谁、等到什么时候一起走"。这一篇把它们挨个拆开看,重点关注每一个工具适合什么场景、和相邻工具的边界在哪里。
目录
- CountDownLatch:一次性的门闩
- CyclicBarrier:可重复使用的栅栏
- 两者的边界:门闩等外部事件,栅栏等彼此到达
- Semaphore:控制并发访问数量的信号量
- Phaser:更灵活的多阶段协作(简要了解)
- Exchanger:两个线程之间的数据交换
- 实战:秒杀系统预热+分片处理+限流的组合
- 选型小结
一、CountDownLatch:一次性的门闩
CountDownLatch(倒计时门闩)的用法非常直白:用一个计数器初始化,多个线程各自完成任务后调用 countDown() 让计数器减一,等待的线程调用 await() 阻塞,直到计数器归零才被放行。
java
public class SeckillWarmup {
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3); // 3 个子系统要初始化
AtomicReference<Throwable> failure = new AtomicReference<>();
new Thread(() -> {
try {
loadStockToRedis();
} catch (Throwable t) {
failure.compareAndSet(null, t);
} finally {
latch.countDown(); // 成功失败都必须减一,否则等待方可能永久阻塞
}
}).start();
new Thread(() -> {
try {
initCouponRules();
} catch (Throwable t) {
failure.compareAndSet(null, t);
} finally {
latch.countDown();
}
}).start();
new Thread(() -> {
try {
loadRiskBlacklist();
} catch (Throwable t) {
failure.compareAndSet(null, t);
} finally {
latch.countDown();
}
}).start();
latch.await(); // 主线程阻塞,直到计数器归零
if (failure.get() != null) {
throw new IllegalStateException("预热失败", failure.get());
}
System.out.println("预热完成,开闸放行");
openSeckillGate();
}
}
底层实现直接复用了第 5 篇讲过的 AQS 共享模式 :内部维护一个 Sync extends AbstractQueuedSynchronizer,state 就是那个计数器。countDown() 本质是 releaseShared(),每次把 state 减一,减到 0 时唤醒所有等待线程;await() 本质是 acquireSharedInterruptibly(),state 不为 0 就把当前线程挂进 AQS 的等待队列。这也解释了为什么它天然支持"多个线程同时 await()"------共享模式下,一个唤醒动作可以级联唤醒队列里所有等待的节点,而不像独占模式一次只放一个。
CountDownLatch 有个容易被忽略但很关键的限制:计数器只能减、不能重置,减到 0 之后这个门闩就永久保持开放 ,后续 await() 会直接通过;想开始一轮新的计数必须重新 new 一个。这决定了它适合的是"一次性"的等待场景------像上面这种应用启动预热,天然只需要发生一次。
示例里还有一条生产代码必须守住的规则:countDown() 要放在 finally 中,同时用单独的失败通道把异常传回等待方。 只放 finally 能避免永久等待,但如果不记录失败,主线程仍可能误把"三个任务都结束了"当成"三个任务都成功了"。
二、CyclicBarrier:可重复使用的栅栏
如果预热逻辑不是"启动一次",而是要反复出现 ------比如秒杀系统把海量的扣库存请求分成一批一批处理,每一批里的所有线程都必须先处理完当前这批,才能一起进入下一批 (常见于分片计算、批量任务分阶段执行)------这种"周期性汇合点"就该用 CyclicBarrier(循环栅栏)了。
java
public class BatchStockDeduction {
public static void main(String[] args) {
int workerCount = 4;
CyclicBarrier barrier = new CyclicBarrier(workerCount,
() -> System.out.println("本批次全部处理完毕,进入下一批")); // 栅栏放开时执行一次
AtomicBoolean cancelled = new AtomicBoolean();
for (int i = 0; i < workerCount; i++) {
int workerId = i;
new Thread(() -> {
for (int batch = 0; batch < 10 && !cancelled.get(); batch++) {
try {
processBatch(workerId, batch); // 处理当前批次分配到的任务
if (cancelled.get()) break;
barrier.await(); // 等其余3个线程也处理完这一批
} catch (InterruptedException e) {
cancelled.set(true);
barrier.reset();
Thread.currentThread().interrupt();
break;
} catch (BrokenBarrierException e) {
cancelled.set(true);
break; // 栅栏已经损坏,继续 await 只会再次失败
} catch (RuntimeException e) {
cancelled.set(true);
barrier.reset(); // 自己提前退出时,唤醒正在等这一批的同伴
throw e;
}
}
}).start();
}
}
}
关键区别在实现上:CyclicBarrier 不是基于 AQS ,而是靠一把 ReentrantLock 配合 Condition 手写的(回顾第 6 篇)。每个线程调用 await() 时,先 lock(),把计数减一,如果减到 0 就执行可选的 Runnable(就是上面例子里那个"进入下一批"的打印),然后 condition.signalAll() 唤醒所有等待者,同时把计数器重置回初始值 ------这就是"循环"(Cyclic)的含义,用完一次自动复位,可以无限次重复使用。如果计数没减到 0,当前线程就 condition.await() 挂起等待。
三、两者的边界:门闩等外部事件,栅栏等彼此到达
CountDownLatch 和 CyclicBarrier 长得很像------都是"等到某个数量才放行"------但语义上有一条清晰的分界线,容易在面试或者选型时搞混:
| 维度 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 等待的对象 | 一个或多个外部事件完成 | 一组彼此协作的线程互相到达同一个点 |
| 谁调用哪个方法 | 完成任务的线程调 countDown(),等待的线程调 await()------两者可以是完全不同的线程集合 |
所有参与协作的线程都调用同一个 await()------等待者和被等待者是同一批线程 |
| 能否重复使用 | 不能,减到 0 即失效 | 能,放行后自动重置计数 |
| 到达后的动作 | 无内置回调 | 可传入一个 Runnable,最后一个到达的线程触发,在放行前执行 |

用一句话记:门闩(Latch)适合"1 个或多个线程等另外几个线程把活干完"(如主线程等子系统初始化);栅栏(Barrier)适合"一组同伴互相等对方走到同一步,再一起往下走"(如批量任务分阶段推进)。 秒杀系统的启动预热天然是前者,分片批处理天然是后者,选错了工具虽然能通过一些变通用法凑合实现,但语义会很扭曲。
四、Semaphore:控制并发访问数量的信号量
前两个工具解决的是"等待",Semaphore(信号量)解决的是另一类问题:限制同一时刻能有多少个线程同时访问某个资源。
它的模型很直观:维护一定数量的"许可"(permit),线程访问资源前先 acquire() 拿一个许可(没有许可就阻塞等待),用完之后 release() 归还许可。
java
public class SeckillRateLimiter {
// 假设数据库连接池只能扛住 20 个并发扣库存操作,用信号量限流保护它
private final Semaphore semaphore = new Semaphore(20);
public void deductStock(Long productId) {
try {
semaphore.acquire(); // 拿不到许可就在这里排队等
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return; // 没拿到许可,不能执行 release()
}
try {
doDeductStock(productId); // 真正操作数据库
} finally {
semaphore.release(); // 只归还已经成功拿到的许可
}
}
}
底层同样是基于 AQS 的共享模式 (和 CountDownLatch 用的是同一套家族),state 表示当前剩余的许可数量。acquire() 是 tryAcquireShared() 失败则入队等待,release() 是增加许可后唤醒等待队列。
release() 必须和一次已经成功完成的 acquire() 配对。若线程在等待许可时被中断,acquire() 会直接抛异常,此时不能进入释放逻辑,否则会凭空增加许可、悄悄放宽并发上限。

一个常被拿来类比又容易搞混的点:如果把许可数设成 1,Semaphore 看起来就像一把互斥锁 ,但和 ReentrantLock 有本质区别------Semaphore 没有"持有者"概念,不具备可重入性,A 线程 acquire() 拿到的许可,可以由 B 线程 release() 归还 ,这在锁的语义里是不允许的(锁必须由持有它的线程自己释放)。这个特性反过来也是它的价值所在:Semaphore 表达的从来不是互斥关系,而是"资源配额"------无论是限制数据库连接数、限制同时下载的文件数,还是给下游接口做限流保护,本质都是"这批资源总量有限,谁先申请到谁先用,用完还回去",这跟"谁拿到锁就必须谁释放"是两种不同的心智模型。
五、Phaser:更灵活的多阶段协作(简要了解)
JDK 7 引入的 Phaser 可以看作 CyclicBarrier 的加强版,解决的是同一类"多阶段协作"问题,但补上了 CyclicBarrier 的两个短板:
- 参与者数量可以动态增减。
CyclicBarrier的参与线程数在构造时就固定死了;Phaser支持运行期通过register()/deregister()动态调整参与者数量------比如一个批处理任务,中途可能有新的工作线程加入或者提前退出。 - 支持分层(多级 Phaser 树)。 大量参与者时,单个
Phaser内部同步的开销会变高,可以把参与者组织成一棵树,每层内部先同步,再逐层向上汇聚,降低整体的同步竞争。
代价是 API 比 CyclicBarrier 复杂不少(arrive()、arriveAndAwaitAdvance()、awaitAdvance() 等一堆方法,还要理解"阶段号"这个概念)。实际业务代码里 Phaser 出现的频率并不高 ------多数分阶段同步的需求用 CyclicBarrier 就够了,只有真的遇到参与者数量会动态变化的场景才值得多学一层 Phaser 的复杂度。这里知道它存在、明白它解决什么问题即可,不做源码级展开。
六、Exchanger:两个线程之间的数据交换
Exchanger 是这几个工具里最小众的一个,只解决一件很窄的事:让恰好两个线程在同一个点上,各自把手里的数据交给对方。
java
Exchanger<List<Order>> exchanger = new Exchanger<>();
// 线程A:填充数据的一方
new Thread(() -> {
List<Order> buffer = fillOrderBuffer();
try {
List<Order> partnerBuffer = exchanger.exchange(buffer); // 等待线程B,交换后拿到对方的数据
process(partnerBuffer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
// 线程B:另一方
new Thread(() -> {
List<Order> buffer = fillAnotherBuffer();
try {
List<Order> partnerBuffer = exchanger.exchange(buffer); // 等待线程A,交换后拿到对方的数据
process(partnerBuffer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
exchange() 是双向阻塞的------先到的线程会一直等,直到另一个线程也调用 exchange(),两者才同时完成交换并各自继续往下走 ,这跟第 10 篇讲的 SynchronousQueue 的"面对面交接"神似(实际上 SynchronousQueue 内部的等待/传递机制和 Exchanger 的实现思路一脉相承)。它适合的是那种两个角色分工明确、需要周期性互换缓冲区的场景,比如一个线程专门负责填充数据、另一个线程专门处理数据,用交换而不是加锁复制的方式减少数据拷贝开销。这个场景在日常业务代码里确实不算常见,了解思路即可。
七、实战:秒杀系统预热+分片处理+限流的组合
把本篇讲的三个主力工具(CountDownLatch、CyclicBarrier、Semaphore)串起来看一个更完整的秒杀系统骨架:
java
public class SeckillSystem {
private final Semaphore dbSemaphore = new Semaphore(20); // 保护数据库的并发限流
public void start() throws InterruptedException {
// 第一阶段:CountDownLatch ------ 等待所有子系统初始化完成
CountDownLatch warmupLatch = new CountDownLatch(3);
AtomicReference<Throwable> warmupFailure = new AtomicReference<>();
ExecutorService warmupPool = Executors.newFixedThreadPool(3);
submitWarmup(warmupPool, warmupLatch, warmupFailure, this::loadStockToRedis);
submitWarmup(warmupPool, warmupLatch, warmupFailure, this::initCouponRules);
submitWarmup(warmupPool, warmupLatch, warmupFailure, this::loadRiskBlacklist);
try {
warmupLatch.await();
} catch (InterruptedException e) {
warmupPool.shutdownNow();
throw e;
} finally {
warmupPool.shutdown();
}
if (warmupFailure.get() != null) {
throw new IllegalStateException("系统预热失败", warmupFailure.get());
}
System.out.println("预热完成,开始处理请求");
// 第二阶段:CyclicBarrier ------ 分批处理请求,每批同步一次
int workerCount = 4;
CyclicBarrier batchBarrier = new CyclicBarrier(workerCount,
() -> System.out.println("本批处理完毕,统计当前剩余库存"));
AtomicBoolean cancelled = new AtomicBoolean();
for (int i = 0; i < workerCount; i++) {
new Thread(() -> {
for (int batch = 0; batch < 100 && !cancelled.get(); batch++) {
// 第三阶段:Semaphore ------ 每个具体的扣库存操作前先限流
try {
dbSemaphore.acquire();
try {
deductStockForBatch(batch);
} finally {
dbSemaphore.release(); // 只在 acquire 成功后进入这层 finally
}
if (cancelled.get()) break;
batchBarrier.await(); // 等其他worker也处理完这一批,再一起进下一批
} catch (InterruptedException e) {
cancelled.set(true);
batchBarrier.reset(); // 若中断发生在 acquire 阶段,也要放开其他等待者
Thread.currentThread().interrupt();
break;
} catch (BrokenBarrierException e) {
cancelled.set(true);
break;
} catch (RuntimeException e) {
cancelled.set(true);
batchBarrier.reset(); // 业务异常退出时避免同伴永久卡在栅栏
throw e;
}
}
}).start();
}
}
private void submitWarmup(ExecutorService pool,
CountDownLatch latch,
AtomicReference<Throwable> failure,
Runnable task) {
pool.execute(() -> {
try {
task.run();
} catch (Throwable t) {
failure.compareAndSet(null, t);
} finally {
latch.countDown();
}
});
}
}
三个工具在这里各司其职:CountDownLatch 管"启动前的一次性等待",CyclicBarrier 管"处理过程中反复出现的阶段汇合",Semaphore 管"任意时刻别让并发量把数据库压垮"------它们互不冲突,可以在同一个系统里叠着用,各自解决自己那一层的协调问题。
八、选型小结
| 场景 | 工具 | 关键理由 |
|---|---|---|
| 等待多个一次性任务全部完成再继续 | CountDownLatch |
一次性门闩,基于AQS共享模式,减到0永久放行 |
| 一组线程反复地"互相等对方到达再一起走" | CyclicBarrier |
可重复使用,锁+Condition实现,支持到达后回调 |
| 限制同一时刻并发访问某资源的线程数量 | Semaphore |
许可无持有者概念,表达资源配额而非互斥 |
| 参与者数量会动态变化的多阶段协作 | Phaser |
支持register/deregister动态增减+分层降低竞争 |
| 恰好两个线程之间周期性交换数据 | Exchanger |
双向阻塞的面对面数据交换,减少复制开销 |
一条主线贯穿全篇:这几个工具解决的都不是"互斥"问题(那是锁的地盘),而是"线程之间怎么排节奏"------谁等谁、等到什么条件、要不要重复等------分辨清楚这一点,选型时就不会纠结。
带走两句话:① 分不清用 CountDownLatch 还是 CyclicBarrier 时,问自己"等待者和被等待者是不是同一批线程"------不是同一批,用门闩;是同一批互相等,用栅栏。② Semaphore 不是锁的替代品,它表达的是资源配额,release() 不要求必须是 acquire() 的那个线程,这个特性用对了是灵活性,用错了是隐藏的 bug 来源。
下一篇,我们进入 ThreadLocal ------它和这一篇的思路完全相反:不是让多个线程协作,而是让每个线程拥有自己独立的一份数据,彼此互不干扰 。会讲清楚它的实现原理、经典的内存泄漏问题从哪来,以及 InheritableThreadLocal、阿里开源的 TransmittableThreadLocal 分别解决了什么场景。