此前的文章,我们讲的都是J.U.C中的集合,本文开始我们开始学习J.U.C的另外一个模块--工具。

首先要讲的就是CountDownLatch。

在高并发场景中,我们经常需要协调多个线程的步调,比如主线程等待所有子任务完成,或模拟一组线程的并发开始。

CountDownLatch就是为了解决这些问题而生的工具。

CountDownLatch是J.U.C中的一个强大同步工具,专门用来解决一等多的场景:

一个或多个线程等待其他线程完成任务后再继续执行。

本文结合代码示例和场景,深入浅出地讲解CountDownLatch的用法、原理和注意事项。

一、什么是CountDownLatch

1.1. CountDownLatch概念

CountDownLatch是一个同步辅助类,允许一个或多个线程等待,直到在其他线程中执行的一组操作完成。

他通过一个计数器实现协调,计数器初始化为某个值,每次任务完成时计数减一,当计数归零时,所有等待的线程被释放。

可以把他看成是一个一次性的倒计时门栓,每完成一个事就countDown()一次,计数减到0时,所有在await()的线程统一放行。

老规矩,举个栗子:有这么一个场景,一场赛跑,选手们站在起跑线等待发令枪响,裁判倒数3,2,1,当计数归零的时候,发令枪响,所有选手同时起跑。

countDown就是裁判,复杂倒计时。

这些选手就可以看成是等待的线程,调用await阻塞。

其他线程调用countDown()减少计数。

CountDownLatch的核心作用就是让主线程(或某些线程)等待多个子任务完成,不需要逐个调用 Thread.join()。特别适合需要协调多个线程完成固定数量任务的场景。

1.2. 关键特性

一次性:计数器归零之后,CountDownLatch不能重用。

线程安全:支持多线程并发调用countDown()。

1.3. 主要方法

CountDownLatch(int count):初始化计数器。

countDown():计数减一,表示一个任务完成。

await():阻塞当前线程,直到计数归零。

await(long timeout, TimeUnit unit):带超时的等待。

getCount():获取当前计数。

二、核心原理

CountDownLatch的工作机制可以用一个倒计时门闩来描述:

1、初始化

创建CountDownLatch对象,指定初始计数count,表示需要等待的任务数。

2、计数减少

每个任务完成之后,调用countDown(),计数减一。

3、等待

主线程调用await(),阻塞直到计数归零。

4、释放

计数归零后,所有等待的线程被唤醒,继续执行。

 

一起来看一个简单的示例代码,模拟主线程等待三个子线程完成任务。

package com.lazy.snail.day52;

import java.util.concurrent.CountDownLatch;

/**
 * @ClassName CountDownLatchDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/10/20 14:28
 * @Version 1.0
 */
public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);

        for (int i = 1; i <= 3; i++) {
            int taskId = i;
            new Thread(() -> {
                System.out.println("任务 " + taskId + " 开始执行...");
                try {
                    Thread.sleep((long) (Math.random() * 1000));
                    System.out.println("任务 " + taskId + " 完成!");
                    latch.countDown();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }).start();
        }

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

执行效果:

 

上面的代码如果用Thread.join()来实现的话,需要这样写:

package com.lazy.snail.day52;

import java.util.ArrayList;
import java.util.List;

/**
 * @ClassName JoinDemo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/10/20 16:09
 * @Version 1.0
 */
public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        // 用于存储所有子线程的引用
        List<Thread> threads = new ArrayList<>();

        for (int i = 1; i <= 3; i++) {
            int taskId = i;
            Thread thread = new Thread(() -> {
                System.out.println("任务 " + taskId + " 开始执行...");
                try {
                    Thread.sleep((long) (Math.random() * 1000));
                    System.out.println("任务 " + taskId + " 完成!");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
            threads.add(thread);
            thread.start();
        }

        System.out.println("主线程等待所有任务完成...");

        // 遍历并等待所有线程执行结束
        for (Thread thread : threads) {
            thread.join();
        }

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

虽然效果 是一样的,但是你必须持有每个线程对象的引用。

强依赖于线程的生命周期,主线程等待的是线程的终止,而不仅仅是任务的完成,粒度很粗。

在大量线程的场景下,管理这些线程对象会比较麻烦。

当然你也可以使用ExecutorService来实现上面的功能,线程池能够很好的管理线程资源。

三、使用场景

CountDownLatch在并发编程中有广泛的应用,下面我们看三个比较典型的场景。

3.1. 任务分解与汇总

主线程需要等待多个子任务完成后再汇总结果。

比如文件分片下载,等待所有分片下载完成之后再合并;数据处理,多个线程并行处理数据块,汇总最终结果。

看一个计算一个大数组的和,10 个线程并行处理的案例:

package com.lazy.snail.day52;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;

/**
 * @ClassName CountDownLatchDemo2
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/10/20 16:16
 * @Version 1.0
 */
public class CountDownLatchDemo2 {
    public static void main(String[] args) throws InterruptedException {
        int[] array = new int[100];
        for (int i = 0; i < array.length; i++) {
            array[i] = i + 1;
        }
        int threadCount = 10;
        CountDownLatch latch = new CountDownLatch(threadCount);
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        AtomicLong totalSum = new AtomicLong(0);

        int chunkSize = array.length / threadCount;
        for (int i = 0; i < threadCount; i++) {
            int start = i * chunkSize;
            int end = (i == threadCount - 1) ? array.length : (i + 1) * chunkSize;
            executor.submit(() -> {
                try {
                    long sum = 0;
                    for (int j = start; j < end; j++) {
                        sum += array[j];
                    }
                    totalSum.addAndGet(sum);
                    System.out.println(Thread.currentThread().getName() + " 计算完成,部分和: " + sum);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        System.out.println("总和: " + totalSum.get());
        executor.shutdown();
    }
}

计算效果:

 

先创建了一个整型数组,填充一些数进去。

CountDownLatch创建一个计数为10的倒计时门闩。主线程需要等10个信号才能继续执行。

简单的用ExecutorService创建一个固定包含10个线程的线程池。

AtomicLong创建一个原子长整型变量,因为多个线程会同时修改总和,如果使用普通的long类型,会产生线程安全问题。AtomicLong 保证了在多线程环境下,addAndGet操作是安全的。

计算出每个线程需要处理的数据块大小。循环10次,创建10个独立的计算任务。

把每个计算任务提交给线程池。线程池会从他的10个线程里取出一个来执行这个任务。

每个线程在完成自己的计算任务后,都会执行finally块里的代码。

latch.countDown() 会把CountDownLatch内部的计数器减一。

同时,主线程在提交完所有任务后,会执行到这一行。await() 方法会阻塞主线程,让他在这里暂停。会一直等到 CountDownLatch的计数器被减到0为止。

当第10个线程完成任务并调用countDown()后,latch的计数器变成0。

计数器归零的瞬间,正在await()的主线程被唤醒,继续往下执行。

这个时候,可以保证totalSum已经包含了所有10个部分和。

最后关闭线程池,释放资源。

3.2. 初始化等待

有些系统在启动的时候,需要等待多个服务初始化完成。

比如,Web应用程序启动前需确保数据库、缓存和日志服务都准备好了。

我们来模拟一下服务的初始化过程:

package com.lazy.snail.day52;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @ClassName CountDownLatchDemo3
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/10/20 16:33
 * @Version 1.0
 */
public class CountDownLatchDemo3 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(3);
        ExecutorService executor = Executors.newFixedThreadPool(3);

        String[] services = {"数据库", "缓存", "日志"};
        for (String service : services) {
            executor.submit(() -> {
                try {
                    System.out.println(service + " 初始化开始...");
                    Thread.sleep((long) (Math.random() * 1000));
                    System.out.println(service + " 初始化完成!");
                    latch.countDown();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        System.out.println("主线程等待所有服务初始化...");
        latch.await();
        System.out.println("所有服务初始化完成,系统启动!");
        executor.shutdown();
    }
}

效果:

 

这个例子的核心原理其实跟上一个例子完全一样,只是应用场景不同。

系统或服务启动。主线程(应用启动流程)等待多个前置服务(数据库、缓存等)完成初始化。

他们都使用CountDownLatch(N) 让主线程通过await() 等待,直到N个在线程池中运行的子任务各自完成了自己的工作并调用了countDown()。

只是一个场景是算数据,这个是加载服务,但背后的并发控制逻辑是完全相同的。

3.3. 并发测试

有的时候,我们会利用CountDownLatch能够控制多个线程同时开始执行这个原理,用来模拟高并发场景,比如压力测试。

package com.lazy.snail.day52;

import java.util.concurrent.CountDownLatch;

/**
 * @ClassName CountDownLatchDemo4
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/10/20 16:38
 * @Version 1.0
 */
public class CountDownLatchDemo4 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch startLatch = new CountDownLatch(1); // 控制同时开始
        CountDownLatch endLatch = new CountDownLatch(10);  // 等待所有线程完成

        for (int i = 1; i <= 10; i++) {
            int taskId = i;
            new Thread(() -> {
                try {
                    System.out.println("线程 " + taskId + " 准备就绪,等待开始...");
                    startLatch.await(); // 等待发令枪
                    System.out.println("线程 " + taskId + " 开始执行!");
                    Thread.sleep((long) (Math.random() * 500));
                    System.out.println("线程 " + taskId + " 完成!");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    endLatch.countDown();
                }
            }).start();
        }

        Thread.sleep(1000); // 模拟准备时间
        System.out.println("发令枪响,所有线程开始!");
        startLatch.countDown();
        endLatch.await();
        System.out.println("所有线程执行完毕!");
    }
}

CountDownLatch(1)的计数是1,所有子线程都会调用startLatch.await()来等待这个门打开。

在门打开之前,所有线程都会被阻塞。主线程通过调用一次countDown()就能同时打开大门,释放所有等待的线程。

CountDownLatch(10)的计数是10,跟前两个例子一样,用于让主线程等待所有10个子线程完成他们的任务。

大致的一个步骤就是:

for循环启动了10个线程。每个线程启动后,马上调用 startLatch.await()。因为startLatch的计数是1,所以所有10个线程都会在这里被阻塞。

主线程在启动所有子线程后,执行Thread.sleep(1000),模拟一个准备时间。这个时候,10个子线程都已就绪并处于等待状态。

主线程调用startLatch.countDown()。startLatch的计数从1变成0。

startLatch计数归零的瞬间,所有因为startLatch.await()而阻塞的10个线程同时被唤醒,几乎在同一时刻开始执行后面的代码,实现了并发执行的效果。

每个线程执行自己的任务(sleep模拟),完成后在finally块里调用endLatch.countDown(),把终点线的计数器减一。

主线程调用endLatch.await()。这个时候主线程自己也进入了第二次等待,他在等待所有10个线程都跑完。

当最后一个线程调用endLatch.countDown() 后,endLatch的计数归零,主线程被唤醒,程序结束。

四、核心方法

其实在上面的案例中,CountDownLatch中的核心方法我们都使用到了,这里做一个简单的梳理。

CountDownLatch(int count)

 

简单的构造方法,count就是需要等待的任务数量,用来初始化计数器。

count必须大于0,不然就会抛出IllegalArgumentException异常。

void countDown()

 

计数减1,表示一个任务完成了。如果计数已经是0了,调用是无效的。

void await()

 

阻塞当前线程,直到计数归零。可能会抛出InterruptedException,需要处理。

boolean await(long timeout, TimeUnit unit)

 

带超时的等待,超时之后返回false(计数未归零)。

long getCount()

 

返回当前计数,主要是用来调试或监控的。

五、工具对比

J.U.C里面还有像CyclicBarrier、Semaphore这些工具,暂时还没讲到, Thread.join我们在上面已经对比过了。

下面先做一个简单的区分对比:

工具类

功能

是否可重用

底层机制

CountDownLatch

等待计数归零,释放等待线程

AQS 共享模式

CyclicBarrier

所有线程到达屏障点后同时继续

ReentrantLock + Condition

Semaphore

控制同时访问资源的线程数

AQS 共享模式

Thread.join()

等待线程终止

JVM 线程状态管理

针对场景来说,CountDownLatch适合一等多的场景,上面的案例中基本都基于主线程等待多个子任务。

CyclicBarrier适合多等多的循环任务,比如多线程迭代计算。

Semaphore适合控制资源访问,比如限制数据库连接数。

join的话只适合简单等待线程终止,大多数情况只用来写一些Demo。

六、常见问题

CountDownLatch是不可重用的,计数器归零之后,不可以重置,如果要可重用的,就要考虑使用CyclicBarrier。

如果子线程异常退出可能会导致没调用countDown(),主线程就一直阻塞了。在实际代码里一般都在finally块里调用countDown()。

await()还可能抛出InterruptedException,需要捕获并处理。

虽然我们可以设置计数,但是这个初始化的计数也需要合理,太高了会导致不必要的等待,太低了有可能直接报错了。

CountDownLatch是基于AQS的共享模式的,性能还可以,但是在高并发的情况下还是要注意线程争用。

七、底层实现

CountDownLatch是基于AQS(AbstractQueuedSynchronizer)的共享模式实现的,核心逻辑:

public class CountDownLatch {
    private final Sync sync;

    private static final class Sync extends AbstractQueuedSynchronizer {
        Sync(int count) { setState(count); }
        int getCount() { return getState(); }

        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int releases) {
            for (;;) {
                int c = getState();
                if (c == 0) return false;
                int next = c - 1;
                if (compareAndSetState(c, next))
                    return next == 0;
            }
        }
    }
}

可以看出AQS的state就是用来表示计数的。

countDown()实际调用的是tryReleaseShared(),通过CAS减少state,当state归零时唤醒等待线程。

await()实际调用的是tryAcquireShared(),检查state是不是为0,如果不是0就阻塞。

 

我们之前讲的AQS相关知识就应用上了。

结语

CountDownLatch是 J.U.C中一个简单而强大的工具,适合协调多线程任务的完成。

通过合理的计数设置和异常处理,能高效解决一等多的并发问题。

希望本文的讲解和示例能帮助你快速掌握CountDownLatch,并在实际项目中灵活运用。

下一篇预告

Day53 | J.U.C工具-CyclicBarrier详解

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐