Semaphore 是许可控制而非限流,因其仅限制并发线程数、不感知时间窗口与请求速率,无法实现QPS限流。

为什么 Semaphore 不是“限流”而是“许可控制”
很多人一看到 Semaphore 就默认它是“接口限流工具”,结果在高并发下发现 QPS 没压住、甚至线程卡死——根本原因在于:它不感知请求速率、不自动释放许可、也不管你调用的是哪个接口。它只是对「同时能进入某段代码的线程数」做硬性计数,本质是资源池的守门员,不是流量警察。
典型误用场景:tryAcquire() 后忘记 release();或在异步回调里释放,但回调可能永不触发;又或者把 Semaphore 当成全局 QPS 限流器,却没配合定时器重置许可数。
- 它不记录时间窗口,
acquire()成功只代表此刻有空闲许可,不代表过去1秒内只放行了N个请求 -
permits是静态上限,不能动态调整(除非手动release()/acquire()干预) - 若用在 Web 请求中,必须确保每个请求对应一次
acquire()+ 一次release(),且都在同一线程或明确传递许可所有权
Semaphore 的正确释放姿势:别信 finally,要信作用域
写 try-finally 看似稳妥,但遇到异步、CompletableFuture、或被中断的线程,finally 可能不执行,许可就永久泄漏。更可靠的方式是把许可绑定到明确的作用域生命周期上。
比如在 Spring WebMvc 中,可以用 @Async 方法包装逻辑,并在方法入口 acquire(),出口 release();但更推荐用装饰器模式封装:构造一个 PermitGuard 对象,在其 close() 方法里 release(),再用 try-with-resources 保证释放。
立即学习“Java免费学习笔记(深入)”;
- 避免在
Runnable或Callable内部直接acquire()后交由线程池执行——线程池可能复用线程,导致许可错配 - 不要在
Future.get()阻塞后才release(),阻塞本身会拖慢整体吞吐,许可应尽早归还 -
tryAcquire(long timeout, TimeUnit)要设合理超时(如 100ms),否则排队线程可能挂太久,引发雪崩
和 RateLimiter(Guava)混用时的常见冲突
有人想“双保险”:前面用 RateLimiter 控制每秒请求数,后面用 Semaphore 控制并发连接数。结果发现 RateLimiter 没生效,或者 Semaphore 频繁拒绝——因为两者粒度不同,且没有协同机制。
RateLimiter 是平滑令牌桶,允许突发;Semaphore 是严格计数,突发直接排队或失败。如果 RateLimiter 放行了 5 个请求,而 Semaphore 只有 2 个许可,那 3 个线程就会在 acquire() 上阻塞,造成响应延迟升高,监控上看像是限流失效。
- 若真需组合,建议用
RateLimiter做前置粗筛(如每秒最多 100 次调用),Semaphore做后端资源保护(如最多 5 个 DB 连接),两者作用域要隔离清楚 - 不要对同一请求链路连续套两层限流,尤其当后端是异步 I/O(如 Netty、WebClient)时,
Semaphore守住的可能是线程数,而非真实资源消耗 -
RateLimiter的acquire()会休眠,Semaphore的acquire()会阻塞线程——混用时注意线程模型是否兼容(比如在 EventLoop 线程里调用会阻塞整个 loop)
性能敏感场景下 Semaphore 的隐形开销
多数人以为 Semaphore 是轻量级锁,实际在高争用(大量线程抢同一个 Semaphore)时,AQS 队列的入队/唤醒成本不可忽略,实测比 ReentrantLock 高约 15–20%。更麻烦的是,它不提供公平性以外的调度策略,所有等待线程按 FIFO 排队,无法优先处理低延迟请求。
- 非必要不用
new Semaphore(n, true)(公平模式),它会让每次acquire()多一次 volatile 写,吞吐下降明显 - 如果只是保护单个共享对象(如一个缓存加载器),考虑用
AtomicBoolean.compareAndSet()+ 自旋替代,避免 AQS 开销 - 监控时别只看
getQueueLength(),还要结合hasQueuedThreads()和 GC 日志——长时间排队容易引发线程堆积和内存泄漏
真正难的不是写对 acquire() 和 release(),而是判断该不该用它。当需求里出现“每秒最多”“滑动窗口”“按用户限流”“失败快速降级”时,Semaphore 往往不是第一选择。











