Semaphore 给我的感觉,就是一个放在方法入口处的通道闸机——你提前设置好“同时能放进去多少人”,放进去之后,后面的人就得在门外等着,等里面的人出来一个,再放一个进去。这玩意儿在 Java 并发编程里已经存在了二十多年,可靠、轻量、用途极广,但很多 Java 开发并没有真正把它用明白,要么当摆设写了几行没人调用的代码,要么就因为一个参数错误在生产环境放了烟花。
这一篇就是围绕它写的实战笔记。我会从信号量的底层原理拆起,把acquire、release、tryAcquire这些方法的差异性讲透,然后通过几个直接可以搬走的案例(接口限流、数据库连接限制、全局限流改进型),带你把“流量阀门”这四个字从口号变成真正能扛压的代码。适合所有写 Java 的开发者,尤其是手里管着线上接口、天天担心被突发流量打爆的同学。
1. 内容整体设计与思路拆解
1.1 为什么“锁”解决不了所有并发问题
很多人的第一反应是:并发太猛?加锁啊。文件写入加锁、共享变量加锁、数据库更新加锁,锁确实能保护共享数据的正确性,但它的“串行化”思想在限流场景下是灾难。
想象你有一个下单接口,底层依赖数据库连接池(假设最多 10 个连接)。你用 ReentrantLock 把整个下单逻辑锁住,同一时刻只有一个线程能进数据库,其他 999 个线程全部阻塞等待。这样确实保证了不超连接数,但代价是吞吐量几乎成了 1,系统的响应时间表惨不忍睹。
锁解决的是“同一时刻只有一个线程访问某资源”,而信号量解决的是“同一时刻只允许 N 个线程访问一组资源”。这两者最大的区别在于“许可数”:锁的许可数是 1,信号量的许可数是 N。当你面对的是一个共享资源池、一组可复用连接、一段需要限流的业务逻辑时,信号量的建模更加自然,也不会把系统的并发能力压到 1。
这个思路我在多个项目里验证过:接口 QPS 高并发的场景,用 Semaphore 做限流,配合合理的超时时间和阈值,系统稳定性提升非常明显;而如果当时我用一把大锁去锁整个处理链路,可能并发一上来就直接雪崩了。
1.2 流量阀门的“N 许可”模型
信号量的设计哲学可以理解成一个停车场。停车场有固定的车位数量 N,车到了先看车位,有空位就进去,没空位就在门口排队,有车离开便腾出一个空位,排在后面的车就能进来。这就是Semaphore的核心逻辑:维护一个计数器,它不是计数“已经进入的线程数”,而是“剩余的许可数”。
初始化的时候,你通过构造方法指定最大许可数;线程要执行临界区代码之前先acquire()领取一个许可;执行完之后必须release()归还许可。这样就能保证同一时刻最多只有 N 个线程在临界区里跑。
这个“N 许可”模型的好处在于:它关注的不是“谁拿到了锁”,而是“还有多少个空位”。业务层不需要关心线程调度细节,只需要设定好阀门的最大容量,至于排队顺序、唤醒方式,这些都是 Semaphore 内部通过 AQS(AbstractQueuedSynchronizer)机制完成的。
如果你把数据库连接池想像成车位,把并发请求想像成来停车的司机,Semaphore 就是那个尽职尽责的保安,永远只放 N 辆车进去,永远不会多做一步。
1.3 方案选型时的核心考量:公平与非公平
使用 Semaphore 之前,你一定会遇到一个问题:要不要开启fair模式?这直接决定了排队线程的唤醒策略,并且会影响吞吐量。
非公平模式下,后到的线程只要发现还有剩余许可,就可以直接“插队”进入临界区,根本不需要排队。优势是吞吐量高,坏处是极端情况下,队列里长时间等待的线程可能饿死,始终没机会执行。公平模式会把所有 acquire 请求放进队列,先来后到,保证每个线程最终都会被执行,但吞吐量会有所下降,因为唤醒和上下文切换更频繁。
我的建议是:如果你拿它做控制资源的访问上限(比如连接池),开公平模式,因为资源应该按先来后到分配,这才是合理业务;但如果你只是做流量的粗略控制,允许某些请求稍微“插队”也不会带来副作用,那就用非公平模式,获取更高的吞吐。
2. 核心细节解析与实操要点
2.1 Semaphore 核心方法讲透:acquire 与 release
这组方法是你最常接触的,但很多人只知道个大概。acquire()获取一个许可,如果当前没有可用许可,当前线程会阻塞,直到有线程释放许可。它响应中断,也就是说在阻塞期间如果收到中断信号,会抛出InterruptedException。
acquire(int permits)和上面的区别是:一次性获取多个许可。比如你需要同时占用 3 个连接才能完成业务,就可以一次 acquire 3 个。但这里有个特别容易犯错的地方:如果你一次获取多个许可,那么 release 时也必须释放同样数量的许可,否则信号量的计数会出现漂移,久而久之就废了。
那release()呢?它释放一个许可,并且把信号量内部的许可数加 1。释放操作不要求必须由acquire()的线程来调用,你可以让另一个线程释放许可(但强烈不建议这么干,会让代码可读性很差)。释放许可的时候如果内部计数器已经在最大值了,它不会让数值继续增加,最大就是你初始化的那个数。
我踩过一个坑:有一次用信号量控制 Redis 连接,获取的时候用acquire(),释放的时候却用了acquire(2)对应的release(2),结果许可数出现过负数,新请求永远拿不到许可,整个接口直接卡死。从那以后我在任何项目中都会在 release 方法周围加注释,标注“此处必需与 acquire 成对使用”。
2.2 tryAcquire:限流器里最重要的方法
tryAcquire()可以说是 Semaphore 所有方法里最适合拿来写限流逻辑的,因为它不会无限期阻塞:尝试获取许可,能拿到就返回 true,拿不到就立即返回 false,线程不用排队等。
这非常符合限流的业务模型:并发超了,我不让你等,我直接告诉你“当前系统繁忙,请稍后再试”。如果全部用阻塞式 acquire,当流量是阀值的 10 倍时,后面的几千个请求全部阻塞在 Semaphore 上,线程资源被占满,新请求甚至进不来,这种“阻塞式等待”就变成了另类的雪崩点。
tryAcquire(long timeout, TimeUnit unit)更进一步,它允许你在一定时间内等待一个许可,超时再放弃。这个方法的真实使用场景是:某服务响应时间波动明显,如果 500ms 内没拿到许可,就走降级逻辑(比如返回兜底数据、走缓存)。
我在限流场景里的推荐配置是:核心接口用tryAcquire()做“快速失败”,业务需要容忍短暂等待的用tryAcquire(100, TimeUnit.MILLISECONDS)做“超时降级”,尽量避免无限等待的acquire()。
2.3 availablePermits 与 drainPermits:监控和熔断的好帮手
除了 acquire 和 release,Semaphore 还有两个你可能没太注意但很实用的方法:availablePermits()可以随时查看当前剩余许可数,drainPermits()则会把当前所有可用的许可一次性拿走。
这两个方法能派上什么用场?我常用availablePermits()做实时监控,把信号量的剩余量定期输出到日志或监控大盘,比如一旦发现剩余许可长期处于 0 或者稳定低于 10%,说明流量已经接近阀值,就该触发告警,提前扩容或者做限流策略调整。
drainPermits()适合做“优雅停机”或“熔断”的操作:比如你收到运维通知,要下线一个节点,此时可以调用 drainPermits() 把所有许可拿走,这样新请求直接兜底失败或者拒绝,而已经获取了许可的线程会正常执行完,实现流量逐渐归零的效果。
这两个方法不常被提及,但用好了,整个信号量的可观测性和运维友好性会提升一大截。
2.4 源码思维导图:Semaphore 的底层其实是 AQS
可能有人好奇,Semaphore 内部到底怎么维护这些许可数的。其实它就是基于 AQS(AbstractQueuedSynchronizer)实现的,内部有一个 Sync 类继承 AQS,通过 state 变量来记录许可证数量。
acquire()实际调用的是 AQS 的acquireSharedInterruptibly,核心动作是 CAS 操作尝试把 state 减 1,如果 state 小于 0 则表示许可不足,当前线程会被加入 AQS 的等待队列并挂起。而release()对应releaseShared,使用 CAS 把 state 加 1,然后唤醒等待队列中的线程。
这个设计是非常经典的无锁并发模型:在没有竞争的情况下,acquire 和 release 都只是对 state 做一次 CAS,性能极高;在有竞争的情况下,通过 AQS 队列来管理阻塞与唤醒。所以 Semaphore 在实际高并发场景下表现优秀,并且不会像 synchronized 一样频繁升级成重量级锁带来性能抖动。
3. 实操过程与核心环节实现
3.1 场景一:秒杀接口的全局限流实战
先来一个最常见的场景:秒杀接口被脚本疯狂刷量,导致后端数据库压力巨大。直接用 Semaphore 做全局限流,把并发访问量控制在合理范围。
public class SeckillService { private static final Semaphore SEMAPHORE = new Semaphore(100, true); public Result doSeckill(Long userId, Long productId) { // 尝试获取许可,拿不到直接快速失败,避免线程堆积 if (!SEMAPHORE.tryAcquire()) { return Result.busy("当前参与人数过多,请稍后重试"); } try { // 核心业务逻辑:校验库存、秒杀下单 return doSeckillInternal(userId, productId); } finally { // 释放许可,必须放在 finally 里,异常也要释放 SEMAPHORE.release(); } } }这个代码你可以直接抄作业。注意几个细节:设置初始许可数为 100,代表同时最多 100 个请求进入核心逻辑;开公平模式,让先来的请求优先处理;tryAcquire 快速失败;release 放 finally,保证无论正常还是异常,许可都能归还。
还有一个隐藏细节:很多人奇怪为什么不用线程池限流。线程池的maximumPoolSize确实能限制线程数,但它面对的是“并发执行的任务数”。如果你接口里大量时间都耗在 IO 等待上(比如调用外部服务),线程池线程空闲但被占用,你再想通过线程池限流其实限不住。Semaphore 是纯计数机制,不关心线程状态,反而可以精确限制“正在执行业务的请求数”。
3.2 场景二:数据库连接池的手动实现
如果说上面那个场景是限流,那这里就是纯资源控制。Java 项目里你用 HikariCP 或者 Druid,它们的连接池内部其实就用了 Semaphore 类似的原理。这里我自己动手实现了个轻量版,帮助理解。
public class SimpleConnectionPool { private final Semaphore semaphore; private final LinkedList<Connection> pool = new LinkedList<>(); public SimpleConnectionPool(int poolSize) { semaphore = new Semaphore(poolSize, true); for (int i = 0; i < poolSize; i++) { pool.addLast(createConnection()); } } public Connection getConnection() { // 先获取许可,保证不会超过 poolSize 个线程同时取连接 try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("获取连接被中断", e); } synchronized (pool) { return pool.removeFirst(); } } public void returnConnection(Connection conn) { synchronized (pool) { pool.addLast(conn); } // 归还许可,唤醒等待线程 semaphore.release(); } }这里有几个容易忽略的点:获取时先 acquire 再取资源,顺序不能反。如果先取资源再 acquire,可能在多线程下取出资源数超过连接池容量。归还时先归还资源再 release,顺序正相反,是先放回资源再释放许可,这样才能保证“有资源可拿”的情况下才唤醒等待线程。连接池与信号量组合使用,资源控制就变得非常优雅。
但要注意:这个手写版本没有处理连接失效问题,真实项目建议直接用成熟连接池组件,不要用自己的轮子替代,这里只是帮你理解底层原理。
3.3 场景三:限流之外,信号量做服务自我保护
有时候我们不仅要对接口限流,还要对某个下游依赖做熔断保护。比如你的服务调用了第三方短信接口,对方限制每秒最多 200 次。你用 Semaphore 把每次调用放进一个“许可门”,超过就快速失败,别让第三方接口被打爆。
public class SmsClient { private static final Semaphore SMS_LIMIT = new Semaphore(200, true); public SmsResult send(String phone, String content) { boolean acquired = false; try { acquired = SMS_LIMIT.tryAcquire(50, TimeUnit.MILLISECONDS); if (!acquired) { // 拿不到许可说明并发较高,快速降级:走异步 MQ 重试 return SmsResult.degraded(); } return doSend(phone, content); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return SmsResult.fail("发送被中断"); } finally { if (acquired) { SMS_LIMIT.release(); } } } }这个场景里有个非常关键的细节:tryAcquire 返回 true 之后,必须记住acquired = true状态,finally 里只有拿到许可才 release。我见过一些同事在 finally 里无条件调用 release,结果明明没有 acquire 成功,还是把许可给增回去了,导致信号量的许可越来越多,限流形同虚设。这种问题排查起来特别隐蔽,得留意。
同样,等待时间的设置最好预估一下下游的响应时间。如果短信接口平均耗时 20ms,那 50ms 的等待时间就意味着 2 到 3 个请求并发等待,降级率不会太高;如果你设置 500ms 等待,那在峰值时线程就会大规模阻塞,反而会占用你应用的线程资源。
3.4 场景四:多许可控制的经典案例
再补充一个需要一次获取多个许可的场景。比如你要做一个批处理任务,每次从任务队列里取 3 个文件,同时并行处理,限制总处理文件数不超过 100。这意味着信号量的“每个单位”不再是单个请求,而是“一个文件”。
public class FileBatchProcessor { private static final Semaphore FILE_LIMIT = new Semaphore(100, true); private static final int BATCH_SIZE = 3; public void processBatch(List<File> files) { try { FILE_LIMIT.acquire(BATCH_SIZE); files.forEach(this::processFile); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { FILE_LIMIT.release(BATCH_SIZE); } } }需要注意:acquire(BATCH_SIZE) 这行代码是“拿 3 个许可”,如果当前信号量只有 2 个可用许可,这个线程会阻塞等待,直到有足够的许可一次性被满足。这就有个潜在问题:如果许可数长期不足 3,比如大多数时候只有 2 个剩余许可,批处理线程可能一直等不到足够的许可而饿死。
解决思路有两个:第一,如果必须原子地获取多个许可,可以动态调整 BATCH_SIZE 大小,让每次获取的数量与剩余许可匹配;第二,把批处理改成单个文件逐个处理,不强制一次性拿 3 个许可。在真实业务里,如果批处理任务特别重要,我一般更倾向于“可拆分”的设计,而不是强制原子化获取多许可。
3.5 参数选择的计算思路
Semaphore 的初始许可数怎么定?这其实没有一个万能公式,但有一套非常实用的推算路径。
首先,你需要明确系统最重要的资源是什么。如果瓶颈在数据库,参考数据库最大连接数,再留出冗余。比如数据库 max_connections=200,你的应用有 4 个实例,那么每个实例的信号量上限 = (200 - 数据库管理的预留连接数) / 4,结果大概在 35~40。如果信号量设成 50,4 个实例同时打满就能占 200 个连接,一旦数据库自己有后台任务需要额外连接,就会爆。
其次,要考虑你的接口平均耗时和单机线程数量。举个例子,你的接口平均处理时间 100ms,单机有 200 个 Tomcat 线程,如果信号量上限设 200,那么每个线程都能进入临界区,等于没有限流。但如果接口平均耗时 500ms,信号量设 50,那么理论上最多同时处理 50 个请求,队列积压的请求会拒绝掉,线程处于空闲状态,响应能力没问题。
最后,压测永远是验证参数的最可靠手段。我会先设置一个保守值,比如单机 50,然后加大并发压测,观察 CPU、GC、RT 和错误率。如果所有指标都很健康,再把信号量提高到 80 继续压,直到找到临界拐点。生产上一般留 70%~80% 的余量,不要卡在极限值上。
4. 常见问题与排查技巧实录
4.1 信号量泄漏:release 没被调用的严重后果
这是最常见也是最严重的问题。如果业务代码抛异常时 release 没有被执行,许可数就会永久减少。假设初始许可 100 个,每异常一次少一个,到第 100 次异常后,信号量变成 0,所有请求都会阻塞或快速失败,服务相当于瘫痪。
排查这种问题的思路:先看监控,如果 availablePermits 持续下降,基本可以断定有线程没 release;然后检查所有 acquire 相关代码的 finally 块,确保 release 在 finally 中执行。此外,可以在 release 处打 WARN 日志做 trace,用日志关联请求 ID,快速定位是哪个逻辑抛异常导致未释放。
还有个注意点:tryAcquire返回 false 时不持有许可,此时不需要 release;返回 true 时必须保证最终 release 执行。我在前面的 SmsClient 示例里用了一个 boolean 标记,就是为了处理这个区分。
4.2 死锁:多信号量嵌套获取很容易出事
如果你在一个临界区内又去获取另一个信号量,就可能会死锁。比如线程 A 拿下了信号量 S1,正在等信号量 S2;线程 B 拿下了 S2,正在等 S1。两个线程互相等待,谁也无法继续。
解决思路:尽量减少嵌套获取;如果无法避免,保证所有线程都以相同的顺序获取多个信号量(比如先 S1 后 S2),这样不会出现死锁。另外也可以通过 tryAcquire 加上超时时间,让拿不到第二个信号量的线程主动放弃已有资源,直接打破循环等待。
4.3 信号量拿到多个许可,释放时数量不匹配
前面提到过,acquire(3) 和 release(3) 必须成对。如果你 acquire(3) 后,忘记实际拿了几个许可,finally 里写死 release(),只归还了一个,那计数器就会发生漂移。针对这个问题,可以在代码里用一个局部变量记录许可数量,finally 里释放时引用这个变量。
这里还有一个更隐蔽的坑:如果你的业务代码中途 catch 了异常并继续往下走,但许可已经在某次操作中被提前 release 了,后面 finally 又 release 一次,就会造成重复释放。重复释放本身不会报错,但会让许可数超过初始值,限流效果被稀释。Practically,你需要保证 acquire 与 release 一一对应,不要在多分支里重复 release。
4.4 与 CountDownLatch、CyclicBarrier 搞混
很多初学者会把 Semaphore、CountDownLatch、CyclicBarrier 三个并发工具搞混。这里直接用一段话区分:Semaphore 是“控制同时访问的线程数”,CountDownLatch 是“让多个线程等待某个计数归零”,CyclicBarrier 是“让多个线程互相等,到达同一个屏障点再一起放行”。三者使用场景完全不同,千万别用混。
比如你有个需求:主线程需要等待所有子线程完成任务,这是 CountDownLatch;你有 5 个任务要并发跑,每个任务完成前要等所有任务准备好,这是 CyclicBarrier;你要限制同一时刻最多 10 个任务在跑,这就是 Semaphore。各司其职,工具不存在谁替代谁的关系,你清晰地认清语义,代码就不会歪。
5. 信号量的另类玩法:优雅限流升级版
5.1 Semaphore 结合时间窗口做动态限流
纯靠 Semaphore 做静态限流有一个明显缺点:阈值是固定的。线上流量往往会随时间波动,比如白天流量大,晚上流量低。固定阈值可能白天设高了保护不住系统,晚上设低了浪费资源。这时候可以用 Semaphore + 定期刷新阈值的方案:每隔 1 分钟读取外部配置中心的值,动态 new 一个信号量替换旧信号量,旧的信号量不能直接丢弃,要等已持有的许可都释放完再回收。
public class DynamicLimiter { private volatile Semaphore semaphore = new Semaphore(100); public void updateLimit(int newLimit) { Semaphore oldSemaphore = semaphore; Semaphore newSemaphore = new Semaphore(newLimit); semaphore = newSemaphore; // 旧的 semaphore 等待全部许可释放后自然失效 for (int i = 0; i < newLimit; i++) { newSemaphore.release(); // 新信号量初始化时没有许可,这里补充 } } public boolean tryAcquire() { return semaphore.tryAcquire(); } public void release() { semaphore.release(); } }这个动态切换的思路挺实用,但实现时要特别注意:等待获取许可的线程可能还阻塞在旧信号量上,不能简单丢弃旧对象,要给一个缓冲期。比较稳妥的做法是:把旧信号量交给一个监控线程,等到 availablePermits 恢复到初始值再置为 null,让 GC 回收。
5.2 从单机限流走向分布式限流
很多人会问:既然项目是微服务架构,单机 Semaphore 够用吗?答案是不够。原因很简单:不同的服务实例内存各自独立,A 实例限流 100,B 实例限流 100,同一个用户完全可能调用到两台机器,合计可以达到 200。真实的全局限流必须依赖一个共享的计数器,而这就不是 Semaphore 能解决的问题了。
分布式限流的主流方案有几种:基于 Redis + Lua 脚本实现令牌桶或者滑动窗口;基于 ZooKeeper 或者 etcd 来做分布式协调;还有基于网关层限流组件(如 Sentinel、Hystrix)的方式。
那 Semaphore 在分布式场景里是不是就没有用了?不是的。它非常适合作“本地保护层”:分布式限流在上游拦截大头流量,但到了每个实例依然会有瞬间的流量尖刺,此时在每个实例内部用 Semaphore 做“二次闸门”,双保险。这也是我在生产环境的推荐组合:网关有总控限流,实例内部有本地信号量,两层叠加,比单独用任何一层都稳。
5.3 信号量与线程池的组合拳
信号量和线程池是互补的关系。线程池负责控制“有多少个任务在跑”,信号量可以负责控制“有多少个任务在执行敏感操作”。你可能会问:这不冲突吗?实际上它们是不同维度的限制。
举个例子:一个业务服务有 200 个线程可用,高峰期有一批任务要调用外部接口,外部接口只允许 30 个并发。如果你只靠线程池限制,200 个线程可能同时在调用外部接口,直接打爆对方。如果你只靠信号量,200 个线程虽然大部分会被信号量堵住,但它们依然占用着线程资源。两者结合:线程池提供基本的并发执行能力,信号量保护外部依赖不被冲垮。在真实的微服务治理中,这是非常常见的组合拳。
6. 避坑指南与独家经验总结
6.1 关于初始化许可数的“坑中坑”
我在第 3 节讲了参数计算思路,这里再补充一个容易被忽略的细节:Semaphore 的许可数一旦在构造时设定,除非调用 release 多次或者 drain,否则不会自己改变。如果你在配置中心调整了阈值,下发了新信号量之后,旧信号量如果还持有许可,应该想办法让它在业务低峰期自然释放,否则会出现新旧信号量并存期间,实际并发量变成两倍的问题。
比较成熟的做法是:动态更新时先创建一个新的 Semaphore,并设置成新阈值,然后利用一个缓冲区策略,把旧的 Semaphore 的 acquire 方法全部改成 tryAcquire,让旧线程不再长期阻塞,然后等它归零再销毁。这个过程看起来简单,实际操作时务必通过监控来全程观察,不可盲目上线。
6.2 代码细节:tryAcquire 拿不到许可到底该干什么
拿到不到许可的最终处理方式,很多人没想清楚,直接抛出异常或者返回一个固定的错误码,用户端体验很差。其实这里应该结合业务来决策。
如果是秒杀场景,“挤不进去”本身就符合预期,返回“已售罄”或“请稍后重试”即可;如果是查询类接口,可以降级到本地缓存或历史数据;如果是核心支付链路,则不建议直接 quick fail,而是应该设置一个较长的超时时间,比如 500ms,让请求有等待的余地。一般来说,写限流代码前,先想清楚“超出阈值的行为语义是什么”,比单纯加一个信号量重要得多。
6.3 信号量的可观测性建设
最后分享一个我在团队里强力推行的做法:所有 Semaphore 实例都要能够被监控看到。具体做法是,在创建信号量时,统一通过一个工厂方法创建,并注册到一个 Metrics 容器中,定时采集 availablePermits 和 queueLength 指标。
AQS 里本身有 getQueueLength() 方法,能查看有多少线程在等待许可。当这个值长期大于 0,且 availablePermits 长时间为 0,说明系统已经处于饱和状态,要么扩容,要么排查是否存在信号量泄漏。这套监控体系搭好之后,线上限流器的“健康度”一目了然,不会再等到用户投诉才发现系统已经瘫痪了半天。
我个人在实际操作中还有一个体会:信号量最好用有意义的命名包裹一层。比如封装一个 RateLimiterHolder 类,里面包含 Semaphore 实例、名字、阈值、指标采集器等字段,统一对外提供 tryAcquire 和 release 方法。这样你部署了十个限流点之后,还能分清哪个是数据库保护、哪个是短信网关保护、哪个是秒杀接口限制,排查问题时才不会一脸懵。这套方法我用了几年,每次团队新人上手也能很快定位到问题。