面试官一句“你讲讲 Semaphore 的限流原理,扯上 AQS 和 CAS 的那种”,能当场卡住不少人。背过八股的人都会说“Semaphore 是信号量,基于 AQS 实现”,但真被追问到“AQS 怎么配合 CAS 把线程拦住”“非公平模式下凭什么性能更高”“state 为什么能当许可证用”,回答就支支吾吾了。
其实 Semaphore 的原理不复杂,一句话能说透:它内部持有一个 AQS 同步器,用一个 volatile 修饰的 int state 记录剩余许可证数量。acquire 时用 CAS 把 state 减 1,减成负数说明配额耗尽,线程进 AQS 等待队列挂起;release 时用 CAS 把 state 加 1,成功后唤醒队列里排在最前面的线程。就这么点事,但想讲清楚、讲透、让面试官点头,需要把 CAS、volatile、AQS 队列、公平/非公平、中断/超时这些模块全部串起来。
这篇文章我按源码级拆解的思路写,适合正在准备 Java 并发面试的同学,也适合那些项目中用了 Semaphore 但只停留在 API 层面的开发者。读完你不仅能应付追问,还能在实际限流场景里避开那些文档里不会写的坑。
1. Semaphore 是干嘛的:先搞清楚“限什么流”
很多面试者一上来就背“Semaphore 是限流器”,但问他“你限的是什么?”立刻露馅。Semaphore 限的不是 QPS(每秒请求数),而是并发度——同一时刻最多允许多少个线程进入临界区。这两个概念是面试第一个分水岭。
1.1 停车场模型:信号量的现实映射
把 Semaphore 想象成一个停车场,构造函数里的 permits 就是车位总数。车进场调用 acquire(),占一个车位,state 减 1;车离场调用 release(),让出一个车位,state 加 1。车位满的时候,新来的车必须在门口排队等着,直到有车离场腾出位置。
这么设计的好处是:它天然适配那些“资源有限、不能无限并发”的场景,比如数据库连接池、第三方 API 调用、大文件导出等。代码写起来也非常朴素:
Semaphore semaphore = new Semaphore(5); // 业务入口 try { semaphore.acquire(); // 这里只能同时有 5 个线程进入 doBiz(); } finally { semaphore.release(); }这个 finally 里的 release() 是命根子,漏写了就是事故。我后面会专门讲这个坑。
1.2 并发度限流 vs 速率限流:别把两件事混在一起
Semaphore 只管“同时有几个线程在跑”,管不了“一秒钟能进来几个请求”。5 个并发意味着同一时刻最多 5 个线程在执行业务,但如果每个请求只要 1 毫秒,一秒钟照样能处理几千个请求。反过来,如果你想限制“每秒最多 10 个请求”,Semaphore 完全帮不上忙,那需要的是令牌桶或漏桶算法,比如 Guava RateLimiter 或 Sentinel。
这个区别在面试里是典型的加分项。你主动说出“Semaphore 是并发度闸门,不是速率闸门”,面试官会认为你真的理解限流的本质,而不是只会背 API。
2. 底子上的两个神操作:CAS 和 volatile
要说 Semaphore,绕不开 AQS;要说 AQS,绕不开 CAS 和 volatile。这两个是 JUC 包的基石,也是面试最容易深挖的点。
2.1 CAS:改之前先对一眼
CAS 全称 Compare And Swap,翻译过来就是“比较并交换”。它的语义是:只有当内存当前值等于我预期的旧值时,才把它更新成新值,整个过程是原子操作。
举个生活例子。你去柜台改手机号,工作人员不会直接改,而是先核对“你报出的手机号”和“系统里存的手机号”是否一致,一致才更新成新号码。如果中途别人也改了系统里的号码,这次核对就失败,你得重来。
在 Java 里,CAS 最终落到 Unsafe 类的 compareAndSwapInt 方法,底层是 CPU 的 cmpxchg 指令配合 LOCK 前缀(多核场景下锁总线或锁缓存)实现的原子操作。JUC 包几乎所有同步器都在用它做“无锁状态更新”。
Semaphore 里的典型 CAS 是这样一段代码:
int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) { return remaining; }compareAndSetState(available, remaining) 这一步就是 CAS:如果当前 state 还是 available,就把它改成 remaining;如果已经被别的线程改了,就重新读、重新算。外面套个 for 死循环,就是著名的“自旋 CAS”。
2.2 volatile:让所有线程都能看到最新值
CAS 负责“改”,volatile 负责“让改的结果被所有人看到”。AQS 里的 state 就是 volatile 修饰的:
private volatile int state;volatile 有两个核心能力:一是可见性,一个线程修改 state 后,其他线程能立刻读到最新值;二是有序性,通过内存屏障禁止指令重排,避免“改了一半被读到”这种诡异情况。
为什么 Semaphore 需要 volatile?因为多个线程同时 acquire/release 时,大家都得基于同一个最新 state 做 CAS,如果某个线程读到的是旧值,就会把别人刚减掉的许可又减一遍,造成超发。CAS 保证了“改”的原子性,volatile 保证了“看”的实时性,两者缺一不可。
提示:volatile 不能保证复合操作的原子性。state-- 不是原子操作,所以才需要 CAS 来兜底。这也是为什么 Semaphore 内部永远不写 state--,而是用 compareAndSetState。
2.3 ABA 问题在 Semaphore 里不算事
CAS 有个经典问题叫 ABA:线程 A 读到的值是 1,中间被改成 2 又改回 1,线程 A 再 CAS 时发现还是 1,就认为没人动过,实际上中间已经动过了。
但在 Semaphore 的场景里,state 代表剩余许可证数量,它的值本身只关心“当前还剩多少”,不关心历史轨迹。线程拿到许可再释放,数值从 5 变 4 再变 5,这本来就是正常业务。就算有线程“插队”改动了 state,CAS 的语义依然正确——它只保证修改基于最新值,不保证期间无人触碰。所以 ABA 在这个场景下没有实际危害,面试时能说出这一层,会显得你思考很深。
3. AQS 到底是什么:一把会排队的锁框架
AQS 全称 AbstractQueuedSynchronizer,是 JUC 包所有锁和同步器的底座。ReentrantLock、CountDownLatch、Semaphore、ReentrantReadWriteLock 全是靠它实现的。理解 AQS,等于一次性理解了半个 JUC。
3.1 一个 state 加一个等待队列
AQS 的核心就两样东西:
- 一个 volatile int state,代表共享资源的数量或状态。
- 一个 CLH 变种的双向等待队列,存放获取资源失败的线程。
state 的具体含义由子类定义。在 ReentrantLock 里,state 是可重入次数;在 CountDownLatch 里,state 是倒数计数;在 Semaphore 里,state 就是剩余许可证数量。AQS 不关心 state 代表什么,它只提供一套“如何在多线程竞争下安全修改 state”的机制。
CLH 队列本质上是个 FIFO 双向链表,每个节点 Node 里持有线程引用和等待状态。线程抢不到资源就入队,然后通过 LockSupport.park 挂起自己,等前驱节点被唤醒后再尝试。
3.2 模板方法:子类只管“能不能拿”和“怎么还”
AQS 设计用的是模板方法模式。它把通用流程(入队、自旋、挂起、唤醒、中断处理)写死,把两个钩子方法留给子类实现:
- tryAcquireShared / tryAcquire:尝试获取资源,成功返回非负数,失败返回负数。
- tryReleaseShared / tryRelease:尝试释放资源,成功返回 true。
Semaphore 的 Sync 类只实现了 tryAcquireShared 和 tryReleaseShared,剩下的事全交给 AQS。这也是为什么 Semaphore 源码很精简——核心逻辑其实都在 AQS 里,Semaphore 只负责定义“什么是获取许可证成功”。
3.3 排队、挂起、唤醒的完整链路
AQS 获取共享资源的完整流程是:
- 调用 acquireSharedInterruptibly,先检查线程中断状态。
- 调用子类的 tryAcquireShared,如果返回值大于等于 0,直接成功返回。
- 如果返回负数,说明资源不够,创建共享节点加入队尾。
- 进入 for 循环:如果前驱是头节点,再尝试一次 tryAcquireShared。
- 拿不到资源就通过 shouldParkAfterFailedAcquire 检查前驱状态,然后 LockSupport.park 挂起线程。
- 被唤醒后回到循环重新尝试,直到成功或抛出中断异常。
这套流程的好处是:大多数情况下线程根本不会入队,直接 CAS 就抢到了资源,只有真正竞争激烈时才进队列休眠,把无锁高并发和阻塞等待结合得很好。
4. 打开 Semaphore 源码:限流就是这么干的
前面铺垫完,现在真正打开 Semaphore 源码。你会发现它非常紧凑,核心就是一个 Sync 内部类,以及公平与非公平两个子类。
4.1 从 new Semaphore(5) 开始:state 就是剩余许可证
构造函数对应源码:
public Semaphore(int permits) { sync = new NonfairSync(permits); } public Semaphore(int permits, boolean fair) { sync = fair ? new FairSync(permits) : new NonfairSync(permits); }Sync 的构造方法调用 AQS 的 setState:
Sync(int permits) { setState(permits); }也就是说,new Semaphore(5) 做的最核心的事就是 state = 5。这 5 就是初始许可证数量,每次 acquire 减少它,每次 release 增加它。
4.2 非公平模式:CAS 手快有,手慢无
默认的 Semaphore(5) 是非公平模式。非公平的 tryAcquireShared 长这样:
static final class NonfairSync extends Sync { protected int tryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) { return remaining; } } } }逻辑拆解:
- 读当前剩余许可证 available。
- 计算 remaining = available - 1。
- 如果 remaining 已经小于 0,说明许可证耗尽,直接返回负数。
- 否则 CAS 尝试把 state 从 available 改成 remaining,失败就循环重试。
注意这个顺序:先判断剩余,再做 CAS。如果 remaining 小于 0,连 CAS 都不做,直接返回负数走排队逻辑。
非公平的意思是:新来的线程不检查队列里有没有人在等,直接尝试 CAS 抢许可证。只要 CAS 成功,它就能插队拿到资源。这种策略在高并发下性能更好,因为省去了检查队列的步骤,还能让刚释放锁的线程立刻重新获取,减少线程上下文切换。代价是队列里排队的线程可能饿死,但 Semaphore 的许可证场景下,饿死只是延迟,不会死锁。
4.3 公平模式:先看排队,再动手
FairSync 的 tryAcquireShared 和非公平的差别只有一行:
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() 是 AQS 提供的方法,用来判断等待队列里是否已经有线程在排队。如果有,当前线程直接返回 -1,老老实实去队尾排队,绝不插队。
公平模式的语义是“先来后到”,适合对执行顺序敏感的业务。但代价是吞吐量下降,因为每个线程都要先查一次队列,而且刚释放许可的线程不能立刻重抢,必然经历一次挂起和唤醒。我个人的建议是:默认用非公平,只有明确需要顺序保证时才开公平模式。
4.4 acquire 全链路:从方法调用到线程挂起
acquire() 的调用链是:
public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); }进入 AQS 的模板方法:
public final void acquireSharedInterruptibly(int arg) throws InterruptedException { if (Thread.interrupted()) { throw new InterruptedException(); } if (tryAcquireShared(arg) < 0) { doAcquireSharedInterruptibly(arg); } }tryAcquireShared 返回负数时,进入 doAcquireSharedInterruptibly,这个方法的核心流程是:
- 用 addWaiter(Node.SHARED) 把当前线程包装成共享节点,加入队尾。
- 进入自旋:如果前驱是头节点,再试一次 tryAcquireShared。
- 获取成功就 setHeadAndPropagate,把自己设为新头节点,并尝试唤醒后面的共享节点。
- 获取失败就 shouldParkAfterFailedAcquire 标记前驱状态,然后 parkAndCheckInterrupt 挂起线程。
线程被 park 就意味着它彻底让出 CPU,不会再空转消耗资源。这也是 AQS 的高明之处:先用 CAS 无锁抢,抢不到才阻塞,既保证了大多数场景的性能,又避免了无限自旋浪费 CPU。
4.5 release 全链路:还回许可并唤醒
release() 的调用链:
public void release() { sync.releaseShared(1); }AQS 的 releaseShared:
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { doReleaseShared(); return true; } return false; }Semaphore 的 tryReleaseShared:
protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next < current) { throw new Error("Maximum permit count exceeded"); } if (compareAndSetState(current, next)) { return true; } } }释放的逻辑比获取更简单:把 state 加回 releases 数量,CAS 成功就返回 true。有个细节值得注意——next < current 这个溢出保护。如果 state 加到 int 最大值后继续加,会变成负数,所以 AQS 直接抛 Error。日常业务基本碰不到这个边界,但面试官偶尔拿它考你,说“Semaphore 允许的动态超发是怎么防止溢出的”。
tryReleaseShared 返回 true 后,AQS 会执行 doReleaseShared,核心逻辑是:把等待队列头节点状态改成 0,然后 LockSupport.unpark 唤醒后继节点对应的线程。被唤醒的线程从 parkAndCheckInterrupt 处恢复,回到自旋循环里重新尝试获取许可证。
4.6 超时与中断版本的额外通道
Semaphore 还提供了 tryAcquire 的重载版本,支持传时间和时间单位:
semaphore.tryAcquire(2, TimeUnit.SECONDS)它走的是 acquireSharedInterruptibly 的带超时版本 tryAcquireSharedNanos。线程会在循环里用 deadline 判断是否超时,超时了直接返回 false,不会一直阻塞。这个 API 特别适合做优雅降级:等不到许可就放弃本次操作,而不是让请求无限挂起。
另一个通道是 acquireUninterruptibly,它不响应中断,即使线程被 interrupt 也会继续等待。这个 API 用得少,但面试里问“Semaphore 怎么处理中断”时,能说出 acquire 和 acquireUninterruptibly 的区别,又是一个加分点。
5. 面试追问连击:这些问题答不上来还是白搭
原理讲完了,面试官不会就此收手。下面是几道高频追问,我把回答思路和容易踩的坑一起整理出来。
5.1 被挂起的线程怎么知道有许可了?
这是 AQS 最核心的机制。线程在 doAcquireSharedInterruptibly 的自旋循环里 park 挂起。当 release 发生时,AQS 调用 unparkSuccessor 唤醒队列中第一个等待的线程。唤醒后线程从 LockSupport.park 返回,重新执行循环里的 tryAcquireShared。此时 state 已经被 release 加回去了,CAS 大概率成功,线程就拿到了许可。
关键点在于“循环 + 状态重读”这个设计。线程醒来不是直接获得许可,而是重新参与竞争,只是它已经在队列头了,优先级最高。这种设计避免了“唤醒后许可又被抢走”导致的不一致,也更符合非公平锁的整体语义。
5.2 Semaphore 和 CountDownLatch 有什么区别?
两者都基于 AQS,但语义完全不同:
- Semaphore:许可证可以循环使用,acquire 拿,release 还,强调的是“资源池的并发上限”。
- CountDownLatch:计数只能减不能增,倒数到 0 后闸门永久打开,强调的是“等所有线程完成一次协作”。
用一个场景区分:Semaphore 像停车场,车走了车位还能复用;CountDownLatch 像考试铃声,响一次就没了,之后所有等待的人都放行。面试官问这个,其实是想确认你是否理解 AQS state 的语义由子类自定义。
5.3 为什么 AQS 要用 CLH 队列而不是 synchronized 的 notify?
synchronized 的 wait/notify 有两个问题:一是没有公平性保证,二是唤醒是随机的、无法精确控制唤醒哪个线程。AQS 的 CLH 队列是显式的 FIFO 双向链表,可以精确知道谁排在前面,按顺序唤醒,还支持共享模式(Semaphore、CountDownLatch)和独占模式(ReentrantLock)的灵活扩展。
另外,CLH 队列用 volatile 状态和自旋检查前驱的方式,避免了大量线程同时被唤醒导致的惊群效应。Semaphore 释放一个许可只唤醒一个后继线程,而不是一次性把所有人都叫醒,这样既省资源,也不至于让被唤醒的线程又因为抢不到而重新挂起。
5.4 负数 permits 会发生什么?
直接 new Semaphore(-1) 会抛 IllegalArgumentException。原因在 Sync 构造里的检查:
Sync(int permits) { setState(permits); // 实际还有 permits < 0 的校验 }负数许可证本身没有意义。如果你真的需要“先 release 后 acquire”的逆向流程,用动态释放也能模拟,但这是极其边缘的用法,不建议在业务里这么搞。面试里提到这个,说明你连构造器边界都研究过。
5.5 常见错误速查表
| 错误姿势 | 后果 | 正确做法 |
|---|---|---|
| acquire 后 finally 里忘写 release | 许可证泄漏,最终所有线程阻塞 | 一定在 finally 中 release |
| 把 Semaphore 当 QPS 限流器 | 并发度限制不等于速率限制,误判容量 | 速率限流用 RateLimiter / Sentinel |
| 非公平模式下期待先来先得 | 线程可能插队,执行顺序不可控 | 需要顺序保证时构造 fair=true |
| release 次数多于 acquire 次数 | 许可证越积越多,限流失效 | 每次 acquire 严格对应一次 release |
| acquire 无限阻塞 | 高并发下大量线程挂死 | 用 tryAcquire 带超时做降级 |
6. 实战经验:我把 Semaphore 用在哪里,又踩过哪些坑
原理讲完,聊聊真实项目里的落地。我参与过的一个网关服务,需要限制调用第三方风控接口的并发数。对方文档明确说“单实例并发不能超过 10”,常规做法就是 Semaphore(10),在调用前 acquire,调用结束 release。这个场景特别适合 Semaphore,因为资源方给的限制就是并发度而不是速率。
实际写的时候我踩过一次很深的坑。早期代码长这样:
semaphore.acquire(); try { callThirdParty(); } finally { semaphore.release(); }问题不大。但有一次重构,有人把 acquire 挪到了 try 里面,而且 acquire 之前还有个远程配置读取逻辑。结果配置读取抛异常时,acquire 还没执行,finally 里的 release 却执行了,许可证越还越多,限流阈值从 10 悄悄变成了 100。那段时间第三方接口被打到限流,排查了很久才发现是这种不对称的 acquire/release 导致的。
所以我现在写 Semaphore 有一个习惯:acquire 必须放在 try 之前,release 必须放在 finally 最前面,中间任何逻辑抛异常都不会影响许可证的配对。这个规则看起来笨,但能防住大多数事故。
另一个经验是用 tryAcquire 做优雅降级。对于非核心链路,比如用户头像的临时打标,抢不到许可就直接降级走缓存,而不是让请求排队等着:
if (!semaphore.tryAcquire(200, TimeUnit.MILLISECONDS)) { return fallbackResult(); } try { doHeavyWork(); } finally { semaphore.release(); }200 毫秒等不到就直接放弃,用户体验损失很小,系统却避免了线程堆积。这种写法非常适合高并发下的旁路任务。
至于 Semaphore 和 Sentinel 这类框架的选型,我的看法是:Semaphore 是 JUC 自带的轻量级并发闸门,零依赖、语义清晰,适合单机资源限制;Sentinel 更适合分布式场景的 QPS 限流、熔断降级、动态规则推送。两者不是替代关系,Semaphore 管“能不能进”,Sentinel 管“进多少、要不要切断”。小项目用 Semaphore 就够了,大项目往往需要两者配合。
最后再分享一个容易被忽略的细节:Semaphore 支持动态调整许可证数量。通过反射或者自定义子类调用 setState,可以热更新许可证上限。比如业务促销期间想把并发上限从 10 调到 30,不需要重启服务,直接在管理接口里改 state 就行。但千万注意别和正在进行的 acquire/release 打架,最好在低峰期操作,而且只增不减,否则已经持有许可证的线程可能被迫超发。
从面试角度讲,能说到这种深度,已经远超“背 API”的层面了。面试官想听到的,从来不是标准答案,而是你对并发工具背后机制的理解和踩坑后的反思。Semaphore 这套 AQS + CAS 的组合,本质上就是 Java 并发世界里最典型的“无锁优先、阻塞兜底”思想。把它吃透,ReentrantLock、CountDownLatch 这些同类工具,你也能一眼看穿它们的底层逻辑。