Java后端的Ai之路头像
Java并发编程19-CountDownLatch和CyclicBarrier封面图

Java并发编程19-CountDownLatch和CyclicBarrier

19、Java并发编程:CountDownLatch和CyclicBarrier:如何让多线程步调一致?

一文搞懂CountDownLatch与CyclicBarrier封面

写了十几年代码,我发现一个规律:真正让人加班到凌晨的,往往不是业务逻辑太难,而是线程之间"没对齐"。

一个商品详情页接口,你开了四个线程分别去查商品、库存、价格、评论,代码写完只花十分钟,上线后却天天收到"价格那块偶尔是空的";一个服务启动脚本写着 start success,注册中心连接池其实还在建,第一批流量进来直接 500;一批分库分表的数据迁移,阶段二已经开跑,阶段一的最后一个分片还没写完,对账报表差了三十万条。

这些问题的共同点只有一个:某个线程在别人还没准备好的时候,自己先走了。

CountDownLatch 和 CyclicBarrier 就是 JDK 给这个场景准备的两把"指挥棒"。它们不解决业务,只解决一件事——让多线程的步调一致。这篇文章把它俩从痛点、定义、演进、源码、用法、企业落地一直讲到面试题,力求让没有并发基础的同学也能照着上手。

文章目录

一、痛点场景:三个真实翻车现场

三个翻车现场

在讲工具之前,先把"病"看清楚。下面三个场景,是我在真实项目里见过、在面试里被候选人复述过、也在技术群里被反复求助过的。

1.1 场景一:主线程提前交卷

电商详情页的聚合查询是最典型的例子。商品、库存、价格、评论四个服务各自独立,串行调用要四百毫秒,并发调用理论上只要一百毫秒出头。于是很多同学写出了这样的代码:

// 反例:以为"提交完"就等于"做完了"
public ProductDetailVO detail(long skuId) {
    ProductDetailVO vo = new ProductDetailVO();
    pool.execute(() -> vo.setProduct(productClient.get(skuId)));
    pool.execute(() -> vo.setStock(stockClient.get(skuId)));
    pool.execute(() -> vo.setPrice(priceClient.get(skuId)));
    pool.execute(() -> vo.setComment(commentClient.page(skuId)));
    return vo; // 四个线程可能连方法都还没进
}

这段代码不会报错,也不会抛异常,它只会稳定地"偶尔缺字段"。因为 execute 只是把任务交给线程池,方法立刻返回。主线程拿着一个还没被填充的对象去序列化成 JSON,前端看到的就是 price: null。

更隐蔽的是:本地测一百次都正常,因为本地机器快、线程池空闲、任务几乎是同步完成的;一上生产,线程池排队、RPC 抖动,缺字段的概率从百分之一涨到百分之三十。

大白话:你点了三份外卖,付完钱就坐到餐桌前开吃,桌上当然什么都没有。你缺的不是外卖,是"等一会儿"这个动作。

1.2 场景二:服务启动了,其实没就绪

第二个场景发生在微服务冷启动。启动流程里有三件异步初始化的事:拉取配置中心的开关、预热本地缓存、初始化注册中心的客户端连接池。有人为了让启动快一点,把这三件事丢给线程池并行做,然后主流程继续往下走,日志打出"started on port 8080"。

注册中心还没连上,网关已经把流量放进来了。第一个请求打到 @Reference 的 Dubbo 调用上,直接抛出"无可用 provider",整条链路 500,链路监控瞬间飘红,运维半夜打电话问你是不是发布回滚没做干净。

大白话:饭还没熟你就喊"开饭",全家坐上来只能干看着。启动完成不等于初始化完成。

1.3 场景三:多阶段任务步伐错位

第三个场景在批处理里最致命。分库分表迁移、多阶段对账、离线跑批,往往有明确的三段:先写主表、再校验、最后建索引。三个阶段之间有严格的先后关系,而每个阶段内部是分片并行的。

如果只是"分片并行提交完就接着跑下一阶段",那么快的分片已经进了阶段二,慢的分片还卡在阶段一的网络 IO 上。阶段二读到的数据一半是新的一半是旧的,最后对账报表差了三十万条,查了三天才定位到是"阶段之间没对齐"。

大白话:接力赛里,上一棒还没把棒递到你手里,你就先跑了,成绩当然无效。这里要的不是"谁快谁先走",而是"到齐了再一起走"。

1.4 痛点归纳

现象真正的根因线上后果正确的姿势
聚合结果偶尔缺字段主线程没有等待点,异步提交即返回客诉、转化率下降、难以复现主线程等 N 个子任务完成(CountDownLatch)
启动成功但接口 500初始化没有完成信号发布窗口故障、回滚初始化完成前阻塞主流程(CountDownLatch)
多阶段数据错位阶段之间没有屏障数据不一致、对账不平每个阶段结束设一道屏障(CyclicBarrier)
等待没有上限await() 没有超时线程悬挂、线程池被拖死一律使用 await(timeout, unit)
某个子任务异常后永久卡死countDown() 没写在 finally接口整体超时雪崩计数必须放在 finally

看清楚这张表你会发现:这些问题没有一个是"业务逻辑写错了",全都是"同步语义没设计"。而 JDK 在 java.util.concurrent 包里,早就给了标准的答案。

二、痛点的解决方案:把"等待"交给专门的工具

两种步调一致:谁在等谁

先记住两句人话,这两句话是全文的骨架:

  • CountDownLatch:一个线程等另外 N 个线程把事做完,做完就放行,且只放行一次。
  • CyclicBarrier:N 个线程互相等,人到齐了一起走,走完还能再来一轮。

再补一句选型口诀:"等别人"用 Latch,"等彼此"用 Barrier。

所谓"步调一致",拆开其实是四件事:

  1. 同时起跑:所有线程在同一条起跑线上,发令枪一响一起冲。
  2. 等全部完成:主线程必须等到所有子任务都结束,才能汇总。
  3. 分阶段齐步:每一阶段结束,所有参与者到齐后才能进入下一阶段。
  4. 超时兜底:等待必须有上限,不能无限悬挂。

CountDownLatch 天然覆盖第 1、2 件事,CyclicBarrier 覆盖第 3 件事,而第 4 件事两者都提供了带超时的 await 重载。把这三个能力组合起来,上面那三类翻车现场就都有了标准解法。

三、是什么:专业解释、大白话、生活案例

3.1 CountDownLatch:倒计时门闩

专业解释

JDK 官方对它的定义是:A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.(一种同步辅助工具,允许一个或多个线程等待,直到其他线程中执行的一组操作完成。)

它内部维护一个计数器,构造时指定初始值 count。调用 await() 的线程会被阻塞,直到计数器因为其他线程调用 countDown() 而归零;归零之后,所有等待线程被释放,之后再调用 await() 会立刻返回。这是一次性现象,计数器无法重置。

底层它继承自 AQS(AbstractQueuedSynchronizer)的共享模式,计数值就存在 AQS 的 state 字段里,countDown() 用 CAS 把 state 减一,减到零时唤醒队列里的等待线程。

大白话

它就是一个"倒计时门闩"。门闩上挂着 N 把锁,每完成一件事就摘掉一把,N 把全摘完,门才打开;门一旦打开,就再也关不上了。

生活案例

公司团建包了一辆大巴。导游说"人到齐了才发车",于是先到的人坐在车上等,每来一个人就在名单上划掉一个名字,名单划完,司机才启动。注意两个细节:第一,先到的人不会自己开车走,他们必须等;第二,这辆车今天只发这一趟,明天再想用,得重新包一辆——这正是"不能重置"的含义。

三个典型用法

JDK 注释里其实已经把用法写清楚了,一共三种:

用法计数设置场景
开关 / 发令枪new CountDownLatch(1)所有线程在门口等一个信号,信号一到同时开跑
一等 Nnew CountDownLatch(N)主线程等 N 个子任务全部完成
N 等一new CountDownLatch(1)N 个工作线程等一个"总开关"打开

核心 API

方法作用注意点
await()阻塞直到计数归零无超时,线上禁用
await(timeout, unit)带超时等待,返回是否归零生产环境首选
countDown()计数减一,减到零唤醒全部等待者必须放在 finally
getCount()返回当前计数官方说明主要用于调试和测试

JDK 还明确写了一条内存语义:在计数归零之前,某个线程调用 countDown() 之前的操作,happen-before 另一个线程从 await() 成功返回之后的操作。这句话听起来拗口,翻译成人话就是:子线程写的数据,主线程 await 返回后一定看得见,不需要你再加任何 volatile。这是很多人忽略的价值点。

3.2 CyclicBarrier:循环栅栏

专业解释

JDK 官方定义是:A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.(一种同步辅助工具,允许一组线程全部互相等待,直到到达一个公共屏障点。)因为栅栏在释放等待线程后可以被重用,所以叫"循环(Cyclic)"栅栏。

它的内部结构和 CountDownLatch 完全不同:用的是 ReentrantLock + Condition,而不是 AQS 的共享模式。核心字段有三个:parties 是参与者总数,count 是剩余未到达数,generation 是"代",用来标识当前是哪一轮。

每个线程调用 await() 时,会在锁保护下把 count 减一。最后一个到达的线程(count 减到 0)会做三件事:执行可选的 barrierCommand、唤醒所有等待线程、调用 nextGeneration() 复位计数并换代。于是同一批线程可以接着进入下一轮。

大白话

它是一道"人齐了才开的门"。门上有 N 个座位,来一个人坐一个,坐满 N 个,门自动打开,所有人一起走出去;门随即复位,下一轮还能用。

生活案例

大学小组讨论。老师规定"人到齐了才开始",先到的三个人只能刷手机等,第四个人一坐下,讨论立刻开始,谁也不能提前发言。讨论完这一轮,大家去查资料,回来再讨论第二轮——还是那四个人,还是那道门,这就是"循环"。

核心 API

方法作用注意点
await()到达屏障并等待其他人返回"到达索引",parties-1 表示第一个到
await(timeout, unit)带超时等待超时会打破屏障,抛 TimeoutException
reset()重置到初始状态正在等待的线程会收到 BrokenBarrierException
isBroken()屏障是否已损坏排查问题很有用
getNumberWaiting()当前已等待的线程数监控、日志
getParties()参与者总数常用于校验配置

它是怎么"循环"起来的

循环屏障:一代一代地复用

generation 这个设计很妙。每一轮是一个 Generation 对象,里面只有一个 boolean broken 标记。当最后一个线程到达时,nextGeneration() 会做三件事:signalAll() 唤醒所有等待者、把 count 复位成 parties、新建一个 Generation。等待中的线程醒来后,通过比较手里的 g 和当前的 generation 是否相同,就能判断"我是被正常放行的,还是这一代已经作废了"。

这也解释了一个高频问题:为什么 CyclicBarrier 能复用,而 CountDownLatch 不能。因为栅栏的状态是"每代独立"的,换代即重置;而 Latch 的 state 一旦归零就永久停在那里,JDK 根本没有提供"再加回去"的入口。

它最容易被忽略的能力:barrierAction

构造函数支持传入一个 Runnable,它会在最后一个线程到达时、唤醒其他人之前执行,而且是由最后一个到达的线程来跑。这个位置适合做"阶段汇总":比如每一轮分片计算结束后,在这里把各分片的中间结果合并,再一起进入下一轮。反过来也要注意:这个 Runnable 里千万不要做耗时操作或阻塞等待,它跑得越久,整组线程就被拖得越久;一旦它抛异常,整代屏障直接作废,所有人收到 BrokenBarrierException。

3.3 本质区别:一句话说透

维度CountDownLatchCyclicBarrier
谁在等谁一个(或几个)线程等另外 N 个线程N 个线程互相等,谁也不能先走
计数方向只减不增,归零即终止减到零后自动复位,可反复使用
可复用一次性循环使用
底层实现AQS 共享模式(state 计数)ReentrantLock + Condition + generation
典型语义“等事情做完”“等人到齐”
异常语义中断抛 InterruptedException中断、超时、reset 都会"打破"整代屏障
线程角色等待者和执行者可以是不同线程到达者和被放行者是同一批线程
一句话裁判等所有选手跑完选手互相等,到齐了一起跑

如果你只记一句话:CountDownLatch 是"事件计数",CyclicBarrier 是"人员集合"。

四、为什么用:它到底替我们省了什么

有人会问:不就是等几个线程吗,我自己写个 AtomicInteger 加 while 自旋不就行了?下面这张表是我压测过的真实差距。

方案编码复杂度超时能力异常处理复用性CPU 开销可读性
while (count.get() > 0) {} 自旋低需要自己算差无极高,空转烧 CPU差
Thread.join()低只有 join(ms)无法感知子线程异常无低中
wait() / notifyAll()高,必须配锁需要自己算容易漏唤醒需自己维护状态低差
Future.get()中有抛 ExecutionException无低中
CountDownLatch低内置中断语义清晰无极低(park)好
CyclicBarrier低内置打破屏障语义统一有极低好

省下的其实是三样东西:

第一,省掉了自己维护同步状态的风险。手写 wait/notify 最经典的 bug 就是"通知早于等待"——子线程先 notifyAll(),主线程才 wait(),然后永远等下去。Latch 的计数器是"先扣分再检查",天然不存在这个时序陷阱,因为 await() 会先看 state 是不是 0,是 0 就直接返回。

第二,省掉了可见性问题。前面提过,countDown 到 await 之间存在 happen-before 关系。你自己用 AtomicInteger 等,虽然计数是可见的,但子线程往 HashMap 里写的业务数据未必可见,还得再加一层同步。用 Latch,这层语义是 JDK 帮你兜住的。

第三,省掉了"等待"这件事对业务代码的污染。用 Latch 的时候,业务代码里只多了一行 countDown() 和一行 await(),业务逻辑本身不被锁、条件变量、循环判断切碎。这一点在 code review 时特别明显:一看 await(300, MILLISECONDS) 就知道这里有超时预算,一看 countDown() 在 finally 里就知道作者踩过坑。

当然,它也不是万能的。它不传递结果,只传递"完成"这个信号;它不传播异常,子线程抛了什么,主线程完全不知道,必须自己用容器收集;它不做重试,超时了就是超时了。这三点决定了它只能做"同步原语",不能做"任务编排框架"——这也是后面要讲的 CompletableFuture 的生存空间。

五、它是怎么演进过来的

从 join 到结构化并发:步调一致的演进

理解一个工具,最好的方式是看它取代了什么。并发同步这件事,Java 走了二十多年,大致是五步。

第一步,JDK 1.0:Thread.join() 与 wait/notify。 想等一个线程结束,就 t.join();想等一个条件成立,就 synchronized + wait/notify。问题是:join 只能一对一,等五个线程就要 join 五次,而且是串行 join,总耗时等于最慢那个加上循环开销;wait/notify 更是把锁、条件、状态机全压在业务代码上,稍不注意就是死锁或者丢通知。

第二步,JDK 1.5:JSR-166 与 JUC 三件套。 2004 年,Doug Lea 把一套并发工具捐给 JCP,形成了 java.util.concurrent。CountDownLatch、CyclicBarrier、Semaphore 就是这一批进来的,同时带来了 AQS 这个统一抽象。这是关键的一步:同步语义第一次从"线程 API"变成了"可组合的同步器"。你不用再关心锁怎么排、队列怎么挂,只需要关心"我要等几个"和"我什么时候算完成"。

第三步,JDK 1.7:Phaser 与 Fork/Join。 CyclicBarrier 有一个硬约束——参与者数量在构造时就固定了。真实的分片任务经常是"分片数由数据量决定",执行中还想动态注册。于是 Phaser 出现了,它把"阶段"和"参与者"都做成了可动态调整的,可以理解为"可插拔的、多阶段的 CyclicBarrier"。同年 Fork/Join 带来了工作窃取,解决的是"任务怎么切",而不是"任务怎么等"。

第四步,JDK 1.8:CompletableFuture。 大家发现,光是"等完成"不够,还要"拿到结果"“串起来”“失败降级”“多个里取最快”。CompletableFuture 把这些编排能力做成了一套函数式 API,allOf 直接就是 N 等全部,anyOf 是取最快。从这一刻起,CountDownLatch 的定位被重新划清:它负责"同步",不负责"编排"。

第五步,JDK 19 到 JDK 25:虚拟线程与结构化并发。 虚拟线程(JDK 21 正式)让"一个请求一个线程"重新成为可行方案,阻塞的代价从平台线程的几 MB 栈变成几百字节的续体。而结构化并发(StructuredTaskScope,JDK 25 的 JEP 505 重写了 API,JDK 26、JDK 27 仍以预览形式在演进,JEP 525、JEP 533)试图从语言层面保证"子任务一定在父任务的作用域内开始和结束"——它想取代的,恰恰是我们用 CountDownLatch 手工维护的那种"父子任务生命周期"。

但请注意:结构化并发到现在仍然是预览特性,而 CountDownLatch 和 CyclicBarrier 从 JDK 5 一直稳定服役到今天。这也意味着,未来五年内,它们仍然是你代码里最常见的两个同步器。

顺带说一个和虚拟线程有关的细节:CountDownLatch.await() 底层是 LockSupport.park(),虚拟线程 park 时会卸载(unmount)载体线程,不会把平台线程钉住;这一点和 synchronized 在虚拟线程里可能 pin 住载体线程的情况不同。所以"虚拟线程 + CountDownLatch"在 JDK 21 之后是安全的组合。真正需要警惕的是另一件事:如果你把大量虚拟线程丢进一个固定大小的平台线程池,那虚拟线程的优势会被线程池直接抹平——问题不在 Latch,在池。

六、怎么用:可直接抄的代码

这一节全部是可运行的 Java 代码,从最小例子到企业级模板,按"能直接抄进项目"的标准写。

6.1 最小可用模板

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

public class LatchBasicDemo {

    public static void main(String[] args) throws InterruptedException {
        int workers = 3;
        CountDownLatch done = new CountDownLatch(workers);
        ExecutorService pool = Executors.newFixedThreadPool(workers);

        for (int i = 1; i <= workers; i++) {
            final int no = i;
            pool.submit(() -> {
                try {
                    Thread.sleep(200L * no);          // 模拟耗时不同的子任务
                    System.out.println("子任务 " + no + " 完成");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // 恢复中断标记,不要吞掉
                } finally {
                    done.countDown();                  // 无论如何都要减一
                }
            });
        }

        boolean finished = done.await(2, TimeUnit.SECONDS);
        System.out.println("是否全部完成:" + finished + ",剩余计数:" + done.getCount());
        pool.shutdown();
    }
}

三个细节值得停一下:

  1. countDown() 放在 finally,因为子任务抛异常也必须让计数器前进,否则主线程永远等不到零。
  2. await 带超时,因为线上没有"无限等待"这种奢侈。
  3. InterruptedException 要恢复中断标记,而不是打印一下就继续,否则上层的中断语义会丢失。

6.2 企业级模板:结果收集 + 超时降级 + 异常隔离

真实项目里,子任务是要返回值的,也是会失败的。下面这个工具类可以直接放进 common 包:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Map;
import java.util.concurrent.*;

public final class ParallelInvoker {

    private static final Logger log = LoggerFactory.getLogger(ParallelInvoker.class);

    private ParallelInvoker() {
    }

    /**
     * 并行执行一组有名字的任务,收集成功结果,失败与超时只降级不阻断。
     *
     * @param tasks         任务名 -> 任务
     * @param timeoutMillis 总预算
     * @return 已成功返回的结果,key 为任务名
     */
    public static <T> Map<String, T> invokeAll(Map<String, Callable<T>> tasks,
                                               long timeoutMillis,
                                               ExecutorService pool) {
        CountDownLatch latch = new CountDownLatch(tasks.size());
        Map<String, T> results = new ConcurrentHashMap<>(Math.max(4, tasks.size() * 2));

        tasks.forEach((name, task) -> pool.execute(() -> {
            try {
                results.put(name, task.call());
            } catch (Exception e) {
                // 单个子任务失败不影响整体,记录后降级
                log.warn("子任务 {} 执行失败,已降级", name, e);
            } finally {
                latch.countDown();
            }
        }));

        try {
            if (!latch.await(timeoutMillis, TimeUnit.MILLISECONDS)) {
                log.warn("聚合等待超时 {} ms,已完成 {}/{} 个子任务",
                        timeoutMillis, results.size(), tasks.size());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.warn("聚合等待被中断,返回已完成结果 {}/{}", results.size(), tasks.size());
        }
        return results;
    }
}

这段代码里有四个工程化决定,值得在 review 时逐条讲清楚:

  • 用 ConcurrentHashMap 收结果,而不是 HashMap,因为多个子线程并发写;同时它也避免了"主线程读不到"的可见性问题。
  • 异常在内层吞掉并记录,因为聚合场景的语义是"尽力而为",一个评论服务挂了不该让整个详情页挂掉。
  • 超时只打日志不抛异常,把"降级"这个决策留给业务层,工具类不越权。
  • 线程池由调用方传入,工具类不自己 new 线程池,避免线程泄漏和无法统一治理。

6.3 实战:商品详情页四路聚合

商品详情页聚合:四路并发,一次汇总

把上面的工具类落到一个 Spring Boot 的服务里,代码大概长这样:

// 省略 import:AtomicReference、Consumer、Supplier、List 等
@Service
public class ProductDetailService {

    private static final Logger log = LoggerFactory.getLogger(ProductDetailService.class);

    /** 聚合预算:整页 300 毫秒,超过就降级返回 */
    private static final long BUDGET_MS = 300L;

    @Resource
    private ExecutorService detailAggregatePool;
    @Resource
    private ProductClient productClient;
    @Resource
    private StockClient stockClient;
    @Resource
    private PriceClient priceClient;
    @Resource
    private CommentClient commentClient;

    public ProductDetailVO detail(long skuId) {
        CountDownLatch latch = new CountDownLatch(4);

        AtomicReference<Product> product = new AtomicReference<>();
        AtomicReference<Stock> stock = new AtomicReference<>();
        AtomicReference<Price> price = new AtomicReference<>();
        AtomicReference<List<Comment>> comments = new AtomicReference<>();

        submit(latch, product::set, () -> productClient.get(skuId));
        submit(latch, stock::set, () -> stockClient.get(skuId));
        submit(latch, price::set, () -> priceClient.get(skuId));
        submit(latch, comments::set, () -> commentClient.page(skuId));

        try {
            if (!latch.await(BUDGET_MS, TimeUnit.MILLISECONDS)) {
                log.warn("详情页聚合超时 skuId={},剩余计数={}", skuId, latch.getCount());
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        ProductDetailVO vo = new ProductDetailVO();
        vo.setProduct(product.get());
        vo.setStock(stock.get());
        vo.setPrice(price.get());
        vo.setComments(comments.get() == null ? List.of() : comments.get());
        vo.setDegraded(product.get() == null || price.get() == null);
        return vo;
    }

    /** 统一包装:异常隔离 + 必然计数 */
    private <T> void submit(CountDownLatch latch, Consumer<T> setter, Supplier<T> supplier) {
        detailAggregatePool.execute(() -> {
            try {
                setter.accept(supplier.get());
            } catch (Exception e) {
                log.warn("详情页子调用失败,降级处理", e);
            } finally {
                latch.countDown();
            }
        });
    }
}

这段代码之所以"敢上线",靠的是三个设计:

  1. 总预算 300ms 而不是每个调用各设 300ms。四个调用各自设超时,最坏情况是串行叠加;只有总预算才能守住接口 SLA。
  2. degraded 标记。降级不是悄悄地降,要显式告诉上游"这一屏是降级页",前端才能决定是否展示"价格加载中"。
  3. 线程池隔离。聚合调用单独一个池,不能和主业务共用,否则慢调用会把整个应用的线程池拖垮。

配套的线程池配置建议放在 application.yml 里,而不是硬编码在代码里:

async:
  detail-aggregate:
    core-size: 8            # 按 QPS × 平均耗时 估算,约等于并发数
    max-size: 16
    queue-capacity: 200     # 有界队列,绝不允许无界
    keep-alive: 60s
    reject-policy: CALLER_RUNS   # 满了让调用者自己跑,形成天然背压
    await-timeout-ms: 300

6.4 起跑线模式:压测与并发测试

CountDownLatch(1) 最经典的用法是"发令枪"。测并发问题的关键,是让所有线程在同一毫秒冲出去,否则你测的其实是串行。

public class StartGunDemo {

    public static void main(String[] args) throws InterruptedException {
        int threads = 50;
        CountDownLatch ready = new CountDownLatch(threads); // 大家都准备好了吗
        CountDownLatch start = new CountDownLatch(1);        // 发令枪
        CountDownLatch done = new CountDownLatch(threads);   // 都跑完了吗

        ExecutorService pool = Executors.newFixedThreadPool(threads);
        for (int i = 0; i < threads; i++) {
            pool.submit(() -> {
                ready.countDown();          // 我先举手:我准备好了
                try {
                    start.await();          // 等枪响
                    business();             // 真正要压的代码
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    done.countDown();
                }
            });
        }

        // 等所有线程就位,避免"枪响了还有人没上车";即使超时也继续,保证压测能出结果
        boolean allReady = ready.await(5, TimeUnit.SECONDS);
        if (!allReady) {
            System.out.println("警告:仍有线程未就位,本次压测并发度不准确");
        }
        long begin = System.nanoTime();
        start.countDown();                  // 发令
        done.await(30, TimeUnit.SECONDS);   // 等全部跑完
        long cost = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - begin);

        System.out.println("并发度 " + threads + ",总耗时 " + cost + " ms");
        pool.shutdown();
    }

    private static void business() {
        // 这里放你要验证的临界区
    }
}

注意 ready.await() 这一句很多人会漏。如果不等"就位"就直接发令,先启动的线程已经跑完了,后启动的还没进临界区,你测出来的并发度是假的。

6.5 CyclicBarrier 基础:三阶段任务

import java.util.concurrent.*;

public class BarrierStageDemo {

    public static void main(String[] args) {
        int parties = 3;
        AtomicInteger stage = new AtomicInteger(1);

        CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
            // 由最后一个到达的线程执行,适合做阶段汇总
            System.out.println("[汇总] 第 " + stage.getAndIncrement() + " 阶段全员到齐");
        });

        ExecutorService pool = Executors.newFixedThreadPool(parties);
        for (int i = 1; i <= parties; i++) {
            final int worker = i;
            pool.submit(() -> {
                try {
                    for (int round = 1; round <= 3; round++) {
                        doStage(worker, round);
                        int index = barrier.await(5, TimeUnit.SECONDS);
                        // index == parties - 1 表示第一个到,index == 0 表示最后一个到
                        System.out.println("线程 " + worker + " 第 " + round
                                + " 轮通过屏障,到达序号 " + index);
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } catch (BrokenBarrierException | TimeoutException e) {
                    // 只要有一个线程超时或被中断,整代屏障报废,所有人都走这里
                    System.err.println("线程 " + worker + " 屏障被打破,任务终止:" + e);
                }
            });
        }
        pool.shutdown();
    }

    private static void doStage(int worker, int round) throws InterruptedException {
        Thread.sleep(100L * worker);   // 让每个线程耗时不同,更能看出屏障的效果
    }
}

运行结果会非常整齐:不管三个线程各自快慢,每一轮的"阶段全员到齐"一定出现在三个线程都 await 之后。这就是"齐步走"。

6.6 企业实战:分片导入的三阶段同步

批量导入是 CyclicBarrier 最典型的落地场景:分片大小由数据量决定,分片之间必须阶段对齐,且阶段之间有汇总动作。

public class ShardImportService {

    private static final Logger log = LoggerFactory.getLogger(ShardImportService.class);

    public void importAll(List<Row> rows, int shardSize, ExecutorService pool) {
        List<List<Row>> shards = partition(rows, shardSize);
        int parties = shards.size();

        AtomicInteger stage = new AtomicInteger(0);
        CyclicBarrier barrier = new CyclicBarrier(parties, () -> {
            int current = stage.incrementAndGet();
            log.info("阶段 {} 全部分片完成,共 {} 片", current, parties);
            if (current == 2) {
                // 第二阶段结束:做一次全量校验汇总,再放行第三阶段
                validateSummary();
            }
        });

        CountDownLatch allDone = new CountDownLatch(parties);

        for (List<Row> shard : shards) {
            pool.execute(() -> {
                try {
                    writeMainTable(shard);      // 阶段一:写主表
                    barrier.await(60, TimeUnit.SECONDS);

                    verify(shard);              // 阶段二:逐片校验
                    barrier.await(60, TimeUnit.SECONDS);

                    buildIndex(shard);          // 阶段三:建索引
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } catch (BrokenBarrierException | TimeoutException e) {
                    log.error("分片导入阶段同步失败,整批任务终止", e);
                } finally {
                    allDone.countDown();
                }
            });
        }

        try {
            if (!allDone.await(5, TimeUnit.MINUTES)) {
                log.error("分片导入整体超时,请检查慢分片");
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // partition / writeMainTable / verify / buildIndex / validateSummary 省略
}

这里同时用了两个工具,分工非常清楚:CyclicBarrier 负责"阶段之间对齐",CountDownLatch 负责"整批任务结束"。很多同学只会用其中一个,结果要么阶段错位,要么主线程等不到全部完成。两个一起用,才是完整的企业级写法。

6.7 代码规范清单:这十条能帮你躲过绝大多数事故

  1. countDown() 必须写在 finally,任何"提前 return"都不能绕过它。
  2. 生产代码禁止裸 await(),一律 await(timeout, unit)。
  3. await 返回 false 不是异常,是"预算用完了",必须有降级分支。
  4. InterruptedException 必须 Thread.currentThread().interrupt(),不要吞。
  5. CountDownLatch 不要复用,每一批任务 new 一个,成本极低。
  6. CyclicBarrier 的 parties 必须等于实际参与 await 的线程数,差一个就永久卡住。
  7. barrierAction 里不要做 RPC、不要 sleep、不要阻塞等待别的屏障。
  8. 捕获 BrokenBarrierException,因为只要有一个线程中断或超时,其他线程都会收到它。
  9. 线程池大小必须大于等于 parties,否则线程互相等待会直接死锁(这个坑下面会专门讲)。
  10. 所有等待都要打点:等待开始时间、等待时长、剩余计数,出问题时这是唯一的线索。

七、源码层面:它们到底怎么实现的

7.1 CountDownLatch:一个 AQS 共享模式的外壳

CountDownLatch 的内部:AQS 共享模式

CountDownLatch 本身的代码非常短,核心就是一个内部类 Sync 继承 AQS,然后把"计数"这件事映射到 AQS 的 state 上:

// 基于 JDK 8 的 CountDownLatch.Sync,语义与后续版本一致
private static final class Sync extends AbstractQueuedSynchronizer {

    Sync(int count) {
        setState(count);              // 计数就是 AQS 的 state
    }

    int getCount() {
        return getState();
    }

    /** 共享模式获取:state == 0 才算"拿到资源",返回 1 表示成功且无剩余 */
    @Override
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    /** 共享模式释放:CAS 递减,减到 0 时返回 true,触发唤醒后继等待者 */
    @Override
    protected boolean tryReleaseShared(int releases) {
        for (;;) {
            int c = getState();
            if (c == 0) {
                return false;         // 已经归零,重复 countDown 无副作用
            }
            int nextc = c - 1;
            if (compareAndSetState(c, nextc)) {
                return nextc == 0;    // 只有减到 0 的那一次才"释放成功"
            }
        }
    }
}

看懂这二十行,你就能回答好几个面试题:

  • 为什么 countDown() 调用多了也不会出错?因为 c == 0 时直接返回 false,状态不再变化。
  • 为什么不能重置?因为 AQS 没有任何入口能把 state 加回去,CountDownLatch 也没暴露 setState。
  • 为什么归零之后 await 立刻返回?因为 tryAcquireShared 一看到 state == 0 就返回 1,压根不会入队。
  • 为什么是"共享模式"?因为多个等待线程可以同时被放行,而不是像独占锁那样一个个来。

7.2 CyclicBarrier:锁 + 条件队列 + 分代

CyclicBarrier 的核心方法是 dowait,它的逻辑可以浓缩成五步(源码较长,这里保留关键结构):

private int dowait(boolean timed, long nanos)
        throws InterruptedException, BrokenBarrierException, TimeoutException {

    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
        final Generation g = generation;

        if (g.broken) {
            throw new BrokenBarrierException();      // 这一代已经作废
        }
        if (Thread.interrupted()) {
            breakBarrier();                          // 中断即打破整代
            throw new InterruptedException();
        }

        int index = --count;                         // 到达一人,减一
        if (index == 0) {                            // 最后一个到达
            boolean ranAction = false;
            try {
                final Runnable command = barrierCommand;
                if (command != null) {
                    command.run();                   // 先执行阶段汇总
                }
                ranAction = true;
                nextGeneration();                    // 唤醒全体 + 复位 + 换代
                return 0;
            } finally {
                if (!ranAction) {
                    breakBarrier();                  // 汇总逻辑抛异常,整代报废
                }
            }
        }

        for (;;) {
            try {
                if (!timed) {
                    trip.await();                    // 挂在 Condition 上
                } else if (nanos > 0L) {
                    nanos = trip.awaitNanos(nanos);  // 带超时等待
                }
            } catch (InterruptedException ie) {
                if (g == generation && !g.broken) {
                    breakBarrier();
                    throw ie;
                } else {
                    Thread.currentThread().interrupt();
                }
            }

            if (g.broken) {
                throw new BrokenBarrierException();
            }
            if (g != generation) {
                return index;                        // 正常放行,返回到达序号
            }
            if (timed && nanos <= 0L) {
                breakBarrier();
                throw new TimeoutException();        // 超时也打破整代
            }
        }
    } finally {
        lock.unlock();
    }
}

对照代码看三个结论:

第一,"循环"不是重置计数器,而是换了一代。nextGeneration() 里 count = parties 只是复位,真正让别人知道"我确实被正常放行"的是 generation 换了一个新对象。

第二,它比 CountDownLatch 更"脆"。中断、超时、reset()、barrierAction 抛异常,任何一种都会 breakBarrier(),把整代标记为 broken 并 signalAll(),让所有等待者收到 BrokenBarrierException。这是设计上的取舍:栅栏的语义是"要么一起走,要么一起失败",不允许有人偷偷先走。

第三,它是独占锁保护下的条件等待,所以 getNumberWaiting()、isBroken() 这些方法都必须先拿锁。这也意味着 CyclicBarrier 在高并发、超多参与者的场景下会有锁竞争,通常几十个参与者以内完全无感。

八、竞品对比:七个工具横向打分

选型这件事,最怕"手里只有一把锤子"。所以把同一个战场上的七个工具摆在一起看:

工具核心语义可复用超时结果返回异常传播动态参与者典型场景
CountDownLatch等 N 件事做完否支持不返回不传播不支持聚合查询、启动预热、压测发令
CyclicBarrierN 个线程互相等是支持(但会打破屏障)只返回到达序号统一 BrokenBarrier不支持多阶段批处理、分片同步
Semaphore限流,控制并发数是支持 tryAcquire不返回不传播支持(可增减许可)接口限流、资源池、下载并发控制
Phaser多阶段、可动态注册是支持返回当前阶段号有 onAdvance 钩子支持分片数不定的批处理、多阶段流水线
CompletableFuture.allOf异步编排与结果聚合是(每次新建)支持 orTimeout返回 CompletableFuture传播(CompletionException)支持(动态追加)服务编排、并行调用、异步链路
Thread.join等某个线程结束否支持 join(ms)不返回不传播不支持极简场景、单测
StructuredTaskScope结构化并发,作用域内收敛是支持返回 Supplier 结果传播并取消兄弟任务支持JDK 25+ 预览,新项目探索

逐个说优劣,避免只看表格下结论:

CountDownLatch 的优势是"极简且极其可靠"。 它只有三个方法,任何水平的同学看一眼就懂,出问题的姿势也就那么几种(忘 countDown、裸 await),排查成本极低。劣势是"一次性 + 不返回结果 + 不传播异常",一旦你需要"拿到结果并处理失败",就得自己搭容器,代码会迅速变丑。

CyclicBarrier 的优势是"阶段语义天然贴合业务"。 多阶段批处理、分片对齐这种场景,用它写出来的代码几乎就是业务流程图本身。劣势是"脆":参与者固定、任何异常都整代报废、await 超时之后屏障就废了,必须由上层重建,不能"超时了继续跑"。

Semaphore 和它们不是一个战场。 它管的是"同时允许多少个进来",不是"什么时候一起走"。但很多人会拿它当 Latch 用(acquire(N) 再 release(N)),这是误用,语义不清且容易泄漏许可。

Phaser 是 CyclicBarrier 的进阶版。 分片数不固定、阶段要动态注册、需要 onAdvance 钩子,这些场景 CyclicBarrier 都做不到。劣势是 API 更复杂,arriveAndAwaitAdvance、arriveAndDeregister 这些方法名字就劝退一半人,团队不熟时容易写错阶段号。

CompletableFuture 是"编排层"的工具。 它能返回值、能串行、能异常降级、能取最快,功能上几乎全面碾压 Latch。但它的问题是"太重":一个简单的"等三个任务完成",用 allOf 要写一串 supplyAsync,还要处理 thenApply 的线程切换,读起来比 Latch 累。判断标准很简单:只需要"完成信号"就用 Latch,需要"结果和失败处理"就用 CompletableFuture。

Thread.join 只适合单测和玩具项目。 它等的是"线程对象结束",而在线程池里线程根本不会结束,join 会直接把你的服务挂死。

StructuredTaskScope 是未来。 它把"子任务不能活过父任务"这件事变成编译期和运行期的强约束,天然支持失败取消兄弟任务,配合虚拟线程非常优雅。但它到 JDK 27 仍是预览特性,API 还在改,生产项目现在押注为时尚早。

选型决策:到底该用谁

一张决策图收个尾:先问"要不要等",再问"等的是事还是人",最后问"要不要结果"。

九、常用场景教学:企业项目到底怎么用

企业项目里的四类落地

理论讲完,接下来是最容易被面试官追问的部分:你在项目里到底怎么用的。 下面六个场景,覆盖了绝大部分真实需求。

9.1 场景 A:多接口聚合(CountDownLatch)

这是最常见的落地。要点不在于"会不会用",而在于"预算怎么分配"。企业级做法通常是三层预算:接口总预算 300ms、单次 RPC 预算 80ms、Latch 等待预算等于总预算减去序列化与组装耗时。这样即使某个下游慢,也只是局部降级,不会击穿整页。

配套要有监控:latch.getCount() 在超时那一刻的值,就是"有几个下游没回来"的直接证据,把它打进日志和猫眼/鹰眼这类监控里,比事后猜有用得多。

9.2 场景 B:批量导入分片(CyclicBarrier)

数据量决定分片数,分片之间要阶段对齐。核心是三段式:写、校验、建索引,每段结束 await 一次。注意两个工程细节:一是 barrier.await 一定要带超时,因为慢分片可能卡在数据库连接上;二是任何一片失败都要让整批终止并触发补偿任务,不能让"一半成功一半失败"的数据留在库里。

9.3 场景 C:多阶段对账(CyclicBarrier + CountDownLatch)

对账的语义是"这一天的账,所有渠道都拉回来、都解析完,才能开始比对"。用一个 CyclicBarrier 卡住"拉取完成"和"解析完成"两个阶段,再用一个 CountDownLatch 等所有渠道的比对结果收齐,最后由主线程出差异报表。这套组合几乎是离线对账系统的标准骨架。

9.4 场景 D:压测与并发缺陷复现(CountDownLatch(1))

前面 6.4 已经给了完整代码。工程上还要补两点:一是压测线程数要等于目标并发度,不能用线程池复用;二是要采集 P99 而不只是平均值,因为并发问题往往体现在尾部延迟。

9.5 场景 E:服务预热与优雅启动(CountDownLatch)

@Component
public class WarmUpRunner implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(WarmUpRunner.class);

    @Resource
    private ExecutorService warmUpPool;
    @Resource
    private ConfigClient configClient;
    @Resource
    private LocalCache localCache;
    @Resource
    private RegistryClient registryClient;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        CountDownLatch latch = new CountDownLatch(3);
        AtomicReference<Throwable> fatal = new AtomicReference<>();

        run(latch, fatal, "配置中心", configClient::preload);
        run(latch, fatal, "本地缓存", localCache::warmUp);
        run(latch, fatal, "注册中心", registryClient::connect);

        // 10 秒还没预热完,直接启动失败,让发布流水线红掉,而不是带病接流量
        if (!latch.await(10, TimeUnit.SECONDS) || fatal.get() != null) {
            throw new IllegalStateException("服务预热失败,拒绝启动", fatal.get());
        }
        log.info("服务预热完成,开始对外提供服务");
    }

    private void run(CountDownLatch latch, AtomicReference<Throwable> fatal, String name, Runnable task) {
        warmUpPool.execute(() -> {
            try {
                task.run();
                log.info("预热项 {} 完成", name);
            } catch (Throwable t) {
                fatal.compareAndSet(null, t);
                log.error("预热项 {} 失败", name, t);
            } finally {
                latch.countDown();
            }
        });
    }
}

这个场景的价值在于:把"启动成功"从一句日志,变成一个有语义的状态。 预热没完成就不接流量,是可用性治理里性价比最高的一招。

9.6 场景 F:单元测试等待异步结果(CountDownLatch)

异步代码最难测的是"我怎么知道它跑完了"。用 Latch 做测试同步,比 Thread.sleep(1000) 可靠一百倍:

@Test
void shouldInvokeCallbackAfterAsyncSubmit() throws Exception {
    CountDownLatch called = new CountDownLatch(1);
    AtomicReference<String> payload = new AtomicReference<>();

    orderService.submitAsync(new Order("NO-1"), result -> {
        payload.set(result);
        called.countDown();
    });

    assertTrue(called.await(3, TimeUnit.SECONDS), "回调 3 秒内未触发");
    assertEquals("SUCCESS", payload.get());
}

注意断言里那句"3 秒内未触发"——测试失败时它会直接告诉你"是超时"而不是"值是 null",排查效率完全不同。

十、面试官高频面试题

这一节按"面试官真正会问的顺序"排,每题给出答题骨架,不背原文也能答得像回事。

1. CountDownLatch 和 CyclicBarrier 有什么区别?

先给一句话结论:一个等事件,一个等人。然后展开三个维度:谁等谁(一方等另一方 vs 互相等)、能否复用(一次性 vs 循环)、底层实现(AQS 共享模式 vs ReentrantLock + Condition)。最后补一句场景:聚合查询用 Latch,多阶段对齐用 Barrier。

2. CountDownLatch 底层怎么实现的?

AQS 共享模式。state 就是计数,countDown() 走 tryReleaseShared 做 CAS 递减,减到 0 返回 true 触发唤醒;await() 走 acquireSharedInterruptibly,tryAcquireShared 在 state == 0 时返回 1,否则返回 -1 并入队挂起。

3. 为什么 CountDownLatch 不能复用?想复用怎么办?

因为 state 只能减不能加,且没有任何公开入口能重置。想复用就换 CyclicBarrier(固定参与者)或 Phaser(动态参与者),或者干脆每批 new 一个 Latch——对象很轻,创建成本可以忽略。

4. CyclicBarrier 的"循环"是怎么实现的?

靠 generation。每轮是一个 Generation 对象,最后一个线程到达时执行 nextGeneration():signalAll() 唤醒全体、count 复位为 parties、新建 Generation。等待线程醒来后比对 g != generation 判断自己是被正常放行还是这一代已作废。

5. BrokenBarrierException 什么时候抛?

四种情况:屏障已被打破(g.broken == true)时再 await;某个等待线程被中断;某个线程 await 超时;有人调用了 reset()。本质是"这一代作废了",所有人一起失败。

6. await 超时之后屏障会怎样?

dowait 里检测到 timed && nanos <= 0 就 breakBarrier(),把整代标记为 broken 并唤醒所有人,然后抛 TimeoutException。也就是说超时不是"我自己退出",而是"整代报废",这是 CyclicBarrier 和 CountDownLatch 在异常语义上最大的差异。

7. 忘记在 finally 里 countDown 会怎样?

计数器永远到不了 0,所有 await 的线程永久阻塞(或等到超时)。如果这些线程来自业务线程池,池会被逐步占满,最终整个服务不可用。这是 Latch 最常见的一类生产事故。

8. await 的线程被中断,两者表现一样吗?

不一样。CountDownLatch 抛 InterruptedException,只影响当前线程,计数状态不变;CyclicBarrier 除了抛 InterruptedException,还会先 breakBarrier(),让所有其他等待者收到 BrokenBarrierException。

9. 能不能给 CountDownLatch 加计数?

不能。JDK 没有提供任何增加计数的 API。如果业务上确实需要"动态增加待完成项",说明你该用 Phaser(支持 register)或者干脆用 CompletableFuture 动态追加任务。

10. 内存可见性怎么保证的?

AQS 的 state 是 volatile,CAS 自带内存屏障;JDK 文档还明确声明了 happen-before:countDown() 之前的操作对 await() 成功返回之后的代码可见。所以子线程写入的结果,主线程 await 返回后能安全读到。

11. 线程池只有 4 个线程,CyclicBarrier 的 parties 设成 5,会怎样?

永久死锁。第 5 个线程永远排不进池,前 4 个永远等不到第 5 个到达。这类问题的排查特征是:线程栈里全是 WAITING 在 Condition.await,且 getNumberWaiting() 一直停在 4。规则很简单:池的最大线程数必须大于等于 parties。

12. 在 Tomcat 工作线程里 await 会有什么问题?

会把容器线程占住。Tomcat 的 maxThreads 默认 200,如果每个请求都 await 300ms,QPS 上限就是 200/0.3 ≈ 666,再高就开始排队。解法有三种:缩短预算、把聚合放到独立线程池并用异步 Servlet / DeferredResult 释放容器线程、或者上虚拟线程。

13. 虚拟线程下用这两个工具要注意什么?

CountDownLatch.await 和 CyclicBarrier.await 底层都是 LockSupport.park,虚拟线程 park 会卸载载体线程,不会 pin 住平台线程,所以是安全的。真正要注意的是:不要让大量虚拟线程挤进一个固定大小的平台线程池,否则虚拟线程退化成平台线程,等待成本又回来了。

14. 和 CompletableFuture.allOf 怎么选?

看你要不要"结果"和"失败处理"。只要完成信号,用 Latch,代码最短;要结果、要串行、要异常降级、要取最快,用 CompletableFuture。工程上常见组合是:CompletableFuture 负责编排,内部个别需要严格同步的点用 Latch 兜底。

15. 手写一个简化版 CountDownLatch,你怎么写?

答题骨架:用一个 volatile int state 加 CAS 递减;await 用 LockSupport.park,countDown 减到 0 时 unpark 所有等待线程;等待队列用 ConcurrentLinkedQueue 存 Thread。然后主动指出简化版的问题:无法处理中断、无法超时、有竞态(park 前需二次检查 state),所以生产必须用 JDK 实现。

16. 场景题:四个下游接口,总预算 300ms,怎么设计?

答题骨架:CountDownLatch(4) + 独立线程池 + await(300ms);每个子任务内 try/catch 隔离异常、finally 计数;结果放 ConcurrentHashMap 或 AtomicReference;超时后按"已返回结果 + 默认值"降级,并在响应里打 degraded 标记;同时上报监控(剩余计数即未返回数)。最后补一句:如果下游有强依赖顺序,就不是聚合问题,而是编排问题,应该换 CompletableFuture。

十一、一页纸速查

你的需求用哪个关键写法
主线程等 N 个子任务完成CountDownLatch(N)子任务 finally { latch.countDown(); },主线程 await(timeout)
所有线程等一个信号同时开跑CountDownLatch(1)先 ready.await() 再 start.countDown()
N 个线程分阶段齐步走CyclicBarrier(parties)每阶段末 barrier.await(timeout)
阶段结束要汇总CyclicBarrier(parties, action)action 里只做轻量汇总,不阻塞
参与者数量动态变化Phaserregister / arriveAndAwaitAdvance
要拿结果、要异常降级CompletableFuture.allOforTimeout + handle
控制同时并发的数量SemaphoretryAcquire(timeout) + finally release

上手检查清单(照着勾一遍就能上线):

  1. 计数递减是否在 finally。
  2. 是否所有 await 都带超时。
  3. 超时分支是否有降级返回值。
  4. 中断是否恢复标记。
  5. 线程池大小是否大于等于参与者数。
  6. 是否用了有界队列和明确的拒绝策略。
  7. 结果容器是否线程安全。
  8. 是否打了等待耗时与剩余计数的日志。
  9. CyclicBarrier 的异常是否被 BrokenBarrierException 兜住。
  10. 是否为这批同步逻辑写了并发单测。

十二、写在最后

CountDownLatch 和 CyclicBarrier 这两个类,代码加起来不到一千行,却解决了并发编程里最基础也最容易出错的一件事:让线程按你期望的顺序发生。

我把它们的关系总结成三句话,你可以直接记:

  • CountDownLatch 是"事件的倒计时":谁做完了就减一,减到零,等的人全部放行,且只放行一次。
  • CyclicBarrier 是"人员的集合点":谁到了就登记,人到齐,一起走,走完还能再来一轮。
  • 两个都不是"编排框架":它们只传递"完成"和"到齐",不传递结果,也不传播异常。要结果和失败处理,请交给 CompletableFuture。

最后提醒一句:并发工具从来不是"用了就高并发",它只是把串行等待变成了可控等待。真正决定系统吞吐的,永远是你对预算的分配、对线程池的治理、对降级的取舍。工具只是让这些决策有一个干净的落地方式。

本文为原创文章,如需转载,请联系作者获得授权,并注明出处。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/zxc18344522713/article/details/170560018

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--