CountDownLatch(倒计时器)的工作原理是什么?其典型使用场景有哪些?
CountDownLatch(倒计时器)是 Java 并发包 (java.util.concurrent) 中的一个基础同步工具类。它的主要作用是允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。
下面详细解析其工作原理和典型使用场景。
一、 工作原理
CountDownLatch 的底层是基于 AQS (AbstractQueuedSynchronizer,抽象队列同步器) 实现的。它利用了 AQS 的共享锁机制。
1. 核心结构与状态 (State)
- 初始化: 创建
CountDownLatch时,必须传入一个整数count(如new CountDownLatch(3))。这个count值会被直接赋值给 AQS 的内置状态变量state。 - 这个
state就代表了需要倒计时的次数。
2. 核心方法
await()方法(等待):- 当一个线程调用
await()时,它会去检查 AQS 的state值。 - 如果
state > 0,说明倒计时还没结束,该线程会被放入 AQS 的等待队列中阻塞挂起。 - 如果
state == 0,说明倒计时已经结束,该线程会直接放行,继续执行后续代码。
- 当一个线程调用
countDown()方法(倒数):- 当其他线程完成了一项任务后,调用
countDown()方法。 - 该方法会利用 CAS (Compare-And-Swap) 机制将 AQS 的
state值减 1。 - 当
state被减到 0 时,AQS 会自动唤醒等待队列中的所有阻塞线程(共享锁的传播唤醒机制),这些被唤醒的线程将继续执行。
- 当其他线程完成了一项任务后,调用
3. 重要特性
- 不可重用(一次性): 一旦
count被减到 0,CountDownLatch就失去了作用,无法被重置。如果需要可以循环使用的屏障,应该使用CyclicBarrier。 - 线程安全: 底层利用 AQS 和 CAS 保证了并发环境下计数器递减的原子性和可见性。
二、 典型使用场景
CountDownLatch 的使用场景主要可以分为两类:“一等多” 和 “多等一”。
场景一:主线程等待多个子线程完成任务(一等多 / 任务汇总)
这是最常见的场景。主线程把一个大任务拆分成多个小任务,交给多个线程去执行。主线程必须等待所有小任务都执行完毕后,才能继续执行(比如汇总结果)。
- 生活隐喻: 旅游团团长(主线程)必须等待所有游客(子线程)都上大巴车后,才能宣布发车。
- 实际业务举例:
- 并行数据加载: 比如打开一个大屏看板,需要同时查询用户数据、订单数据、库存数据。可以开三个线程去查,主线程用
await()等待三个查询全部完成后,再拼装成一个 JSON 响应给前端,从而大幅降低接口响应时间。 - 分片下载/处理: 下载一个大文件,分成 5 个线程下载不同的分片,全部下载完成后,主线程负责把 5 个分片合并成一个完整文件。
- 并行数据加载: 比如打开一个大屏看板,需要同时查询用户数据、订单数据、库存数据。可以开三个线程去查,主线程用
代码示例伪代码:
java
int taskCount = 3;
CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
new Thread(() -> {
try {
// 执行具体的业务逻辑...
System.out.println("子任务执行完成");
} finally {
// 务必在 finally 中调用,防止异常导致计数器无法清零
latch.countDown();
}
}).start();
}
System.out.println("主线程等待子任务...");
latch.await(); // 主线程阻塞,直到 count 变为 0
System.out.println("所有子任务完成,主线程继续执行,进行结果汇总...");
场景二:多个子线程等待主线程发令(多等一 / 并发压测)
创建多个线程,让它们处于就绪状态,但先不执行核心逻辑,而是全部阻塞等待。当主线程发出一个指令后,所有线程同时开始执行。这种做法常用来模拟高并发情况。
- 生活隐喻: 百米赛跑,所有运动员(子线程)在起跑线上就位,等待裁判(主线程)的“发令枪”。发令枪一响,所有人同时起跑。
- 实际业务举例:
- 并发压力测试: 在代码里写一个测试工具,想测试某个接口在 100 个并发请求下的表现。如果用 for 循环启动 100 个线程,由于线程创建有时间差,并非绝对的同时并发。可以使用
CountDownLatch(1),让 100 个线程启动后先await(),主线程最后countDown()扣动扳机,实现真正的并发齐发。
- 并发压力测试: 在代码里写一个测试工具,想测试某个接口在 100 个并发请求下的表现。如果用 for 循环启动 100 个线程,由于线程创建有时间差,并非绝对的同时并发。可以使用
代码示例伪代码:
java
// 发令枪,初始值为 1
CountDownLatch startingGun = new CountDownLatch(1);
for (int i = 0; i < 100; i++) {
new Thread(() -> {
try {
// 所有运动员准备就绪,等待发令枪响
startingGun.await();
// 发令枪响后,瞬间并发执行测试逻辑
System.out.println(Thread.currentThread().getName() + " 开始发起请求...");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
System.out.println("裁判准备中...");
Thread.sleep(1000); // 模拟准备时间
System.out.println("砰!发令枪响!");
startingGun.countDown(); // 扣动发令枪,count变为0,瞬间唤醒上述100个线程
场景三:结合使用的“多等多”(发令枪 + 终点线)
在上面赛跑的例子中,通常还会结合第二个 CountDownLatch 作为“终点线”。
- 裁判开枪(
CountDownLatch(1)),所有运动员起跑。 - 裁判等待所有运动员冲线(
CountDownLatch(100)),然后再宣布比赛结束。
三、 使用时的注意事项(避坑指南)
- 必须在
finally块中调用countDown(): 如果子线程在执行任务时抛出未捕获的异常,且没有在finally中调用countDown(),会导致count永远减不到 0,主线程将会永久死锁(死等)。 - 设置超时时间: 在生产环境中,推荐使用
await(long timeout, TimeUnit unit)代替无参的await()。这样即使某个子线程卡死,主线程也能在超时后苏醒并进行异常处理,避免整个系统挂起。 - 替代方案: 在 Java 8 之后,如果是针对复杂的异步任务编排和结果聚合,通常推荐使用
CompletableFuture,它的功能更强大,语法也更现代(无需手动维护计数器)。CountDownLatch如今多用于简单的等待同步场景或底层框架开发中。