首页 / 资讯中心 / 文章详情

Java信号量Semaphore实战:从流量阀门到并发预算管理

Java信号量Semaphore实战:从流量阀门到并发预算管理 ★ FEATURED ARTICLE
多线程安全这块前几篇咱们一直在聊锁锁的核心是互斥。但真实系统里还有一个更常见的诉求不是只允许一个线程进来而是最多只允许 N 个线程同时进入——这就是流量阀门。Java 里对应的工具是信号量 Semaphore一个携带许可证的同步器也是本系列第十期的主题。如果你只在面试题里背过Semaphore的 API那可能感觉它不过就是个带计数器的锁。真正把它当作系统流量阀门来用你会发现它的复杂程度被很多人严重低估了。限流这个词在业务场景里被说滥了但大多数人第一反应是线程池、队列、Redis 令牌桶很少有人想到 JUC 里这个最原生的计数同步器。这篇文章我把实际项目里的用法、坑和面试会追问的底层逻辑一起讲清楚你可以直接抄也能拿来当复习提纲。1. 锁只能守门Semaphore 管的是车道数——先弄清这个差异1.1 只有互斥还不够系统需要的是容量控制锁解决的核心问题是一段临界区同时只能有一个线程进入。它像一扇只能进一个人的门谁拿到钥匙谁进去出来再把钥匙交回。这在修改共享变量、写数据库、更新缓存时确实够用。但现实里更多场景是允许若干个线程同时干活但不许超过某个数。拿数据库连接池举例一个连接池配置了 10 个连接理论上最多支持 10 个业务查询并发执行。这时候你用synchronized或者ReentrantLock去锁那就变成所有查询排队一个一个跑连接池里剩下 9 个连接全在闲着。反过来如果不加控制恰好在高峰期有 40 个线程同时来拿连接等待和线程切换的成本又会把系统拖垮。Semaphore 的核心模型是许可证创建时给一个初始数量比如 10。线程进入临界区前先acquire()拿一张许可证拿不到就阻塞等待干完活release()把许可证还回去。它管的不是这段代码谁能进来而是同时能有多少辆车通过这个收费站。锁管的是互斥信号量管的是容量。这两个概念分不清楚后面设计高并发系统就容易抓瞎。1.2 最小可运行 Demo两个许可证怎么卡住八个线程先看一段最简单、能直接跑起来的代码把感觉找到import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; public class SemaphoreGateDemo { public static void main(String[] args) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(8); Semaphore gate new Semaphore(2); // 同一时刻最多放行 2 个任务 for (int taskId 0; taskId 8; taskId) { final int id taskId; pool.submit(() - { try { gate.acquire(); System.out.printf(任务 %d 进入临界区剩余许可%d%n, id, gate.availablePermits()); Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { gate.release(); } }); } pool.shutdown(); } }线程池创建了 8 个线程8 个任务几乎同时被提交。但因为 Semaphore 初始只有 2 个许可证所以同一时刻只有两个任务能进入临界区打印日志其余六个任务都在acquire()处排队等待。前面两个任务睡 1 秒后释放许可后面才会有人补位进来。这段代码短但已经包含了信号量最核心的使用范式acquire 必须在 try 之前release 必须在 finally 里。这个范式我会反复强调因为它是生产环境出问题最多的位置。2. 动手前必须吃透的语义acquire 在哪阻塞release 和锁的释放有什么不同2.1 方法速览一张表盘点 Semaphore 的全部常用 APISemaphore 的方法不多但每个方法都有明确的使用场景先整体过一遍方法行为典型使用场景acquire()拿 1 个许可证拿不到就阻塞响应中断严格准入排队也要等acquire(int n)一次性拿 n 个许可证不够则阻塞批量资源占用如一个大任务占多个连接tryAcquire()非阻塞有许可立刻拿没有立刻返回 false快速失败、优雅降级tryAcquire(timeout, unit)在指定时间内等待许可超过时间返回 false接口限流设置可接受的等待上限release()归还 1 个许可证finally 中必须执行availablePermits()查看当前剩余许可数监控、日志、告警drainPermits()把剩余许可全部拿走测试或重置场景isFair()判断是否公平模式排查问题做确认最常用的组合是tryAcquire(timeout, unit)配合release()既能控制并发上限又不会让调用方无限阻塞下去。后面代码里我会频繁用这个组合。2.2 release 不需要持有者这回事既是便利也是隐患ReentrantLock有一个严格约束谁加锁谁释放。但 Semaphore 完全不是这个逻辑它没有所有权概念。线程 A 可以acquire()线程 B 来release()这在 API 层面完全合法。因为信号量内部维护的是一个整数状态AQS 的 state它只关心当前还有几个许可不关心谁借走了。这个特性带来的最大问题是你很容易在代码里写错位置导致许可证漂移。举个最简单的例子线程 A 拿了许可业务处理中抛异常线程 B 在某个清理逻辑里顺手调了release()看起来把许可还回去了但实际上 A 和 B 之间的业务逻辑可能根本不配对。更常见的是同一线程里多调了一次release()许可数悄悄变多流量阀门被撑大。我建议在团队规范里强制一条Semaphore 的 acquire 和 release 必须写在同一段 try-finally 代码块中不跨方法、不跨线程。除非是极少数刻意设计的场景否则跨线程释放就是埋雷。2.3 阻塞等待的最小单位线程在 AQS 队列里排队调用acquire()时如果许可证已用完当前线程不会自旋空转而是进入 AQS 的同步队列休眠等待前驱节点释放许可后通过unpark唤醒。这个机制和ReentrantLock获取锁的等待队列本质上是一套东西。所以信号量等待期间 CPU 占用接近零不会造成无谓的忙等。知道这点很重要因为有人误以为 Semaphore 和自旋锁一样会消耗 CPU实际不是。代价是线程休眠唤醒有内核态切换开销所以如果临界区执行时间只有几微秒信号量可能比原子变量慢如果临界区是网络 IO、数据库查询这类毫秒级操作这些开销可以忽略。3. 实战场景一接口层的流量阀门——把并发高峰挡在业务代码外面3.1 信号量限的是并发数不是 QPS这里必须先打破一个常见误解Semaphore 限的是同时执行的线程/请求数量不是每秒能通过多少个请求。两者有关系但不能直接等同。假设一个接口单次处理平均耗时 200ms你想把接口 QPS 限制在 500 以内同时并发数应该设置多少简单估算最大并发数 ≈ 目标QPS × 单次耗时 500 × 0.2 100也就是说约 100 个并发能支撑 500 QPS。如果你把 Semaphore 的许可数设为 20那么接口 QPS 理论上会被卡在 100 左右20 / 0.2。但如果接口耗时波动很大比如极端耗时变成 1 秒那么 20 并发下 QPS 就只有 20。所以信号量是并发数阀门不是速率阀门。需要严格按时间窗口控速时应该考虑 Guava RateLimiter 或 Redis 令牌桶它们是另一类工具。但在多数后端接口场景里真正需要保护的恰好是同时进到数据库、同时打到下游的请求数这时候 Semaphore 反而比 QPS 限流更本质。3.2 一份可直接复用的接口准入控制封装下面这段代码是我实际项目里用过的一个简化版本把 Semaphore 包成一个模板组件import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; import java.util.concurrent.Callable; public class TrafficGuard { private final Semaphore semaphore; public TrafficGuard(int maxConcurrent, boolean fair) { this.semaphore new Semaphore(maxConcurrent, fair); } public T T tryExecute(CallableT action) throws Exception { // 等待 300ms拿不到许可就快速失败 if (!semaphore.tryAcquire(300, TimeUnit.MILLISECONDS)) { throw new RejectedExecutionException(系统繁忙请稍后重试); } try { return action.call(); } finally { semaphore.release(); } } }在 Spring Boot 里把它注册成一个 Bean接口层直接注入使用RestController public class OrderController { private final TrafficGuard orderQueryGuard; public OrderController(TrafficGuard orderQueryGuard) { this.orderQueryGuard orderQueryGuard; } GetMapping(/order/{orderId}) public OrderVO queryOrder(PathVariable String orderId) throws Exception { return orderQueryGuard.tryExecute(() - orderService.getOrder(orderId)); } }这里的300ms不是随便拍的。它必须小于前端或上游网关的整体超时时间。如果上游允许 2 秒你在这里等 300ms 就拒绝用户能收到友好提示如果你傻傻地acquire()无限等线程就一直挂在信号量上等它被唤醒时上游可能早就超时断开了你还白白占着一个线程。3.3 单机信号量的边界分布式限流别指望它必须说清楚本地 Semaphore 只对本进程生效。服务部署了 3 个节点每个节点设了 20 并发那么整个服务最大并发是 60不是 20。如果业务要求全局只能 20本地 Semaphore 做不到需要引入 Redis 分布式限流、中间件配额等手段。但分布式限流方案一般都有网络开销所以更合理的做法是双层配合分布式层控制全局总量本地 Semaphore 控制单个节点不被突发流量击穿。比如 Redis 令牌桶给每个节点分配每分钟 5000 配额节点内部再用 Semaphore 限制同时只有 30 个请求在跑。这样既保证全局可控又避免极端情况下单节点被瞬时冲垮。我经历过几次分布式限流太慢导致接口毛刺的问题用本地 Semaphore 挡住瞬时并发后效果立竿见影。4. 实战场景二跨资源配额控制——给线程池外面再加一道阀门4.1 为什么线程池有自己的上限还需要信号量很多同学会问我线程池最大线程数已经设了 50池本身不就是天然限流器吗还需要 Semaphore 干嘛问题在于一个业务请求往往跨越多个资源每个资源的承载能力不一样。线程池允许 50 个任务同时执行但数据库连接池可能只有 20 个连接。任务进入线程池后如果有 30 个任务都去抢数据库连接会有 30 个线程阻塞在getConnection()上。它们占着线程池的线程干不了活后面还排着新任务线程池很快被占满最终表现为连接池没爆线程池先爆了。更隐蔽的是跨调下游接口的场景。线程池 50某个下游服务只能容忍 10 个并发调用。如果你依赖连接池超时或者下游限流那下游服务可能把你的一堆请求直接拒绝引发连锁重试雪上加霜。Semaphore 在这里的作用是在进入线程池之后、访问资源之前先做一次配额检查把并发数量控制在资源能承受的范围内。4.2 数据库查询准入控制器完整实现我写过一个简化版数据库查询控制器用于限制某个高频查询同时对数据库发起的连接数import javax.sql.DataSource; import java.sql.Connection; import java.sql.SQLException; import java.util.concurrent.Semaphore; public class DbQueryGuard { private final Semaphore semaphore; private final DataSource dataSource; public DbQueryGuard(int maxConcurrentQueries, DataSource dataSource) { this.semaphore new Semaphore(maxConcurrentQueries, true); this.dataSource dataSource; } public T T execute(ConnectionCallbackT callback) throws SQLException { try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new SQLException(等待数据库配额时被中断, e); } try (Connection connection dataSource.getConnection()) { return callback.doInConnection(connection); } finally { semaphore.release(); } } } FunctionalInterface interface ConnectionCallbackT { T doInConnection(Connection connection) throws SQLException; }使用方式ListOrderDO result dbQueryGuard.execute(conn - { try (PreparedStatement ps conn.prepareStatement(SQL)) { // 业务 SQL 执行 return query(ps); } });这个控制器的关键在于maxConcurrentQueries的设置要比连接池 size 小一点留下一部分连接给其他非受控 SQL 使用。假如连接池是 20你可以给这个高频查询的 Semaphore 设成 12剩下 8 个连接给管理后台、定时任务等其他查询用。这样信号量就变成了资源配额分配器而不再只是一个简单的限流开关。4.3 多把信号量组合模拟全局并发预算当业务链路很长时可以用多把 Semaphore 组成预算链。比如一个任务要查数据库、调远程服务、再写消息队列三个环节分别设置信号量DB 并发 12、远程调用并发 8、MQ 写入并发 5。任务进入时先检查第一把信号量通过后再检查第二把。这种方式虽然代码会长一点但每个瓶颈点都被精确保护了不会出现数据库没事下游被打爆的失衡情况。组合使用时最需要注意的是拿多把锁的顺序问题。如果任务 A 先拿 DB 信号量再拿远程信号量任务 B 先拿远程信号量再拿 DB 信号量理论上就可能出现“拿着 A 等 B拿着 B 等 A”的死循环等待。所以必须定死顺序所有任务都先 DB 后远程避免交叉等待。在真实项目里交叉获取多个 Semaphore 导致的死锁比想象中更容易发生排查起来很痛苦。5. 生产环境最容易踩的坑许可证泄漏、中断处理和几个隐藏细节5.1 许可证泄漏流量阀门被一点点腐蚀先看最容易犯的错误semaphore.acquire(); if (order null) { return null; // 忘记 release } try { doBiz(); } finally { semaphore.release(); }如果order null这个分支被执行前面的许可证没还回去这个许可证就永久丢了。一个两个还好高峰期多丢几个能用的许可证越来越少接口越来越慢最后看起来像是线程池饱和实际是信号量被饿死了。这种问题很难从监控上第一时间发现因为系统不会报错只是并发能力逐渐下降。防护方法就一条把 acquire 放在 try 块外面紧接着用 finally 包住全部业务代码。上面的代码应该改成semaphore.acquire(); try { if (order null) { return null; } doBiz(); } finally { semaphore.release(); }另一个方向的问题是多释放。某段排查代码里对同一个 Semaphore 调了两次release()许可证数量从 2 变成 3等于悄悄把上限调大了。因为release()不像锁一样校验收单人所以这种错误编译器不会提示排查难度极高。线上如果发现availablePermits()大于初始值基本可以断定有地方多释放了。5.2 中断处理和超时选择不让线程无限挂起acquire()和tryAcquire()都会抛出InterruptedException。处理时最忌讳两大误区一是 catch 住后什么都不做二是用e.printStackTrace()打条日志就完事。正确做法是恢复中断标志try { semaphore.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 把中断状态交还给上层 throw new BizException(等待资源时被中断); }为什么要Thread.currentThread().interrupt()因为中断标志是协作机制的一部分你吞掉它上层代码就不知道这个线程已经被要求停止。尤其在线程池场景里如果任务吞掉了中断标志线程池关闭时可能无法正常响应。tryAcquire(timeout, unit)更适合大多数业务接口因为它可以避免线程无限阻塞。但超时时间的取值要结合业务容忍度来定不能所有接口统一一个值。我见过一个项目把所有接口的等待时间都设成 5 秒结果数据库连接池已经满了请求还在信号量上排队等排到了连接也早超时了白白把上游超时时间耗尽。通常是上游超时的三分之一到二分之一给业务执行留够时间。5.3 drainPermits 和 availablePermits 的实际用途availablePermits()是查当前还剩多少许可适合做监控。比如定时任务每分钟检查一次如果剩余许可数长期接近 0说明并发压力大如果剩余许可数大于初始值说明有人多释放了。这两个信号都值得告警。drainPermits()一次性把所有剩余许可拿走实际开发中比较少见但在测试和服务复位场景里很好用。比如你要做一次流量切换先调用drainPermits()把许可清空让新的业务请求进不来等存量任务处理完再重新初始化信号量。它比逐个 acquire 高效得多也能避免在清理许可的过程中被新任务抢占。还需要留意一个很少有人提到的细节Semaphore 在构造时允许 permits 为 0 甚至是负数。new Semaphore(0)表示一开始就不放行任何线程必须等某个地方调用release()后才有许可。这个特性在某些任务已完成通知场景中有奇效但也很容易误用。构造参数为负数时acquire()会直接阻塞tryAcquire()永远返回 false如果你没意识到这点排查起问题来会很绕。6. 面试中如何优雅地讲出信号量底层从 P/V 操作到 AQS 到公平队列6.1 讲清历史原型P/V 操作是信号量的老祖宗Semaphore 的源头可以追溯到 Dijkstra 在 1965 年提出的信号量机制用于操作系统里的进程同步。P 操作尝试减少信号量如果结果为负就等待V 操作增加信号量并唤醒等待者。P 对应 Java 的acquire()V 对应release()。命名来源于荷兰语P 是 Proberen尝试V 是 Verhogen增加。这段历史在面试里可以作为引子。它能帮你把信号量是不是锁这个问题讲得很清楚信号量最初是为了解决多个进程对有限资源的协调而设计的锁只是其中一种特例许可数为 1。从语义上讲一个new Semaphore(1)确实能当互斥锁用但它没有锁的可重入性和所有权校验用错了会有额外的风险。6.2 AQS 层面的实现state 字段如何变成许可证池Semaphore 内部有一个基于 AbstractQueuedSynchronizerAQS的内部类 SyncAQS 的state字段保存的就是当前可用的许可证数量。获取许可时tryAcquireShared尝试把 state 减少释放许可时tryReleaseShared尝试把 state 增加。因为 AQS 保证了 state 的原子性更新所以 Semaphore 是线程安全的。简化后的核心逻辑可以理解成这样// 获取许可尝试把 state 减少 acquires 个 protected int tryAcquireShared(int acquires) { for (;;) { int available getState(); int remaining available - acquires; if (remaining 0 || compareAndSetState(available, remaining)) { return remaining; } } } // 释放许可尝试把 state 增加 releases 个 protected boolean tryReleaseShared(int releases) { for (;;) { int current getState(); int next current releases; if (compareAndSetState(current, next)) { return true; } } }这里用了 CAS 自旋来保证多个线程同时操作时不会出现丢许可证的问题。如果剩余许可数不够acquire返回负数当前线程不会立即获得许可而是进入 AQS 队列等待当其他线程release后队列头部的线程会被唤醒重新尝试。非公平模式下新来的线程会先尝试 CAS 抢一次抢不过才进队列这就是非公平和公平模式吞吐量差异的来源。6.3 公平模式的选择什么时候必须用 fairtrue创建信号量时可以指定是否公平Semaphore fair new Semaphore(10, true); // 公平模式 Semaphore unfair new Semaphore(10, false); // 非公平模式默认公平模式下AQS 会让等待时间最长的线程优先获得许可。它的好处是先来后到不会让某个线程永久等不到代价是每次都要检查队列稍慢一点。默认的非公平模式则允许新线程直接抢许可吞吐量更高但理论上存在等待线程饿死的情况。为什么默认是非公平因为大多数场景下流量阀门只要保证并发数量可控就行不要求严格的到达顺序而追求严格顺序往往反而导致最大的吞吐量下降。具体业务里什么时候必须公平比如秒杀场景中的每人限购一次如果请求先到先得公平模式能尽量减少后来者截胡的问题还有对特定用户按申请时间排队发放资源的场景插入用户插队会引发客诉。这类需要严格按到达顺序分配资源的就设fairtrue。除此之外我建议默认用非公平性能更好代码也更贴近常规限流需求。6.4 高频对比题Semaphore 和 CountDownLatch、CyclicBarrier 有什么不同面试里问并发工具类时三个计数器经常被放在一起比较同步器核心语义一次性还是可复用典型场景Semaphore资源容量控制acquire/release 循环使用可复用限流、配额控制、池化资源保护CountDownLatch等待 N 个事件完成后放行一个或多个线程一次性等待多个任务初始化完成、并行任务汇总CyclicBarrier让一组线程互相等待到齐后再同时继续可通过 reset 复用分阶段并行计算、多线程跑批同步一个容易混淆的点是new CountDownLatch(1)看起来像只有一个许可证的信号量但 CountDownLatch 不能往回调。latch 的计数只能减不能加减到 0 之后这个对象就废了Semaphore 的许可可以反复 acquire/release。可以把 CountDownLatch 理解为开幕式倒计时而 Semaphore 是停车场进出闸机。CyclicBarrier 则更像接力赛的起点线所有人到齐才同时开跑跑完还能重置再来一轮。面试官如果接着问Semaphore 能不能替代锁你可以回答基于单个许可的信号量在互斥层面和锁相似但锁提供可重入、超时获取、公平等待、条件变量等丰富能力还有所有权校验。实际工程里不建议用 Semaphore 替代锁它的语义重点始终是容量控制。6.5 追问获取多个许可证时要注意什么acquire(5)表示一次性必须同时获得 5 个许可证才会返回。这里有对这次成功保证的一点在讨论里常被忽略tryAcquire(5)抢占 5 个许可必须一次性成功只要有一个不够就完全不做。但普通的acquire(5)会把线程挂起等待 5 个许可同时凑齐。如果某个线程持有 3 个许可准备等另外 2 个而其他线程分别持有 1 个许可等待释放就有可能出现互相等待的许可证死锁。实际项目中碰见这种批量申请场景最好减少使用或者用 tryAcquire 加超时兜底。面试问到 AQS 时可以把state的含义作为切入点同一个 AQS 框架锁的 state 是重入次数Semaphore 的 state 是可用许可数CountDownLatch 的 state 是剩余计数。面试官会欣赏你这种一套框架不同语义的理解方式。结尾部分一点实际体会Semaphore 用久了我最深的感受是它不是一个QPS 控制器而是一个并发预算管理器。它和线程池、连接池、限流框架解决的是不同层级的问题。线程池管的是底层劳动力的数量连接池管的是底层连接资源Semaphore 则负责在业务入口处提前判断这个资源的并发预算还够不够不够就让请求先排队或者直接拒绝。实际项目里我最推荐的用法是把它封装成小组件比如TrafficGuard、DbQueryGuard而不是让业务代码到处散落acquire/release。这样的好处是整套释放逻辑都集中在一个模板方法里许可证泄漏的概率会急剧下降。监控方面建议额外暴露availablePermits()、排队线程数等指标接进告警系统。我能翻到的多数线上事故最后都不是信号量设计得不好而是许可证悄然泄漏、超时设置不合理、和线程池/连接池参数互相打架这类小问题累积起来的。如果接下来准备深入 JUC我建议在 Semaphore 源码里先看NonfairSync和FairSync这两个内部类再对照 AQS 的acquireShared/releaseShared方法把整条链路读一遍。能把信号量讲透很多并发问题都会迎刃而解。
阅读完成 · 觉得有帮助?
咨询建站