CountDownLatch 是 Java 并发包 (java.util.concurrent) 中一个非常实用的同步工具类,它基于 AQS (AbstractQueuedSynchronizer, 也以此文浅显地梳理下AQS的底层逻辑,后续会写专门的博客介绍这部分) 构建,用于协调多个线程之间的执行顺序,允许一个或多个线程等待其他一组线程(工作线程)完成操作后再继续执行。
首先我先说明的是,被“park”(表现为阻塞)的线程只有调用(调用countdown方法的线程和阻塞无关,会继续正常执行)了await()方法的线程,一般来说,这个线程都是负责收集各个工作线程收尾工作的线程。
还有我强烈建议使用countdownLatch.await方法时加上超时时间,避免工作线程的阻塞导致主线程卡死,导致“服务”停滞。
核心实现原理
1. 基于 AQS 的共享模式
CountDownLatch 的核心是基于 AQS (AbstractQueuedSynchronizer) 的共享锁模式来实现的。其内部定义了一个继承自 AQS 的同步器 Sync类,这是理解其工作原理的关键。
// CountDownLatch 内部同步器类
private static final class Sync extends AbstractQueuedSynchronizer {
Sync(int count) {
setState(count); // 使用 AQS 的 state 字段作为计数器
}
int getCount() {
return getState();
}
// 尝试获取共享锁
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1; // 状态为0时获取成功,否则失败
}
// 尝试释放共享锁
protected boolean tryReleaseShared(int releases) {
for (;;) { // 自旋循环
int c = getState();
if (c == 0)
return false;
int nextc = c-1;
if (compareAndSetState(c, nextc)) // CAS 更新状态
return nextc == 0; // 只有当计数器减到0时才返回true
}
}
}
AQS 的 state字段在这里被用作计数器,其初始值通过构造函数设置。tryAcquireShared和 tryReleaseShared方法分别覆盖了 AQS 的共享式获取和释放同步状态的逻辑。
2. 关键方法的工作原理
await() 方法
当线程调用 await()方法时,会调用 AQS 的 acquireSharedInterruptibly(1)方法。该方法会调用被 Sync重写的 tryAcquireShared方法:若计数器 state 为 0,则返回 1,表示获取成功,线程继续执行;否则返回 -1,表示获取失败,当前线程会被阻塞并加入 AQS 的等待队列中。
public void await() throws InterruptedException {
// 此处列出这个方法是希望大家不要被传入的1这个参数迷惑,CountdownLatch的await方法只是
// 等待计数器归零,这个参数本身并不影响函数的具体行为
// 此处的参数1是为了兼容其他类,如Semaphore调用时可以传入获取多个“许可”的数量
// acquireSharedInterruptibly底层会调用子类实现的tryAcquireShared
// 在CountdownLatch中就是sync子类实现的这个方法(去检查状态)
sync.acquireSharedInterruptibly(1);
}
详细执行步骤(以检查状态不为0为例,为0就直接返回,然后当前thread继续执行了):
-
加入CLH队列:thread会被封装成共享模式的 Node 节点,加入 CLH 队列。此时,队列中有一个虚拟头节点(dummyHead,设计链表时非常有用,充当“哨兵”的角色)。waitStatus会被其前驱(如果没有真实的前驱就会被虚拟的头节点)设置为 SIGNAL(表明当前当前线程需要被唤醒),然后该线程调用LockSupport.park()方法阻塞
-
释放与唤醒:当另一个线程调用 countDown()使状态变为 0 时,会触发 tryReleaseShared,并返回 true。继而调用 doReleaseShared()方法
-
doReleaseShared的核心操作:这个方法会从头节点开始逐一检查(CAS)节点的 waitStatus,直到遇见没有被取消的任务节点。如果它是 SIGNAL,则会通过 CAS 操作将其尝试改为 0(表示初始状态),如果 CAS 成功,则调用 unparkSuccessor方法唤醒该节点中的线程。
-
线程恢复:当前thread 被 unpark唤醒后,会再次尝试 tryAcquireShared。此时状态已为 0,获取成功。然后它会调用 setHeadAndPropagate方法,将自己设为新的头节点,并检查是否需要进行传播
-
注意,这个节点(本质上是线程)被唤醒后,调用 setHeadAndPropagate,将自己设置为新的头节点(即出队)
-
这里确实是会有些困惑,为什么设置头节点反而是出队操作呢?核心就在于这里java优化了链表的删除过程,其只是取消了节点中对于thread的引用,然后将这个节点作为管理角色(“哨兵”节点)
-
-
setHeadAndPropagate 会检查需不需要进行传播(“共享”模式和“独占”模式的根本区别)
-
检查是否满足“传播”条件。条件通常是 propagate > 0(表示还有剩余资源可供后续线程获取)或发现前驱节点的状态为 PROPAGATE(一个专门用于传播的状态标识,枚举类型,实际上=-3)。对于CountdownLatch,该条件总会满足(返回了一个正数)。
-
如果条件满足,该节点会再次调用 doReleaseShared(),实现循环传播
-
简单来说,就是释放上一个节点的时候,发现还有资源(通过PROPAGATE),就继续唤醒当前节点。而当前节点唤醒后。会继续检查(可能可以,也可能不可以)释放能继续唤醒下一个节点
-
-
需要特别注意的是:
-
需要区分头节点(头节点是唤醒过程中的关键角色,头节点是变化的,正如之前介绍的“出队”操作)和前驱节点(前驱节点是节点能否阻塞的关键判断对象,必须找到可以承诺唤醒当前节点(可能有cancel的节点)的前驱才能安全的阻塞,注意如果发现有cancelled(实际上=1)的节点会重新拼接队列,去掉发现的cancel节点)
-
在节点升级为头节点之前也会检查前驱是不是头节点,如果是,并且获取到资源,那么才可以安全地升级为头节点
-
获取到的锁的线程对应的节点就是负责管理的“头节点”,但是这个节点中对应的线程的引用已经失效了。这是种设计策略,既实现了线程的出队,又实现了节点的管理作用
-
内部操作本质上都依赖于CAS,可以理解为都是无“锁”操作,性能尚可。多线程直接对于共享变量的可见性,则依赖于volatile
countDown() 方法
当线程调用 countDown()方法时,会调用 AQS 的 releaseShared(1)方法。该方法会调用被 Sync重写的 tryReleaseShared方法:使用 CAS (Compare-And-Swap) 操作循环尝试将 state 减 1。如果 CAS 成功且 state 减为 0,则返回 true,此时 AQS 会唤醒所有在等待队列中的线程。
public void countDown() {
sync.releaseShared(1);
}
执行过程:
-
初始状态:当你创建一个 CountDownLatch并传入一个初始计数 n时,这个值会被赋予 AQS 的 state字段。该字段由 volatile修饰,保证了多线程环境下的可见性,并且修改是原子的
-
核心调用链:
-
当你调用 latch.countDown()时,它实际上调用了 sync.releaseShared(1)
-
releaseShared方法会进一步调用 CountDownLatch 中 Sync类重写的 tryReleaseShared方法
-
-
关键的 tryReleaseShared方法:
这个方法实现了计数器的安全递减,其核心逻辑如下:
-
它进入一个自旋循环,使用 getState()获取当前的计数器值 c。
-
如果 c已经为 0,说明计数器早已归零,直接返回 false,无需任何操作。
-
如果 c大于 0,则计算 nextc = c - 1。
-
然后通过 CAS (Compare-And-Swap) 操作 compareAndSetState(c, nextc)尝试原子性地更新 state的值。
-
CAS 的重要性:这个操作保证了即使多个线程同时调用 countDown(),也只有一个线程能成功更新 state的值,从而确保了计数器递减的线程安全。如果 CAS 失败,线程会进入下一次循环重试,直到成功为止
-
最后,方法返回 nextc == 0的结果。
-
-
触发唤醒:唤醒队列中的等待线程,特别注意,队列中的线程是那些调用了wait方法的线程
在 releaseShared方法中,如果 tryReleaseShared返回 true(即计数器从 1 减到了 0),就会调用 doReleaseShared()方法。这个方法会遍历 AQS 维护的 CLH 队列,并唤醒所有因为调用 await()方法而阻塞的线程,让它们可以继续执行
3. CAS 操作与线程安全
我们可以发现以上操作都依赖于CAS,本小节队CAS方法进行简单介绍。
tryReleaseShared方法中的 CAS 操作 (如 compareAndSetState) 是确保 countDown()方法线程安全的关键。它避免了使用重量级锁,通过处理器原语(如 x86/x64架构下的cmpxchg指令)实现了高效的无锁更新。
更底层的依赖于硬件(总线)的缓存行锁定,此处不再深究了。
CAS 操作包含三个核心参数:一个内存位置(V)、一个预期原值(A) 和一个新值(B)。其操作逻辑是:当且仅当内存位置 V 的当前值等于预期值 A 时,处理器才会原子性地将 V 的值更新为 B。如果当前值与预期值不匹配,则操作失败,通常意味着在此期间有其他线程修改了该数据。整个“比较-交换”过程由处理器通过一条指令保证其原子性,不会被中断。
但是这里会有个ABA问题,就是说比较期间,线程 T1 读取值 A,之后另一线程 T2 将值从 A 改为 B 又改回 A。T1 进行 CAS 检查时发现值仍是 A,误以为数据未变而操作成功,但中间状态的变化可能引发逻辑错误。
CountDownLatch 与其它同步工具的对比
为了更清晰地理解 CountDownLatch 的定位,下表将其与 Semaphore、CyclicBarrier 进行了对比:
|
特性 |
CountDownLatch |
Semaphore |
CyclicBarrier |
|---|---|---|---|
|
核心目标 |
等待一组操作完成 |
控制资源并发访问数量 |
多线程协同到达屏障点 |
|
重用性 |
否(一次性) |
是(许可允许释放) |
是(可自动或手动重置) |
|
底层实现 |
AQS 共享锁 |
AQS 共享锁 |
ReentrantLock + Condition |
|
适用场景 |
主从协作(一等多) |
资源池化/限流(多对多) |
多线程分阶段协作(多对多) |
|
异常处理 |
无特殊异常 |
需处理 InterruptedException |
需处理 BrokenBarrierException |
典型应用场景
CountDownLatch 适用于以下典型场景:
-
并行执行再汇总:在某些数据分析或计算密集型任务中,将任务分割成多个子任务并行执行,主线程等待所有子任务完成后再汇总结果。
-
多服务依赖协调:当一个服务依赖多个其他服务时,可以使用 CountDownLatch 来同步各个服务的调用,并确保所有依赖服务准备好之后再执行主任务。
-
服务启动检查:系统在完全启动之前,需要依赖多个外部服务,可以使用 CountDownLatch 确保所有服务都正常启动后再对外提供服务。
-
模拟高并发测试:在性能测试中,使用两个 CountDownLatch,一个用于确保所有线程准备就绪,另一个用于等待所有线程同时完成任务。
-
本质上都是先分发执行,然后最终主线程执行汇总。但是有一个比较特殊的案例,就是线程池的预热
-
可以在创建线程池后首先执行countdown操作,使得线程池中的核心线程创建出来,减少真正任务执行时的“启动时间”
-
重要特性与注意事项
-
一次性使用:CountDownLatch 的计数器无法重置。一旦计数器归零,再次调用 await()方法会立即返回,无法重复使用。如需重复使用,应考虑 CyclicBarrier。
-
线程安全:countDown()和 await()方法都是线程安全的,多个线程可以安全地并发调用。
-
异常处理:务必注意,如果在子线程中执行任务时发生异常,必须确保在 finally块中调用 countDown(),否则可能导致主线程在 await()处永久阻塞。
-
响应中断:await()方法支持响应中断。如果等待的线程被中断,会抛出 InterruptedException。
-
超时等待:提供了 await(long timeout, TimeUnit unit)方法,允许线程只等待指定的时间。
代码示例
以下是一个简单的使用示例,演示了主线程等待多个子线程完成任务:
import java.util.concurrent.CountDownLatch;
public class CountDownLatchExample {
private static final int TASK_COUNT = 3;
public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(TASK_COUNT);
for (int i = 0; i < TASK_COUNT; i++) {
final int taskId = i;
new Thread(() -> { // 一般会用线程池,此处为了简单,直接通过new线程的方式执行
try {
System.out.println("任务" + taskId + " 开始执行");
Thread.sleep((long) (Math.random() * 1000)); // 模拟任务执行时间
System.out.println("任务 " + taskId + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown(); // 确保计数器递减,请务必记得把countdown放在finally中,否则打断或者阻塞都有可能导致服务不可用
}
}).start();
}
latch.await(); // 主线程等待所有任务完成
System.out.println("所有任务已完成,主线程继续执行");
}
}
💎总结
CountDownLatch 是一个轻量级、不可复用的倒计数同步器,适合简单的一次性线程协调。其基于 AQS 的共享锁实现使得线程等待和计数器更新具有高效的并发性。虽然 CountDownLatch 不具备重用性,但其设计简洁,尤其适合需要等待多线程任务完成的场景。
1640

被折叠的 条评论
为什么被折叠?



