CountdownLatch详解

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继续执行了):

  1. 加入CLH队列:thread会被封装成共享模式的 Node 节点,加入 CLH 队列。此时,队列中有一个虚拟头节点(dummyHead,设计链表时非常有用,充当“哨兵”的角色)。waitStatus会被其前驱(如果没有真实的前驱就会被虚拟的头节点)设置为 SIGNAL(表明当前当前线程需要被唤醒),然后该线程调用LockSupport.park()方法阻塞

  2. 释放与唤醒​:当另一个线程调用 countDown()使状态变为 0 时,会触发 tryReleaseShared,并返回 true。继而调用 doReleaseShared()方法

  3. doReleaseShared的核心操作​:这个方法会从头节点开始逐一检查(CAS)节点的 waitStatus,直到遇见没有被取消的任务节点。如果它是 SIGNAL,则会通过 CAS 操作将其尝试改为 0(表示初始状态),如果 CAS 成功,则调用 unparkSuccessor方法唤醒该节点中的线程。

  4. 线程恢复​:当前thread 被 unpark唤醒后,会再次尝试 tryAcquireShared。此时状态已为 0,获取成功。然后它会调用 setHeadAndPropagate方法,将自己设为新的头节点,并检查是否需要进行传播

    1. 注意,这个节点(本质上是线程)被唤醒后,调用 setHeadAndPropagate,将自己设置为新的头节点(即出队)

      1. 这里确实是会有些困惑,为什么设置头节点反而是出队操作呢?核心就在于这里java优化了链表的删除过程,其只是取消了节点中对于thread的引用,然后将这个节点作为管理角色(“哨兵”节点)

    2. setHeadAndPropagate 会检查需不需要进行传播(“共享”模式和“独占”模式的根本区别

      1. 检查是否满足“传播”条件。条件通常是 propagate > 0(表示还有剩余资源可供后续线程获取)或发现前驱节点的状态为 PROPAGATE(一个专门用于传播的状态标识,枚举类型,实际上=-3)。对于CountdownLatch,该条件总会满足(返回了一个正数)。

      2. 如果条件满足,该节点会再次调用 doReleaseShared(),实现循环传播

      3. 简单来说,就是释放上一个节点的时候,发现还有资源(通过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);
}

执行过程:

  1. 初始状态​:当你创建一个 CountDownLatch并传入一个初始计数 n时,这个值会被赋予 AQS 的 state字段。该字段由 volatile修饰,保证了多线程环境下的可见性,并且修改是原子的

  2. 核心调用链​:

    • 当你调用 latch.countDown()时,它实际上调用了 sync.releaseShared(1)

    • releaseShared方法会进一步调用 CountDownLatch 中 Sync类重写的 tryReleaseShared方法

  3. 关键的 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的结果。

  4. 触发唤醒​:唤醒队列中的等待线程,特别注意,队列中的线程是那些调用了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操作,使得线程池中的核心线程创建出来,减少真正任务执行时的“启动时间”

重要特性与注意事项

  1. 一次性使用​:CountDownLatch 的计数器无法重置。一旦计数器归零,再次调用 await()方法会立即返回,无法重复使用。如需重复使用,应考虑 CyclicBarrier。

  2. 线程安全​:countDown()和 await()方法都是线程安全的,多个线程可以安全地并发调用。

  3. 异常处理​:务必注意,如果在子线程中执行任务时发生异常,必须确保在 finally块中调用 countDown(),否则可能导致主线程在 await()处永久阻塞。

  4. 响应中断​:await()方法支持响应中断。如果等待的线程被中断,会抛出 InterruptedException。

  5. 超时等待​:提供了 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 不具备重用性,但其设计简洁,尤其适合需要等待多线程任务完成的场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值