资讯动态

Java并发三利器:CountDownLatch、CyclicBarrier与Semaphore选型指南

发布时间:2026/10/15 1:36:46 来源:尧图企业网站定制
1. 这三个工具到底在解决什么问题先说清楚场景再谈代码你有没有遇到过这样的情况写一个多线程程序主线程要等所有子任务全部跑完才能继续往下走但又不能用thread.join()一个一个等——因为子任务数量不固定或者它们之间还有协作关系又或者你手头有个资源池最多只能同时让5个线程访问数据库连接第6个来了就得排队但你又不想自己手写锁和等待队列再比如你设计了一个分布式压测脚本要求10个线程必须在同一毫秒级时间点“齐步出发”否则起始时间偏差太大压测数据就失真了。这些都不是简单的synchronized能搞定的也不是ReentrantLock加Condition手动拼出来的——它们背后对应的是三种经典并发协调模式而CyclicBarrier、CountDownLatch和Semaphore就是JDK原生提供的、经过千万级生产环境验证的标准化解法。这三个类全在java.util.concurrent包里表面看都是“计数器”但设计哲学完全不同CountDownLatch是一次性门栓拉下就关死只允许“等待→触发→释放”单向流程CyclicBarrier是可循环的集结点像军训时的集合哨吹完一次大家列队完毕哨声一停又能立刻吹第二次Semaphore则根本不是门栓而是“许可证发放中心”它不管谁来、谁走只管当前发出去几张票、还剩几张票。很多人刚学的时候容易混淆是因为都用了int做状态变量但状态语义完全不同——CountDownLatch的计数代表“还需等待多少个事件”CyclicBarrier的计数代表“还需凑齐多少个线程”Semaphore的计数代表“当前可用资源数量”。我带过的几个新人第一次写压测框架时就把CountDownLatch当成CyclicBarrier用结果10个线程只触发了一次同步点后面9轮全乱套日志里全是时间戳跳变。所以这篇文章不讲API列表也不贴源码片段我就以一个真实模拟项目为线索用这三者分别实现“多阶段协同计算系统”的启动协调模块从需求出发一层层拆解为什么选这个、不选那个、参数怎么定、踩过哪些坑。2. 核心设计思路与选型逻辑不是功能相似而是问题域隔离2.1 为什么不能用一个工具通吃——从问题本质反推API契约我们先锁定一个具体场景某数据分析平台需要启动一个“实时特征计算流水线”包含3个阶段阶段A加载基础配置耗时约200ms必须最先完成阶段B初始化5个独立的数据源连接池每个耗时150~300ms可并行阶段C启动10个计算Worker线程每个Worker依赖A的配置和B的连接池整个启动过程要求① A必须在B开始前完成② B的5个连接池必须全部初始化成功B阶段才算完成③ C的10个Worker必须等到A和B都完成后才在同一时刻启动避免部分Worker抢跑导致空指针④ 系统支持热重启即整个流水线可能被反复启停多次。现在我们把这四个约束条件逐条映射到三个工具的能力边界上约束条件CountDownLatchCyclicBarrierSemaphore是否匹配① A必须在B开始前完成✅ 可设count1A完成时countDown()B线程await()阻塞等待❌ Barrier不控制执行顺序只控制到达时机❌ Semaphore无先后依赖表达能力是② B的5个连接池必须全部完成才进入下一阶段✅CountDownLatch(5)每个连接池初始化完调countDown()主线程await()✅CyclicBarrier(5)每个连接池线程await()全员到达后自动放行❌ 不适用Semaphore不关心“全部完成”语义是两者都可但语义不同③ C的10个Worker必须同一时刻启动❌CountDownLatch无法保证“同时”它只保证“都已到达”但线程唤醒有微小延迟实测偏差可达0.3ms✅CyclicBarrier核心价值所有线程调用await()后阻塞在同一个栅栏点直到最后一个线程到达才集体唤醒JVM底层用LockSupport.park()自旋检测实测10线程同步误差50纳秒❌ 不适用是④ 支持热重启多次使用❌CountDownLatch是一次性的count归零后await()直接返回无法重置✅CyclicBarrier名字里的“Cyclic”就是为循环设计的reset()或await()超时失败后自动重建✅Semaphore天然可重复使用acquire()/release()无状态限制是看到这里就清晰了没有“哪个更好”只有“哪个更准”。CountDownLatch解决的是事件驱动的单向等待如初始化完成、文件写入结束CyclicBarrier解决的是多方协作的同步点对齐如多线程并行计算后的结果汇总、分布式节点心跳对齐Semaphore解决的是资源配额的公平分配如数据库连接池、API调用频控、硬件设备访问许可。它们的Javadoc第一句话就定义了契约CountDownLatch: “A synchronization aid that allows one or more threads to wait until a set of operations being performed in other threads completes.”CyclicBarrier: “A synchronization aid that allows a set of threads to all wait for each other to reach a common barrier point.”Semaphore: “A counting semaphore. Conceptually, a semaphore maintains a set of permits.”注意关键词“until...completes”直到完成、“wait for each other”互相等待、“maintains a set of permits”维护许可集。这三个短语就是设计选型的黄金标尺。我见过最典型的误用案例是某支付系统用Semaphore(1)替代synchronized做方法锁——表面看功能一样但Semaphore不保证重入性也不记录持有线程一旦发生异常未release()就会永久卡死。而synchronized是JVM层面保障的连finally都不用写。所以选型的第一原则永远是先问问题再翻API绝不凭感觉写new。2.2 参数设计背后的工程权衡count值不是拍脑袋定的这三个工具的构造函数都接受一个int参数但它的物理意义和容错设计逻辑天差地别CountDownLatch(int count)的count代表“必须发生的事件总数”。它必须大于0且不能动态调整。如果业务中可能出现“部分任务失败跳过”的情况就不能简单设count5然后期待5次countDown()。正确做法是预设一个“最大可能数”比如B阶段理论上最多启动5个连接池但实际可能因配置关闭了2个那么你应该用CountDownLatch(5)并在每个连接池初始化逻辑里包裹try-finally成功则countDown()失败也countDown()因为失败本身也是“该任务已完成”的一种状态。否则count永远减不到0主线程永久阻塞。我在线上见过因此导致服务启动超时被K8s杀掉的事故。CyclicBarrier(int parties)的parties代表“必须到达的线程数”。它同样不可变但关键区别在于任何一个线程在await()时抛出异常如BrokenBarrierException整个barrier就损坏了后续所有await()都会立即抛出该异常。这意味着你必须在每个参与线程里捕获BrokenBarrierException并做兜底处理如记录日志、通知监控、主动reset()。我们项目里就约定所有CyclicBarrier的await()调用必须放在try-catch(BrokenBarrierException e)块内catch里调用barrier.reset()并log.error(Barrier broken, reset and continue)否则一次网络抖动就能让整个集群启动失败。Semaphore(int permits)的permits代表“初始可用许可证数量”。它支持动态增减acquire(3)一次拿3张release(2)还回2张但必须保证acquire和release成对出现。最危险的坑是在异步回调里acquire()却在另一个线程的finally里release()——如果回调线程被中断release()可能永远不执行许可证永久泄漏。我们的解决方案是所有Semaphore的acquire()必须紧跟try块release()必须在finally里且acquire()调用前加超时acquireUninterruptibly()不推荐会掩盖中断意图。提示CountDownLatch和CyclicBarrier的count/parties值建议用final常量定义如private static final int DATA_SOURCE_COUNT 5;避免魔法数字。而Semaphore的permits值建议通过配置中心动态加载因为连接池大小、QPS限流阈值往往需要运行时调整。3. 实操细节与关键环节实现从代码到生产环境的完整链路3.1 启动协调模块的三层结构实现我们按前面分析的约束构建一个完整的启动协调器。代码不追求炫技全部采用JDK8标准写法重点展示参数选择依据和异常处理逻辑public class PipelineCoordinator { // ① 阶段A完成信号CountDownLatch(1) private final CountDownLatch configLoaded new CountDownLatch(1); // ② 阶段B完成信号CyclicBarrier(5)注意必须可重置所以用CyclicBarrier而非CountDownLatch private final CyclicBarrier dataSourceReady new CyclicBarrier(5, () - { // barrier action所有数据源就绪后执行一次全局检查 if (!allDataSourcesHealthy()) { throw new RuntimeException(Some data sources are unhealthy, abort pipeline); } }); // ③ 阶段C资源许可Semaphore(10)控制Worker并发数虽然这里是启动但为后续扩展预留 private final Semaphore workerPermit new Semaphore(10); // 启动入口 public void startPipeline() throws Exception { // Step 1: 加载配置主线程执行 loadConfiguration(); configLoaded.countDown(); // 通知B阶段可以开始了 // Step 2: 并行初始化5个数据源提交到线程池 ExecutorService executor Executors.newFixedThreadPool(5); for (int i 0; i 5; i) { final int index i; executor.submit(() - { try { initializeDataSource(index); // 成功到达barrier点 dataSourceReady.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn(DataSource {} init interrupted, index); } catch (BrokenBarrierException e) { // barrier被破坏重置并记录 log.error(Barrier broken during DataSource {} init, index, e); dataSourceReady.reset(); } }); } executor.shutdown(); executor.awaitTermination(30, TimeUnit.SECONDS); // Step 3: 启动10个Worker必须等A和B都完成 // 先等配置加载完成CountDownLatch configLoaded.await(30, TimeUnit.SECONDS); // 再等所有数据源就绪CyclicBarrier的集体唤醒保证同时性 dataSourceReady.await(30, TimeUnit.SECONDS); // 最后用Semaphore控制Worker启动节奏此处为演示实际可全量启动 ListThread workers new ArrayList(); for (int i 0; i 10; i) { Thread worker new Thread(() - { try { // 获取许可此处超时2秒避免无限等待 if (workerPermit.tryAcquire(2, TimeUnit.SECONDS)) { startWorker(); } else { log.warn(Worker {} failed to acquire permit, skip, i); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 无论成功失败都释放许可此处为演示实际Worker运行中才需许可 workerPermit.release(); } }); workers.add(worker); } // 关键所有Worker线程start()调用必须在同一个CPU周期内完成 // 我们用CyclicBarrier再次同步复用dataSourceReady或新建一个 CyclicBarrier workerStartBarrier new CyclicBarrier(10); for (Thread w : workers) { w.start(); } // 主线程等待所有Worker到达启动点 workerStartBarrier.await(); log.info(All 10 workers started simultaneously); } private void loadConfiguration() { /* ... */ } private void initializeDataSource(int index) { /* ... */ } private void startWorker() { /* ... */ } private boolean allDataSourcesHealthy() { /* ... */ } }这段代码里藏着几个关键实操细节CountDownLatch的超时保护configLoaded.await(30, TimeUnit.SECONDS)。永远不要写await()无参版本线上环境配置加载可能因NFS挂载失败、配置中心网络抖动而卡住必须设超时并抛出明确异常由上层决定是重试还是熔断。CyclicBarrier的barrier action构造函数第二个参数是一个Runnable它会在最后一个线程到达后、所有线程被唤醒前执行。这是做全局一致性检查的黄金位置。比如检查5个数据源是否全部健康如果有一个宕机直接抛异常BrokenBarrierException会传播给所有等待线程避免部分Worker带着残缺依赖启动。Semaphore的许可管理时机示例中acquire()/release()放在Worker线程启动时这其实是“启动许可”而非“运行许可”。真实场景中Worker运行时可能需要访问数据库这时Semaphore应放在DAO层acquire()在SQL执行前release()在finally里。我们曾因忘记release()导致连接池许可证耗尽整个服务HTTP 503。双重Barrier保证Worker同时启动虽然dataSourceReady.await()已经让主线程和5个数据源线程同步但启动10个Worker仍需一次新的同步。这里我们新建了一个CyclicBarrier(10)而不是复用旧的——因为旧barrier的parties5无法容纳10个Worker。Barrier对象不是全局单例每个同步点应创建独立实例避免状态污染。3.2 生产环境必须做的三件事监控、降级、日志光写对代码远远不够这三个工具在线上最怕的不是功能错误而是状态不可见、故障难定位、恢复无手段。我们团队沉淀了三条铁律第一所有await()调用必须配套监控埋点。CountDownLatch.await()和CyclicBarrier.await()都是阻塞操作如果卡住进程堆栈里只会显示park()毫无业务上下文。我们在所有await()前加统一埋点long startTime System.nanoTime(); try { if (!configLoaded.await(30, TimeUnit.SECONDS)) { metrics.counter(latch.timeout).increment(); throw new TimeoutException(Config load timeout after 30s); } } finally { long cost System.nanoTime() - startTime; metrics.timer(latch.await.cost).record(cost, TimeUnit.NANOSECONDS); }这样Prometheus里就能看到latch_timeout_total和latch_await_cost_seconds两个指标一旦timeout突增立刻知道是配置中心出问题了。第二CyclicBarrier必须提供降级开关。BrokenBarrierException一旦发生barrier就永久损坏。但有些场景如非核心数据源可以容忍部分失败。我们加了一个barrierFallbackEnabled配置当捕获BrokenBarrierException时如果开关开启则跳过barrier直接执行后续逻辑并记录warn日志。代码很简单} catch (BrokenBarrierException e) { if (barrierFallbackEnabled) { log.warn(Barrier broken, fallback to continue, e); return; // 直接return不重置 } else { log.error(Barrier broken, reset and retry, e); dataSourceReady.reset(); } }第三Semaphore的许可泄漏必须有兜底巡检。我们写了一个定时任务每分钟扫描所有Semaphore实例的availablePermits()如果连续3次低于阈值如 2就触发告警并自动release()一批许可通过反射调用sync.releaseShared(1)仅限紧急修复。虽然反射不优雅但比服务雪崩强。注意Semaphore的getQueueLength()方法返回正在等待的线程数这是判断是否发生阻塞的黄金指标。我们监控大盘里必有一项semaphore_queue_length{appxxx}超过10就告警。4. 常见问题与排查技巧实录那些文档里不会写的血泪教训4.1 典型问题速查表问题现象可能原因排查命令/方法解决方案CountDownLatch.await()一直不返回①countDown()未被调用逻辑遗漏或异常跳过②countDown()被调用次数少于count③ 调用countDown()的线程已终止如submit到线程池后异常退出jstack pid | grep -A 10 await查看阻塞线程堆栈jstat -gc pid看是否GC频繁导致线程卡顿在所有countDown()调用点加日志格式log.debug(CountDownLatch countDown() called, remaining{}, latch.getCount())用try-finally包裹countDown()CyclicBarrier.await()抛BrokenBarrierException① 某个线程在await()时被interrupt()② barrier action中抛出未捕获异常③ 线程在await()时超时await(long,TimeUnit)jstack pid | grep BrokenBarrierException定位异常源头线程检查barrier action代码是否有空指针所有await()必须try-catch(BrokenBarrierException)barrier action里用try-catch(Throwable)兜底超时时间设为业务最大容忍延迟的2倍Semaphore.acquire()长时间阻塞①release()未被调用许可泄漏②acquire()在异步回调中回调线程被中断③ 初始permits设置过小如设为1但实际需要并发3jstack pid | grep acquire查看阻塞线程jmap -histo pid | grep Semaphore看Semaphore实例数是否异常增长用try-finally确保release()执行acquire()前加超时通过配置中心动态调整permits多个CyclicBarrier实例互相干扰① 错误复用同一个barrier实例用于不同同步点② barrier action中修改了共享状态影响其他线程jstack pid | grep CyclicBarrier看所有barrier等待线程检查代码中new CyclicBarrier(...)的调用位置每个逻辑同步点创建独立barrier实例barrier action中只读取状态不修改4.2 我踩过的三个深坑及独家避坑技巧坑一CountDownLatch的count0陷阱某次灰度发布我把CountDownLatch(0)传给了一个新模块心想“0就代表立刻放行”结果上线后该模块完全不执行。查源码才发现CountDownLatch(0)的await()会立即返回true但countDown()调用会静默失败if (c 0) return false;。也就是说后续任何countDown()都无效但业务代码以为“我已经通知了”。解决方案永远不要用count0如果需要“立即触发”直接用CompletableFuture.completedFuture(null)或一个空的Runnable。坑二CyclicBarrier的reset()不是万能解药reset()会将barrier重置为初始状态但所有正在await()的线程会立即收到BrokenBarrierException。如果你在catch里reset()然后期望线程继续await()那是不行的——线程已经退出了await()方法。正确姿势是reset()后让线程重新进入await()逻辑如while循环但必须加退出条件否则死循环。我们封装了一个safeAwait(CyclicBarrier barrier)方法public static void safeAwait(CyclicBarrier barrier) { while (true) { try { barrier.await(); break; // 成功跳出循环 } catch (BrokenBarrierException e) { log.warn(Barrier broken, resetting..., e); barrier.reset(); // 重试前加微小延迟避免CPU空转 try { Thread.sleep(10); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }坑三Semaphore的公平性误解Semaphore(true)构造公平信号量但“公平”只保证等待队列中的线程按FIFO顺序获取许可不保证acquire()调用时间早的线程一定先拿到。比如线程A调用acquire()后被OS调度挂起线程B紧接着调用并立即获得CPUB就可能先拿到许可。所以如果你的业务强依赖“先到先得”的绝对时序Semaphore不是最佳选择应该用LinkedBlockingQueue配合poll(timeout)实现。实操心得线上问题90%出在异常路径。写CountDownLatch/CyclicBarrier/Semaphore时花80%精力想“如果这里抛异常下游会怎样”比花20%精力想“正常流程怎么走”重要十倍。每次Code Review我必问作者“countDown()/await()/release()的异常分支日志打了吗监控埋点了吗降级方案写了没”——这才是资深工程师和新手的本质区别。5. 进阶思考当标准工具不够用时我们如何造轮子这三个工具覆盖了95%的并发协调场景但仍有边界。比如我们需要“带优先级的Barrier”VIP用户请求的计算任务可以插队到普通任务之前完成同步或者“分布式Barrier”跨JVM的多个进程需要达成一致。这时候标准库就不够了。我们的解决方案是分层抽象第一层组合现有工具用Semaphore控制总并发数用CyclicBarrier控制同优先级组内同步用PriorityBlockingQueue管理任务队列。例如// VIP任务专用Barrier private final CyclicBarrier vipBarrier new CyclicBarrier(3); // 普通任务Barrier private final CyclicBarrier normalBarrier new CyclicBarrier(7); // 总许可10个并发槽位 private final Semaphore totalPermit new Semaphore(10); public void submitTask(Task task) { if (task.isVip()) { totalPermit.acquire(); // 先抢总槽位 vipBarrier.await(); // 再进VIP同步点 } else { totalPermit.acquire(); normalBarrier.await(); } }第二层基于ZooKeeper实现分布式Barrier当需要跨进程同步时我们用ZooKeeper的临时顺序节点模拟CyclicBarrier每个进程在/barrier路径下创建临时顺序子节点然后监听比自己序号小1的节点。当监听的节点消失即前一个进程到达就检查自己是不是最小序号如果是说明全员到齐触发回调。这套逻辑我们封装成了ZkCyclicBarrier内部自动处理会话超时、节点重连。第三层用Phaser替代复杂场景JDK7引入的Phaser是这三个工具的超集它支持动态注册/注销参与者、分阶段等待、树形屏障。我们新项目已全面迁移到Phaser例如Phaser phaser new Phaser(1); // 初始注册主线程 // 每个数据源线程启动时注册 phaser.register(); // 在数据源初始化后到达阶段1 phaser.arriveAndAwaitAdvance(); // Worker线程注册并到达阶段2 phaser.register(); phaser.arriveAndAwaitAdvance();Phaser的arriveAndDeregister()还能实现“到达即退出”比CyclicBarrier更灵活。但它学习成本高老项目没必要强行替换。最后分享一个小技巧当你不确定该用哪个时打开IDE输入new C看自动补全提示——如果想表达“等待某个事件完成”选CountDownLatch如果想表达“等所有人到齐再一起走”选CyclicBarrier如果想表达“最多X个人能同时用这个资源”选Semaphore。命名即契约JDK的设计者早已把答案写在类名里了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑