
1. 先搞明白这三兄弟和AQS到底什么关系先说个场景。接口要限流Semaphore是首选并行加载一批配置主线程要等全部完成才能往下走用CountDownLatch几十个线程各处理一个分片处理完必须等同伴也到齐才能统一做下一步那就上CyclicBarrier。这三个同步器是java.util.concurrent里出场率最高的并发工具名字只要写过Java并发都眼熟但真问一句Semaphore和CountDownLatch的底层都挂在AQS上CyclicBarrier为啥偏偏用ReentrantLock加Condition自己搭了一套能答上来的人不多。这篇文章把三份源码从构造器到核心模板方法逐行拆尽量用大白话讲清楚它们各自的state语义、排队机制和中断处理适合所有Java并发入门之后想再往深走一步的读者。1.1 AQS干了什么state是什么AbstractQueuedSynchronizerAQS是java.util.concurrent整片地基里最关键的那块石头。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock甚至ThreadPoolExecutor里的Worker底层都在用同一套东西一个volatile int state表示同步状态一条FIFO等锁队列管理抢不到资源的线程。state是理解所有同步器的钥匙。每个类对state的解释完全不一样ReentrantLockstate表示当前线程重入了几次锁Semaphorestate表示还剩几个许可证CountDownLatchstate表示还有几件事没完成ReentrantReadWriteLockstate高16位是读锁次数低16位是写锁次数AQS本身只提供模板方法把当前线程能不能通过这个判定留给子类实现。所以你翻Semaphore和CountDownLatch的源码时会看到它们各自有一个私有内部类Sync extends AbstractQueuedSynchronizer核心工作就两件事重写tryAcquireShared和tryReleaseShared。这两个钩子方法一旦看懂整个同步器的执行流程基本就通透了。1.2 共享模式与独占模式的差别AQS对外暴露两套模板独占模式acquire/release和共享模式acquireShared/releaseShared。Semaphore和CountDownLatch都属于共享模式因为许可证和倒计时天然可以让多个线程同时消耗。独占模式和共享模式最大的区别在唤醒环节。独占模式释放资源时只会唤醒等待队列头节点那一个线程共享模式释放时会把队列里符合条件的节点挨个唤醒等于喊一嗓子都醒醒轮到你们了。这个差异在后面看doReleaseShared相关代码时会反复遇到先记在心里。2. Semaphore源码解析许可证的借与还2.1 从构造器到acquire的完整链路Semaphore的构造器并不神秘本质就是往Sync里传初始许可数public Semaphore(int permits) { sync new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync fair ? new FairSync(permits) : new NonfairSync(permits); }setState(permits)会把传入的许可数直接当成AQS的初始state。注意默认构造函数走的是NonfairSync只有显式传true才会用到FairSync。acquire()的代码只有一行public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); }acquireSharedInterruptibly是AQS已经写好的模板方法核心流程是先调tryAcquireShared尝试获取返回值如果是负数就把当前线程包装成Node丢进同步队列挂起返回值非负就说明获取成功直接放行。Sync里有两种tryAcquireShared实现非公平版的代码长这样final int nonfairTryAcquireShared(int acquires) { for (;;) { int available getState(); int remaining available - acquires; if (remaining 0 || compareAndSetState(available, remaining)) return remaining; } }这里有个容易被忽略的细节remaining 0时直接返回负数并不做CAS。意思是许可不够了你带着这个负数去排队吧。AQS并不关心你具体差几个只要看到负数就挂起。while(true)加CAS自旋是AQS系代码的典型风格因为并发环境下state可能随时被别的线程改掉必须循环到CAS成功为止。2.2 公平与非公平的分水岭FairSync的tryAcquireShared和NonfairSync几乎一样只多了一行protected int tryAcquireShared(int acquires) { for (;;) { if (hasQueuedPredecessors()) return -1; int available getState(); int remaining available - acquires; if (remaining 0 || compareAndSetState(available, remaining)) return remaining; } }hasQueuedPredecessors()检查等待队列里有没有排在前面的线程。如果有即使当前state还有余量新来的线程也直接返回-1去排队这就是公平。非公平不是不公平到极点。它只是在许可刚好还剩、队列里还没有等待者的时候允许新线程直接插队减少一次上下文切换的开销。一旦队列里有人等着非公平模式也会遵循FIFO顺序。所以结论是非公平Semaphore的吞吐量通常更高但可能出现后来者先得的瞬时现象要求严格按序获取的场合才需要fairtrue。2.3 release的对称性与常见陷阱release()最终走到AQS的releaseShared(1)再到Sync的tryReleaseSharedprotected final boolean tryReleaseShared(int releases) { for (;;) { int current getState(); int next current releases; if (next current) // overflow throw new Error(Maximum permit count exceeded); if (compareAndSetState(current, next)) return true; } }acquire是state减一减不动就排队release是state加一加成功就唤醒等待线程两个操作完全对称。这里专门做了溢出检查如果next current说明加法溢出直接抛Error而不是静默失败避免release假装成功却什么都没发生导致后面acquire永久挂起。正因为是纯计数器模型Semaphore创建后可以无限次acquire/release而且acquire的线程和release的线程不需要是同一个。这把双刃剑在实战里坑过不少人有人写连接池时把acquire放在try外面、release放在finally里逻辑没问题但有人异常分支先return了把release跳过去结果许可数越来越少服务莫名其妙拒绝新流量。我见过一次线上事故某个服务把Semaphore当限流器用某条异常路径只catch没释放几小时后明明几乎没请求在跑许可却被耗尽服务直接不接新流量。后来排查才发现是某个异常分支把release跳过了。这种问题靠code review很难发现最有效的办法是约定谁acquire谁负责releaserelease必须放finallyacquire和release之间不要留任何提前return的路径。3. CountDownLatch源码解析一次性倒计时闸门3.1 await与state归零的触发关系CountDownLatch的Sync同样继承AQS但state的含义完全不同它表示还需要等待多少个事件完成。构造时传入的count就是初始state。它的tryAcquireShared是整个AQS体系里最简单的实现之一protected int tryAcquireShared(int acquires) { return (getState() 0) ? 1 : -1; }就一行。state不为0就返回-1调用线程进队列挂起state变成0之后返回1所有等待线程一次性放行。这里连CAS都不用因为判断过程不修改任何状态属于纯观察者。countDown走的是releaseSharedprotected boolean tryReleaseShared(int releases) { for (;;) { int c getState(); if (c 0) return false; int nextc c - 1; if (compareAndSetState(c, nextc)) return nextc 0; } }两个细节值得圈出来。第一state已经减到0时tryReleaseShared直接返回false后面再countDown不会有任何效果也不报错这是设计好的幂等行为。第二CAS成功之后返回的是nextc 0也就是说只有那个把state从1减到0的线程才会返回true告诉AQS该唤醒所有等待者了。这保证了整个流程只触发一次唤醒而不是每个countDown线程都去尝试signal。3.2 超时与中断的两条退出路径CountDownLatch的await()实际上就是acquireSharedInterruptibly(1)带超时的await(long, TimeUnit)走的是tryAcquireSharedNanos内部帮你处理了中断和超时两个退出路径。这里必须强调一个高频误解主线程await超时返回不代表countDown对应的任务被取消了。我早期写过一段代码用countDownLatch.await(3, TimeUnit.SECONDS)超时后直接把结果当失败处理结果下游任务其实还在正常执行最后造成数据重复写入。超时的语义是我不等了不是大家别干了。真要取消得配合线程池的shutdownNow或者future.cancelLatch本身没有取消能力。3.3 为什么它只能是一次性的CountDownLatch没有任何重置state的方法。state减到0之后tryReleaseShared里c 0直接返回false你再怎么countDown也不会让计数回升tryAcquireShared里state为0永远返回1后来的await线程全都直接放行。这个闸门只能从关闭走向打开没法反向关闭。源码里其实能看出作者的态度。Sync的tryReleaseShared注释写得很明白signal when transition to zero从设计上就没打算让你往回走。如果你想做多轮闸门老老实实换CyclicBarrier或者每轮new一个CountDownLatch。硬在同一个Latch上反复用只会得到一个永远敞开的门。4. CyclicBarrier源码解析不走AQS的可复用屏障4.1 先打破一个常见误区很多资料把三兄弟并列默认CyclicBarrier也是AQS实现的。翻源码就会发现完全不是CyclicBarrier的核心字段是ReentrantLock和Condition整份源码没有一行继承或使用AbstractQueuedSynchronizer。为什么不用AQS因为CyclicBarrier需要的不只是排队等待还需要记录每一轮参与的线程数、支持reset重新开始、能通过代际区分不同轮次这些用一把普通锁加条件队列实现起来更直观。AQS确实强大但不是万能的这是Doug Lea在早期设计里的一个经典取舍。4.2 三个关键字段parties、count、generationprivate final ReentrantLock lock new ReentrantLock(); private final Condition trip lock.newCondition(); private final int parties; private int count; private final Runnable barrierCommand; private Generation generation new Generation();parties是一轮屏障需要等待的线程总数构造后不可变。count是当前这一轮还差几个线程到齐初始等于parties每到一个线程就count--减到0表示全员到齐进入下一代。Generation是理解CyclicBarrier的钥匙。它记录屏障当前处于哪一轮内部只有一个boolean字段private static class Generation { boolean broken false; }每一轮结束后都会new一个Generation。这样设计是为了处理异常场景如果某一轮里某个线程被中断或超时屏障进入broken状态其他线程再await时立刻抛BrokenBarrierException不会傻傻等到天荒地老。4.3 dowait逐行拆解所有await的最终归宿都是同一个私有方法dowaitprivate 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) { // tripped boolean ranAction false; try { final Runnable command barrierCommand; if (command ! null) command.run(); ranAction true; nextGeneration(); return 0; } finally { if (!ranAction) breakBarrier(); } } // loop until tripped, broken, interrupted, or timed out for (;;) { try { if (!timed) trip.await(); 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) throw new TimeoutException(); } } finally { lock.unlock(); } }这段代码信息量很大我挑重点讲。先看index和到达顺序的关系。index --count所以最早到达的线程index最大parties-1最后一个到达的线程index为0。dowait返回的就是这个index。屏障动作barrierCommand一定由最后一个到达的线程执行其他线程只能在trip条件队列里干等。返回值有什么用典型场景是分片计算每个线程算完自己的分片后在屏障汇合通过返回值知道我是第几个到的从而决定下一步谁负责汇总。再看屏障动作的执行。最后一个线程调用barrierCommand.run()如果run()内部抛异常ranAction一直是falsefinally里会调用breakBarrier()把当前代标记为broken然后异常继续往外抛。所以屏障动作里别干重活它阻塞的就是最后那个线程而它的执行时间直接影响所有等待线程的解禁时间。线程中断的处理很讲究。在条件队列等待期间收到中断先判断代际是否变化或者已经broken。如果代际没变且没broken说明是当前这轮的受害者线程必须breakBarrier()让所有同伴解除等待如果代际已经换了说明是上一辈子的中断只需要恢复中断标志位不影响新的一轮。这个细节面试问出来能卡住一大片只会背API的人。超时路径也值得看。带超时的await进入循环后调awaitNanos醒来后依次检查g.broken、g ! generation、nanos 0L。超时同样会触发breakBarrier让所有线程都感知到这轮废了是回滚还是重试业务自己掂量。4.4 reset与broken机制reset()的逻辑非常直白public void reset() { final ReentrantLock lock this.lock; lock.lock(); try { breakBarrier(); // break the current generation nextGeneration(); // start a new generation } finally { lock.unlock(); } }先break再reset顺序不能反。正在等待的线程会先收到BrokenBarrierException下一轮的await才恢复正常。如果业务想区分是有人中断导致的broken还是有人主动reset可以去观察当前generation的broken标志但说老实话正常业务很少需要精确区分这两者。5. 三兄弟对比与实战选型5.1 一张表看懂底层差异把三个类的关键维度放在一张表里差异一目了然对比维度SemaphoreCountDownLatchCyclicBarrier底层实现AQS共享模式AQS共享模式ReentrantLock Conditionstate语义剩余许可证数量未完成事件数本轮未到达线程数是否可复用可天然计数器不可一次性可靠代际切换等待对象获取许可的线程执行countDown的外部线程相互等待的同伴线程公平性支持公平/非公平无公平语义默认基于非公平锁中断影响当前线程中断不影响他人当前线程中断不影响他人任一等待线程中断全员broken超时影响当前线程超时返回当前线程超时返回超时导致当前代broken典型场景限流、连接池、信号控制主线程等N个任务完成分片计算齐头并进、多轮同步5.2 几个容易混淆的场景辨析第一个经典问题CountDownLatch能不能当CyclicBarrier用不能反过来也不行。CountDownLatch是主线程等子线程子线程之间互不等待每个子线程干完自己的事就可以走CyclicBarrier是每个线程都必须等别人一个人提前到了也得在屏障前候着。我在系统里用CountDownLatch模拟过栅栏效果结果某个线程处理得慢其他线程早就各自往下跑了因为Latch只关心计数不关心谁到了。第二个问题CompletableFuture都出来了还要这仨干嘛多数简单编排场景CompletableFuture确实更顺手但Semaphore限制同一时刻最多N个线程进入临界区的资源控制语义CountDownLatch批量等待任务结果的朴素表达CyclicBarrier每轮齐头并进的回合制模型在并发基础设施代码里依然不可替代。工具没有过时一说只有合不合适。6. 高频问题与排坑实录6.1 面试里常被追问的点问题结论CyclicBarrier为什么不用AQS需要代际管理、屏障动作、返回到达顺序LockCondition更直观AQS定位是排队同步器不是万能的CountDownLatch能不能重置不能state只能单向递减想复用换CyclicBarrierawait超时返回后任务还在跑吗还在跑超时只是主线程不等了不是任务取消Semaphore公平模式一定按序通过吗不一定公平只保证新acquire不插队不保证已经获取的线程按序releaseCyclicBarrier的barrierCommand由谁执行最后一个到达的线程也就是dowait返回index为0的那个线程线程中断对三者的影响有何不同Semaphore和CountDownLatch只影响当前线程CyclicBarrier会让整个屏障broken6.2 我实际踩过的三个坑第一个坑是CountDownLatch的count初始值算错。动态生成任务列表时用了taskList.size()当count结果并发环境下List里又被塞了新任务size变了有的任务没countDown主线程永久阻塞。后来我统一改成先固定任务数再启动线程而且所有await都带超时兜底这类永久阻塞再也没出现。第二个坑是CyclicBarrier在线程池里的线程饥饿死锁。我把parties设成跟线程池核心线程数一样在submit的任务内部调用await结果任务还没跑完线程池所有线程都卡在屏障上外部任务再也提交不进去整个executor卡死。CyclicBarrier的等待线程必须是同一批能同时活跃的线程如果靠线程池调度必须保证parties远小于最大并发度留足调度余量否则就是自己给自己造死锁。第三个坑是Semaphore的release与acquire不成对。前面说过忘了release会让限流器逐渐枯竭反过来重复release会让许可数虚高、限流失效。后来我在一个框架里通过包装acquire返回AutoCloseable句柄用try-with-resources自动归还这个坑才算根治。6.3 接下来还可以读什么这三个类看完之后下一步建议去啃AQS本身的骨架方法acquireSharedInterruptibly、doAcquireShared、doReleaseShared以及Node的waitStatus流转CANCELLED、SIGNAL、PROPAGATE。Semaphore和CountDownLatch只是钩子方法的两个不同实现真正复杂的排队、阻塞、唤醒全在AQS骨架里。把骨架搞清楚之后ReentrantReadWriteLock、StampedLock、ThreadPoolExecutor的Worker这些实现你都能举一反三。我自己读这部分源码最大的体会是源码读多了之后收获的不是背得住多少结论而是慢慢能感觉到每个类的边界感哪里用模板方法哪里自己实现哪里宁可自己搭一套Lock加Condition也不硬套AQS。这种分寸感比任何面试八股都值钱。如果让我给一条建议那就是别只盯着代码看先把每个类的state语义、共享/独占模式、可重入性、公平性这几个维度画成一张脑图再回头读源码吸收效率会翻倍。