Java-CountDownLatch原理及解析

CountDownLatch原理及解析

CountDownLatch基础概念

CountDownLatch可以理解为一个一次性的同步计数器。它内部维护了一个计数值:

  • 初始化时指定计数大小
  • 一个或多个线程调用await()进入等待
  • 其他线程通过countDown()不断将计数减1
  • 当计数减到0时,所有等待线程继续向下执行

它解决的核心问题是:让某个线程(或某批线程)等待另外一些操作全部完成之后再继续执行。

例如:

  • 主线程等待多个子任务执行完再汇总结果
  • 某个初始化流程需要等多个前置模块准备完成后再开始
  • 测试场景中需要等待若干异步任务都结束后再断言

需要注意的是,CountDownLatch更适合一次性协调场景。计数归零之后,它不会自动重置,如果想重复使用同一类同步屏障,更适合考虑CyclicBarrier这类组件。

它之所以是“一次性”的,本质原因就在于内部的state只会单向递减到0。AQS并不会在计数归零后自动帮它把状态恢复到初始值,所以同一个CountDownLatch对象完成一次协调任务后,就不能再回到初始状态重新使用。

CountDownLatch使用方式

CountDownLatch最常见的使用模式其实很固定:

  1. 初始化一个计数器
  2. 一个或多个线程调用await()等待
  3. 其他线程在任务完成后调用countDown()
  4. 当计数归零时,等待线程继续执行

一个典型示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class CountDownLatchDemo {

public static void main(String[] args) throws InterruptedException {
CountDownLatch latch = new CountDownLatch(3);

for (int i = 0; i < 3; i++) {
final int taskId = i;
new Thread(() -> {
try {
System.out.println("任务" + taskId + "开始执行");
Thread.sleep(1000);
System.out.println("任务" + taskId + "执行完成");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}).start();
}

latch.await();
System.out.println("所有任务都执行完成,主线程继续执行");
}
}

上面的流程里,主线程会阻塞在latch.await()处,直到3个工作线程都执行完并分别调用一次countDown(),计数器减为0,主线程才会继续向下执行。

如果从使用语义上和Thread.join()做对比,也能更清楚地看到它的定位差异:

  • join()等待的是某个具体线程结束
  • CountDownLatch等待的是某个计数条件归零

因此CountDownLatch并不要求“完成事件”一定来自线程本身,它等的可以是任务、阶段、回调,或者任何你愿意用countDown()表示的一次完成信号。

核心方法

await()

await()表示当前线程进入等待,直到计数归零。

如果调用时计数已经是0,那么线程不会阻塞,会直接继续执行;如果计数还大于0,当前线程就会进入等待状态。

await()本身是可中断的:如果线程在等待过程中被中断,就会抛出InterruptedException并结束等待,而不是继续无限阻塞。

它还有带超时参数的方法:

1
boolean await(long timeout, TimeUnit unit)

表示在指定时间内等待计数归零,超时则返回false

因此从等待结果上看,await()大致有三种结束方式:

  • 计数正常归零,方法返回
  • 等待过程中线程被中断,抛出InterruptedException
  • 带超时版本等待超时,返回false

countDown()

countDown()会把当前计数减1

这里有两个很关键的点:

  • countDown()不会阻塞调用线程
  • 计数减到0之后,再继续调用countDown()也不会变成负数

而且并不是每一次countDown()都会唤醒等待线程。只有最后一次把计数减到0时,AQS才会真正进入共享模式的传播唤醒流程;在那之前,等待线程依旧会继续阻塞。

也就是说,它更像是“上报一次完成事件”,而不是一个需要等待结果的同步方法。

getCount()

getCount()用于查看当前剩余计数。

这个方法更适合做调试、日志打印或状态观察,而不是作为业务线程安全控制的核心判断依据。因为在并发场景下,读到的计数值可能很快就会被其他线程改掉。

CountDownLatch底层原理

CountDownLatch底层是基于AQS(AbstractQueuedSynchronizer)共享模式实现的。

如果从AQS视角来看,它的核心映射关系非常直接:

  • state:表示当前剩余计数
  • await():对应共享获取资源
  • countDown():对应共享释放资源

也就是说,CountDownLatch并不是自己从零维护一套等待唤醒逻辑,而是复用了AQS在共享模式下的排队、阻塞和传播唤醒能力。

state 的含义

CountDownLatch初始化时会把计数值设置到AQS的state里。

例如:

  • state = 3:还需要3次countDown()
  • state = 1:还差最后一次
  • state = 0:条件已经满足,等待线程可以继续执行

所以对CountDownLatch来说,state并不是锁重入次数,也不是许可证个数,而是剩余计数

await() 对应共享获取

调用await()时,本质上会尝试以共享模式获取资源。

对于CountDownLatch来说,这里的“资源是否可获取”判断非常简单:

  • 如果state == 0,说明计数已经归零,获取成功,线程直接继续执行
  • 如果state > 0,说明条件还未满足,获取失败,线程进入AQS同步队列等待

因此,等待线程并不是在“等某个锁释放”,而是在等计数归零这个条件成立

之所以这里要走共享模式而不是独占模式,关键就在于:一旦state == 0,并不是只允许某一个等待线程继续执行,而是所有等待线程都可以通过。所以这里的“共享”并不是多个线程同时访问某个共享资源,而是多个等待线程在条件满足后都可以被一起放行

countDown() 对应共享释放

调用countDown()时,本质上是在共享模式下释放资源,也就是把state减1。

如果减完之后:

  • state > 0:说明计数还没归零,等待线程继续等待
  • state == 0:说明条件已经满足,AQS开始唤醒同步队列中的等待线程

这也是为什么说CountDownLatch虽然不是传统意义上的共享资源访问器,但它在AQS里依然使用的是共享模式语义

从实现主线去理解,countDown()可以粗略看成这样一条过程:

  1. 读取当前state
  2. 基于CAS尝试把state减1
  3. 如果CAS失败,说明有并发更新,重新读取再重试
  4. 只有当某次更新真正把state1改到0时,才触发后续共享释放传播

所以countDown()真正关键的不是“调用了多少次这个方法”,而是哪一次把计数推进到了0

从内存语义上看,还有一个很重要的结论:工作线程在调用countDown()之前对共享数据做的写入,对于那些因为计数归零而从await()成功返回的线程来说,是可见的。也正因为有这层happens-before关系,主线程在等待结束后再去读取多个子任务写入的结果,才有明确的可见性保障。

执行流程

await -> countDown -> 计数归零 -> 唤醒等待线程这条主线串起来,大致可以分成下面几步:

1. 初始化计数

创建CountDownLatch时传入初始值,这个值会保存到AQS的state中。

1
CountDownLatch latch = new CountDownLatch(3);

此时表示还需要3次countDown(),等待线程才能继续执行。

2. 调用await检查计数

线程调用await()时,会先检查当前state

  • 如果已经是0,直接通过
  • 如果大于0,则进入AQS同步队列等待

这里等待线程会被挂起,不再继续执行后续代码。

3. 其他线程不断countDown

其他线程每完成一个子任务,就调用一次countDown()

每调用一次,state都会减1:

  • 3 -> 2
  • 2 -> 1
  • 1 -> 0

前两次减完之后,等待线程仍然不会被真正放行,因为条件还没有满足。

4. 最后一次countDown使计数归零

当最后一次countDown()state减为0时,AQS会进入共享模式下的唤醒流程。

这时等待在同步队列中的线程不再需要继续阻塞,而是会被逐步唤醒,重新参与调度。

5. 等待线程继续执行

被唤醒的线程恢复执行后,await()返回,后续逻辑继续向下运行。

所以从执行语义上看,await()返回并不是表示“刚刚被某个线程通知”,而是表示:计数已经归零,并且当前线程已经从等待状态恢复。

这里还要补一个容易忽略的边界:如果等待线程在await()期间被中断,它只是自己提前结束等待,并不会影响门闩本身的计数状态。也就是说,中断不会帮你把countDown()“补回来”,计数器也不会自动回滚。

CountDownLatch与其他并发工具的区别

和 CyclicBarrier 的区别

  • CountDownLatch:更适合“等别人做完”
  • CyclicBarrier:更适合“一组线程互相等待,全部到齐后一起继续”

并且CountDownLatch是一次性的,而CyclicBarrier在满足条件后通常可以继续复用。

和 Semaphore 的区别

  • CountDownLatch控制的是“剩余计数是否归零”
  • Semaphore控制的是“同时允许多少线程访问资源”

两者都基于AQS共享模式,但表达的同步语义完全不同:一个偏阶段完成通知,一个偏并发流量控制。

如果再从业务语义上补一句,也可以这样记:

  • CountDownLatch只关心“结束了几个”
  • Semaphore更关心“现在允许进去几个”

常见误用与实践建议

  • 初始化计数必须和真实完成事件数量匹配,否则可能永远等不到归零。
  • 子任务里调用countDown()最好放在finally中,避免中途异常导致主线程永久等待。
  • 如果只是等待某一个线程结束,优先考虑join(),不必为了计数而额外引入门闩。
  • 如果需要循环多轮使用同一个同步点,不要用CountDownLatch硬撑,应该换成更适合复用的组件。

另外还要注意一点:CountDownLatch只表达“阶段结束”,并不自动区分“成功结束”还是“失败结束”。很多时候任务即使失败了,也依然应该在finally里执行countDown(),否则等待方可能永远卡住;至于任务到底成功还是失败,则需要额外的共享结果、异常收集或状态变量来表达。

一个很经典的实践方式是“双门闩”模式:

  • 一个startLatch控制所有工作线程统一开始
  • 一个doneLatch控制主线程等待所有工作线程全部结束

这样既可以让多线程尽量同时起跑,也可以在最后统一收口等待结果。

这种模式在并发压测、并发单测、批量任务统一起跑的场景里非常常见:

  • startLatch初始值通常为1,所有工作线程先await()等起跑信号
  • 主线程准备完成后执行一次countDown(),所有工作线程同时放开
  • 每个工作线程结束时对doneLatch执行countDown()
  • 主线程最后再await()等待全部任务结束

这样既能最大化模拟“同一时刻并发开始”,也能在结尾统一回收结果。

小结

可以把CountDownLatch理解成:基于AQS共享模式实现的一次性同步计数器。

理解这篇时,重点抓住下面这条主线就够了:

  • state 表示剩余计数
  • await() 检查计数是否已经归零
  • countDown() 负责递减计数
  • state == 0 时,AQS 共享模式唤醒等待线程

它的最大特点有两个:

  • 使用简单,非常适合“等待一批任务完成”场景
  • 一次性使用,计数归零后不会自动重置

如果从使用习惯上再补一句,可以记成:

  • await()负责等
  • countDown()负责报完成
  • state == 0负责放行所有等待线程

如果再加一句最容易忽略的提醒,就是:

  • await()被中断不会回滚计数
  • countDown()也不表示任务一定成功

Java-CountDownLatch原理及解析
https://leo-wxy.github.io/2020/10/08/Java-CountDownLatch原理及解析/
作者
Leo-Wxy
发布于
2020年10月8日
许可协议