做并发编程的朋友,十有八九都会遇到这样一个场景:主线程要等好几个子线程都干完活,才能继续往下走。比如启动应用时要并行加载多个配置、批量查询多个接口、或者压测时想让几十个线程同时发请求。最早我处理这种需求,要么用Thread.sleep硬等,要么用join一个个串着等,代码难看不说,还经常出问题。后来接触到Latch设计模式,在Java里对应的就是CountDownLatch,困扰我很久的“等待”问题一下子清爽了很多。这篇文章就把Latch设计模式掰开揉碎讲清楚,从原理到实战到踩坑,让没接触过的朋友也能直接上手。
1. 为什么需要Latch:并发协作里的“等待”难题
1.1 并发场景里的三种典型等待需求
先说一个我遇到过的真实场景。之前做一个数据聚合服务,接口需要同时调三个下游服务拿数据,三个数据都齐了才能拼装结果返回。用串行调用的话,整体耗时等于三个接口耗时之和,用户等得着急。改成并行调用之后,就得让主线程“等”三个子线程全部执行完。
类似的场景在并发编程里太常见了,我总结了一下,大致有三种典型等待需求:
- 主线程等待多个子任务全部完成,然后继续执行。比如并行查询、批量导入导出、服务启动时加载多个配置项。
- 多个线程全部就绪之后,统一触发开始执行。最典型的就是压测场景,你想模拟一百个用户同时点击某个按钮,就得保证一百个线程在同一瞬间发请求,而不是谁启动快谁先跑。
- 一个线程等待一个外部条件满足后再继续。比如初始化流程里,等某个资源加载完成、等某个远程服务注册完成。
这三种需求,单靠join、sleep、while循环都能实现,但实现起来要么繁琐、要么不够精确、要么浪费CPU。而Latch这个设计模式,正好就是专门干这个事的。
1.2 CountDownLatch的工作原理
CountDownLatch是JDK并发包里的一个工具类,也是Latch设计模式在Java中的标准实现。它的核心思想特别简单:设定一个初始计数,多个线程各自把这个计数减到零,另一个线程在计数未归零前一直等着,直到归零才继续往下走。
打个比方,可以把Latch想象成一道门上挂了N把锁。await()就是主线程站在门口等,每有一个子线程执行完,就过来打开一把锁(调用countDown()),当N把锁全部打开,门就开了,主线程这才走出去。整个过程精确、可控、不靠猜。
1.3 为什么不用join、sleep、while轮询
很多人一开始会问:“难道Thread.join()不行吗?”这里面的差别其实挺大的。
join的本质是“等待某个具体线程终止”,它的粒度是一个线程一个线程地等。如果是多线程并行,你想让主线程等所有子线程,就得在代码里把每个join都写一遍,而且如果子线程本身又派生了孙线程,join根本管不住。更麻烦的是,join是等待线程终止,而不是等待“任务完成”或者“条件满足”,有些场景线程不退出但活已经干完了,join就尴尬了。
再说Thread.sleep和while循环。sleep是纯靠时间估算,估短了数据没准备好,估长了白白浪费时间;while循环配标志位,虽然能精确感知完成状态,但没睡着的“忙等待”会空转CPU,线程一多整个系统的性能就难看了。
CountDownLatch把所有这些问题都解决了。它底层用的是AQS(AbstractQueuedSynchronizer)的共享锁机制,等待中的线程会被挂起而不是空转,计数归零后通过unpark唤醒等待线程,既不浪费CPU,又能做到精确放行。从设计思路上说,它就是为“N个任务完成后放行”这种协同场景量身定做的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. CountDownLatch关键API与细节把控
2.1 核心API全景
CountDownLatch的方法非常少,日常使用基本就四个,但每个都值得细说:
| 方法 | 作用 | 使用注意 |
|---|---|---|
new CountDownLatch(int count) |
构造器,传入初始计数 | count必须大于等于0,为0时await不阻塞 |
countDown() |
计数器减一 | 没限制调用次数,可以多次调用,但减到0后不再变化 |
await() |
阻塞等待,直到计数归零 | 可被中断,需处理InterruptedException |
await(long timeout, TimeUnit unit) |
超时等待,最多等指定时间 | 返回boolean,true表示计数归零,false表示超时 |
getCount() |
获取当前剩余计数 | 多用于日志、监控,不太适合做业务判断 |
这里要特别强调一个容易踩的坑:构造时的count必须和你要等待的任务数完全一致。count设大了,await迟迟等不到归零;count设小了,主线程提前放行,后面的数据可能还没准备好。我自己写并行聚合代码的惯例是,先把任务数量算出来,再创建Latch,绝不随手写个固定值。
2.2 一个“准则”:countDown一定要放进finally
这是Latch用得好不好的分水岭,也是我踩过最深的坑。如果子线程执行过程中抛出异常,而countDown又写在正常逻辑的后面,那计数器就永远少减一次,主线程也就永远卡在await()上了。
正确的写法只有一个:countDown只出现在finally块中,没有例外。不管任务正常完成、提前返回、还是抛了异常,finally都会执行,计数器必然归零。这也是我在团队里给所有人定的并发规范第一条。
code复制try {
// 业务逻辑
doSomething();
} finally {
latch.countDown();
}
千万别说“我的任务不会抛异常”,线上环境什么都能发生,下游超时、数据格式变化、网络抖动,哪一样都能让你的countDown缺席。
2.3 await超时与中断处理
await()会阻塞,而阻塞就不可避免要面对两个问题:中断和超时。
中断这块,InterruptedException必须处理,要么吞掉、要么重新设置中断标志位。我看到很多初学者在代码里直接catch完就啥也不干了,其实这是有隐患的。比较合适的做法是在catch块里调用Thread.currentThread().interrupt(),把中断状态重新置位,让上层调用方知道这个线程被打断过,线程池也能借此感知任务状态。
超时则要结合业务判断。await(10, TimeUnit.SECONDS)返回false时,说明计数还没归零,这时候你至少有两个选择:继续等、放弃本次操作、或者记录日志并返回部分结果。我处理聚合查询时,通常会给await加超时,超时后打印已完成的子任务信息,方便排查到底是谁拖慢了整体速度。如果业务要求强一致性、数据缺一个都不能继续,那就得配合其他手段,比如CompletableFuture.allOf加orTimeout来做更细颗粒的控制。
3. 实战:三种高频场景落地
3.1 场景一:并行聚合查询
这是最入门的用法,主线程同时发起多个子任务,全部完成后统一汇总。直接看代码。
code复制public class ParallelDataLoader {
public static void main(String[] args) throws InterruptedException {
int taskCount = 3;
CountDownLatch latch = new CountDownLatch(taskCount);
List<String> results = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < taskCount; i++) {
int taskId = i;
new Thread(() -> {
try {
// 模拟调用下游服务
TimeUnit.SECONDS.sleep(1);
results.add("任务 " + taskId + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
}).start();
}
latch.await();
System.out.println("所有任务完成,结果: " + results);
}
}
有几个细节说下。results我用了synchronizedList包装,因为多个线程同时往同一个ArrayList里写数据肯定会有并发问题,这属于最基础的线程安全问题。如果你用到了Java 8以上的版本,更推荐用ConcurrentLinkedQueue或者CopyOnWriteArrayList来处理结果收集。另外,任务数不是越多越好,这个场景是IO密集型,线程数按“CPU核数 * 2 + 1”的思路估算比较合理,或者直接用线程池管理,后面第3.3小节会展开。
3.2 场景二:压测门闩
为什么要用到Latch做压测?因为压测的准确性依赖“真正同时发起请求”。如果每个线程各自独立启动,前面几个线程已经开始打接口了,后面的线程还在慢悠悠地初始化,整体并发形态就不是你想要的。
解决思路是用两个Latch配合。控制流程是先让所有压测线程各自准备好,等大家都停在起点后,主线程统一“发令枪响”,所有线程才真正开始发请求。
code复制public class ConcurrentStressTest {
public static void main(String[] args) throws InterruptedException {
int threadCount = 10;
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch finishLatch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
readyLatch.countDown();
try {
startLatch.await();
// 模拟业务操作
TimeUnit.MILLISECONDS.sleep(500);
System.out.println(Thread.currentThread().getName() + " 执行完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
finishLatch.countDown();
}
}).start();
}
readyLatch.await();
System.out.println("所有线程就绪,开始执行...");
long start = System.currentTimeMillis();
startLatch.countDown();
finishLatch.await();
System.out.println("全部完成,耗时: " + (System.currentTimeMillis() - start) + "ms");
}
}
三个Latch各司其职:readyLatch负责等所有线程就绪,startLatch是个计数为1的门闩,主线程一调用countDown就集体放行。最后一个finishLatch用来统计总耗时。这里的核心思想是把“准备阶段”和“执行阶段”通过不同的Latch隔离,保证命令的“同时启动”语义。压测工具JMeter里也有类似的线程组控制,但如果你要写自定义脚本或压测内部服务,这套组合很实用。
3.3 场景三:线程池 + CountDownLatch 组合
实际生产环境很少用new Thread裸奔,绝大多数场景都是通过线程池管理线程。把CountDownLatch和线程池配合起来,有几个地方要格外留意。
先看一个示例:
code复制public class LatchWithThreadPool {
public static void main(String[] args) throws InterruptedException {
int taskCount = 6;
CountDownLatch latch = new CountDownLatch(taskCount);
ExecutorService executor = new ThreadPoolExecutor(
3, 3,
0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(10),
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i < taskCount; i++) {
int taskId = i;
executor.execute(() -> {
try {
TimeUnit.SECONDS.sleep(1);
System.out.println("Task " + taskId + " done by " + Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await();
System.out.println("全部任务完成");
executor.shutdown();
}
}
在做这种组合时,我建议重点思考三个问题:
第一,线程池的阻塞队列选择直接决定了任务的排队方式。LinkedBlockingQueue是无界队列,newFixedThreadPool默认用它,任务不会拒绝但可能堆积,任务特别多时容易把内存吃满。ArrayBlockingQueue是有界队列,容量固定,满的时候触发拒绝策略,配合CallerRunsPolicy就能让提交失败的任务由提交方线程自己执行,不会丢任务。这里我特别强调:凡是用CountDownLatch等待线程池任务完成的场景,一定要选有界队列并把拒绝策略设置为CallerRunsPolicy,否则万一任务被拒绝,提交方又没有感知,Latch的计数器永远不会归零,整个主线程就卡死了。
第二,execute和submit的区别会影响异常处理。execute丢进去的任务,运行中抛出的异常在线程池内部就被处理掉了,外层拿不到。submit会把任务包装成Future,但必须调用get()才会抛出任务内部的异常。在Latch场景里,我推荐用submit提交,然后对每个Future调用get()并捕获异常,这样既能拿到异常信息,又能通过finally保证countDown执行。
第三,线程池核心线程数设置要和Latch的计数器匹配。如果线程数是3、任务数是6,3个并发执行完Latch计数从6减到3,然后线程继续消费队列里剩余任务,直到全部减到0。这个过程中Latch的目的只是“感知任务整体完成”,而不是“等待线程池空闲”,所以latch.await()返回时,线程池里的线程可能还在空闲存活,这属于正常状态,不要误以为线程池也关闭了。
4. 踩坑与排查:从原理到实战
4.1 三个同步协作工具对比
很多并发初学者分不清CountDownLatch、CyclicBarrier和Semaphore,我整理了一张对比表,把它们的核心区别列清楚:
| 工具 | 核心语义 | 是否可重用 | 典型场景 |
|---|---|---|---|
CountDownLatch |
门闩,计数减到0放行 | 不可以 | 一个线程等待多个线程完成任务 |
CyclicBarrier |
栅栏,N个线程互相等待到齐后同时通过 | 可以(reset后复用) | 多个线程互相等待后并发执行 |
Semaphore |
信号量,控制同时访问的许可数量 | 可以 | 限流,控制并发访问数 |
选型的时候,核心就看两点:你是不是要“一个等多个”还是“多个互等”,以及你需不需要复用。CountDownLatch一旦计数归零就废了,想重新用必须new一个;CyclicBarrier则可以reset()后循环使用。信号量的语义完全不同,它管的是“最多多少个线程同时执行”,不是协同等待,别搞混了。
4.2 死等排查实录
我在线上遇到过一起最典型的CountDownLatch死等问题。现象是服务启动卡住不动,日志最后一行停在“加载远程配置中”。当时整个调用链是主线程发起10个子任务从远程拉取配置,然后await(30, TimeUnit.SECONDS)等待。排查时第一件事自然是看线程栈。
如果你遇到同类问题,可以先用jstack dump线程栈:
code复制jstack <pid> > thread_dump.txt
然后重点搜CountDownLatch相关的栈帧。你会看到类似这样的信息:“java.lang.Thread.State: WAITING (parking)”,并且waiting on <0x000000...>后面跟着CountDownLatch$Sync。这基本就能确认主线程卡在await上了。
拿到栈之后,接下来要判断是哪个子线程没有执行countDown。此时需要看count当前的值,可以通过加日志或者用getCount()输出。我遇到过的情况是某个子任务拉配置时抛了NullPointerException,而当时的代码没有把countDown放finally里,异常一抛,计数少扣一次,所有线程全卡死。
如果排查时你拿不到getCount()的值,还有一个更直接的办法:把原本的await()换成带超时的await(5, TimeUnit.SECONDS),这样即使卡住也不会永久阻塞,并且可以打印日志“剩余未完成任务数”,配合业务日志就能快速定位是哪个任务没执行完。
4.3 Latch模式的变体与思想延伸
最后聊点超出Java本身的东西。Latch作为一种“等待条件满足再放行”的并发设计思想,在实际工程里其实有各种变体。
比如很多中间件里的“Barrier节点”就和Latch思想同源。在分布式协调场景中,ZooKeeper里有专门的Barrier原语,本质上就是分布式版本的CountDownLatch:多个客户端各自创建临时节点,当节点数达到预期值,所有客户端同时放行。这个思想和单机版Latch的对应关系非常清晰。
再比如,如果你在大型系统里要控制批量任务的整体状态,可以自己实现一个可重入版本的Latch,用AtomicInteger维护计数,配合Condition实现等待和唤醒。这样能得到比CountDownLatch更灵活的语义,比如支持增加计数、支持超时重置、支持多轮复用,代价是需要自己处理好并发安全。
最近我在看多Agent编排的设计,主Agent把多个子Agent并行派发出去,等待全部结果回来再汇总,本质上也是一种Latch思想——把“子任务的数量”当作计数器,每回来一个就countDown一次。和这里讨论的CountDownLatch是一个套路。无论技术形态怎么变,“等待全部就绪再统一放行”这个模型,始终是并发和分布式系统里最基础也最关键的一环。
我在实际项目里用CountDownLatch最多的场景就是数据聚合和压测门闩,踩过最狠的坑就是忘了把countDown放finally里,导致线上服务启动一直卡在等待。后来我给自己定了一条规矩:凡是用了CountDownLatch的地方,countDown永远只出现在finally块里,没有例外。另一条经验是复杂场景下优先用带超时的await,即使出了问题,也能快速失败并暴露日志,而不是让整个服务傻等。如果你能养成这两个习惯,Latch用起来基本就不会出大问题。
