ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java信号量Semaphore从入门到实战:用法、原理与避坑指南

Java信号量Semaphore从入门到实战:用法、原理与避坑指南 做后端这些年凡是涉及并发控制的代码Java并发包里的Semaphore信号量几乎总是绕不开的那一个。网上讲基本用法的文章不算少但多数版本太零散要么只给个demo要么直接甩一堆AQS源码真正能把“怎么用”和“为什么这么用”讲透的其实不多。这篇我打算按自己项目里的真实使用习惯把Semaphore的基本用法、背后原理、选型边界和常见误区一次性梳理清楚适合刚接触并发编程的同学也适合那些已经会用但总感觉哪儿不太对劲的开发者。先说说为什么值得花时间搞懂它。如果你写过限流、连接池、批量任务调度大概率会遇到一个共同的需求某份资源同时只能被N个线程使用。synchronized和Lock能做互斥但做不到“同时放行N个”这种更细粒度的数量控制。Semaphore就是专门解决这个问题的它内部维护一组许可线程拿得到许可就放行拿不到就排队等着用完之后把许可还回去下一个线程再进来。理解了这句话基本用法已经掌握了一半剩下的一半是各种细节和边界。1. 信号量到底在解决什么问题名额控制与资源上限1.1 从停车位类比说起——信号量的模型我一般跟新人解释Semaphore都用停车场做例子。假设一个停车场有5个固定车位门口保安手里有5张停车卡。每来一辆车司机拿一张卡进去停车车开走时把卡还给保安。当5张卡片全部发完后再来的车就只能堵在门口排队等里面有空位、有人还卡保安才放下一辆进去。这里的“卡”就是许可证permit“5”就是初始化时传入的许可数车辆就是并发线程。Semaphore维护的许可数本质上是共享资源单元的“副本数量”它不关心这份资源具体是什么只管发放和回收。如果从代码角度再看一遍就三件事构造时设置许可总数线程执行前调用acquire()尝试取许可执行完务必调用release()还许可。几乎所有的Semaphore用法最终都能归到这个模型上。1.2 为什么Java需要单独一个信号量工具JUC包里已经有synchronized、ReentrantLock、ReentrantReadWriteLock为什么还要专门搞一个Semaphore关键在于synchronized和Lock模型是“互斥”同一时间只允许一个线程进入临界区它回答的是“能不能进来”的问题但回答不了“同时能有几个人进来”。想象一下你手上有10个数据库连接允许10个线程同时持连接干活第11个线程必须等待。用Lock怎么实现常规做法是搞一个简单的池子加一个条件变量代码写起来相当绕。ReentrantReadWriteLock虽然让读和写分开但读写之间依然是互斥的做不到“两个写线程同时各拿一个连接”。Semaphore则直接把“数量”这个维度建模出来了。它底层基于AQS状态变量state保存剩余许可数。acquire()就是对state做一次递减减到0就入队阻塞release()对state递增并唤醒等待线程。虽然平时写业务代码不用关心AQS细节但理解了这个机制后面排查“为什么线程全卡住了”“为什么许可越来越少”这类问题会快很多。1.3 三个核心概念许可数、获取与释放用Semaphore之前先把三个词的含义对齐否则很容易把API用错。许可数permits是构造方法里传的数字代表可以并发通过的线程总数。它不是“请求数上限”而是“同时持有的资源份数上限”。获取acquire是每次执行需要消耗一个许可如果当前没有许可线程会阻塞等待直到其他线程归还。释放release则是把许可重新放回去让其他等待线程继续。这里有个容易忽略的关键点Semaphore的获取和释放并不要求是同一个线程。ReentrantLock要求谁加锁谁释放锁否则会报IllegalMonitorStateException但Semaphore只维护一个计数器线程A获取的许可可以让线程B来释放。这既是便利也是隐患异步场景里release的时机一旦放错限流保护就形同虚设第四章我会专门讲。2. 从最小demo开始Semaphore核心API的完整实操2.1 一个可以直接跑起来的示例5个车位、10辆车先给一个不依赖任何框架的最小示例建议去IDE里亲手跑一遍运行几次观察输出结果。import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; public class SemaphoreDemo { public static void main(String[] args) throws InterruptedException { // 停车场一共5个车位 Semaphore parkingLot new Semaphore(5); // 来10辆车 for (int i 1; i 10; i) { final int carNo i; new Thread(() - { try { // 拿一张卡没车位就排队 parkingLot.acquire(); System.out.println(车辆 carNo 进入停车场剩余车位 parkingLot.availablePermits()); // 模拟停车时间 TimeUnit.MILLISECONDS.sleep((long) (Math.random() * 1000)); System.out.println(车辆 carNo 离开停车场); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { // 车开走了卡还回去 parkingLot.release(); } }).start(); } } }运行结果里有一点需要留意availablePermits()打印的数字不会小于0最多就是0。10个线程竞争5个许可任意时刻“进入”和“离开”之间的车不会超过5辆。输出顺序看起来可能乱因为println不是原子操作多个线程可能交错输出但核心并发约束是成立的。这段代码里最关键的是finally块。如果哪次业务逻辑抛异常而release没有执行许可数就永久少了一个长期运行后所有线程都会卡死在acquire()上。这是Semaphore使用中最常见的“许可泄漏”事故。2.2 核心API的取舍acquire、tryAcquire、releaseSemaphore的公开方法不算多但每个都有明确的适用场景我用一个表格把它们分清楚。方法行为说明典型场景acquire()获取1个许可无许可则阻塞可响应中断内部批量任务宁可等也不失败acquire(int permits)一次性获取多个许可必须配套release同样数量一次任务需要占用多个资源单元acquireUninterruptibly()获取许可时忽略中断直到成功不允许被中断关闭的强制任务tryAcquire()尝试获取许可拿不到立即返回false非阻塞场景拿不到就做其他事tryAcquire(timeout, unit)在超时时间内等待许可超时返回false接口限流超过等待阈值直接降级release()归还1个许可所有获取成功后的finally块里release(int permits)归还多个许可与acquire(int)配套使用表格里最值得展开的是tryAcquire加超时。它表面上是acquire的“温柔版”但用不好会变成静默故障源。如果超时设得太短比如50毫秒高峰期大量请求还没来得及排上队就被拒了如果条件允许建议把超时时间和服务端线程池队列的等待时间放一起考虑统一设计请求的“最大可容忍等待时长”。另一个细节是acquire(int)和release(int)必须严格匹配。调了acquire(2)release时只release(1)相当于永久吞掉一个许可反过来一次性release(2)则可能把当前并发数顶到超过上限限流失效。2.3 公平模式传true与不传true的差别构造Semaphore时可以传第二个参数new Semaphore(5, true)代表公平模式new Semaphore(5)或new Semaphore(5, false)是非公平模式。这个参数很容易被忽略但线上行为差别很大。非公平模式下新来的线程在许可被归还时可以直接通过CAS抢到许可不需要排队。好处是吞吐量高省去了频繁队列切换的成本坏处是极端情况下可能“插队”后到的线程反而比先到的线程先拿到许可某些线程等待时间被无限拉长出现饥饿。公平模式则会让所有线程按到达顺序进入等待队列先来先服务。代价是每次放行都要做队列状态检查吞吐量略低上下文切换更频繁。如果你的业务对“顺序”敏感——比如叫号办理、批量任务按提交顺序执行——就用公平模式。如果只是做流量保护不关心谁先谁后默认的非公平模式就够了。我在实际项目中倾向于一个经验接口入口级的限流非公平足够但内部任务调度尤其是处理顺序会影响最终数据一致性的尽量用公平模式。这个判断不需要绝对核心是想清楚你的等待线程池里是不是存在“优先级”。3. Semaphore与同类工具的边界什么时候选它什么时候不选3.1 Semaphore vs synchronized/Lock一个是互斥一个是放行很多初学者会把Semaphore理解成“高级锁”这个概念偏差需要尽早纠正。锁解决的核心问题是互斥同一时刻只能有一个线程执行某段代码。Semaphore解决的是配额同一时刻最多有N个线程通过某个入口。当N1时两者在效果上确实接近但语义仍然不同。synchronized和ReentrantLock本质上是“所有权模型”谁拿到锁谁负责释放Semaphore是“计数模型”A线程获取的许可完全可以由B线程归还。如果把Semaphore(1)当成独占锁用还让不同线程负责释放会出现“锁”被莫名其妙放掉的问题业务边界直接失效。更典型的坑是重入。ReentrantLock支持同一个线程反复进入同一段临界区Semaphore不支持重入。Semaphore sem new Semaphore(1)线程A执行sem.acquire()后在执行release()之前再次acquire()会把自己阻塞死——因为当前唯一一个许可已经被自己拿走了。网上有些老代码拿Semaphore(1)模拟锁遇到递归或嵌套调用时卡死得莫名其妙就是这个原因。3.2 Semaphore vs CountDownLatch vs CyclicBarrier红绿灯、闸门、接力棒JUC包里这三样东西常被放在一起比较但它们的语义差异很大。用生活场景说可能更好记。CountDownLatch像餐厅包间的入场闸门必须等所有人都到齐了服务员才放行开始上菜。它是“一次性”的计数只能减不能增减到0之后闸门永远打开。CyclicBarrier像过山车必须凑满一车人设备才发车这一车人下来后下一批继续凑。它是循环使用的每一轮都重新计数。Semaphore更像食堂窗口一共3个打饭窗口同时最多有3个人在窗口前打饭打完一个空出位置后面排队的人补进来。它管理的不是“一次聚齐”而是“持续并发数量”窗口永久运作许可循环收放。用一个表格把三者放到一起选型时一目了然工具核心语义可循环使用典型场景Semaphore控制同时刻最大并发数量是连接池、接口并发上限保护CountDownLatch等待N个事件完成后放行否等待多个接口返回后继续执行CyclicBarrierN个线程互相等待齐后再出发是分阶段任务每阶段同步一次3.3 从实际需求倒推选型三个典型问题的判断路径判断工具选型我习惯不直接记结论而是拿需求问三个问题。第一问是不是“同一时刻只能有一个线程执行”如果是用锁。比如库存扣减、状态修改这类场景要的是排他。第二问是不是“必须等N个任务都完成才能继续”如果是用CountDownLatch。比如批量请求第三方接口全量返回后再聚合。第三问是不是“资源有固定份额用一份少一份用完了要还回来、其他人继续用”如果是用Semaphore。比如数据库连接池、对外开放的并发名额。还有一个补充判断维度资源是不是可再生。锁对应的是“代码互斥区”不是可再生资源连接、线程、端口这些稀缺资源是可持续申请和归还的更贴近Semaphore的语义。4. 实测中必须避开的几个坑许可泄漏、窗口期、静默限流4.1 release放错地方导致的许可泄漏我一直强调release必须放finally是因为线上真的会因为这个出事故。之前接手过一个内部数据处理服务现象是运行几个小时之后任务全部堆积日志里全是超时。查线程dump发现大量线程停在Semaphore的acquire()上而且availablePermits()已经变成0但按正常业务并发量根本不应该占满。逐个排查callsite后发现同事为了“性能好一点”把release写进了业务逻辑末尾没有放finally。平时处理正常数据不会触发异常但只要某条数据格式异常业务代码抛出RuntimeExceptionrelease就被跳过了。一条异常数据吞掉一个许可累积几十条后整个服务瘫痪。排查这类问题有两个抓手第一看线程dump如果大量线程处于WAITING状态且卡在Semaphore.parkAndCheckInterrupt附近优先检查release路径第二在调用入口周期性打印availablePermits()如果许可数不可逆地减少基本就是泄漏了。正确的写法是模板级别的统一规范if (semaphore.tryAcquire()) { try { // 业务逻辑 } finally { semaphore.release(); } }4.2 permit数量拍脑袋定下来的后果许可数不是“并发请求数”这么简单它应当等于你的稀缺资源单元数。一个经常踩的坑是系统最大并发其实是20但每个请求会同时占用两个数据源连接那Semaphore应该设成10而不是20。如果不加区分地设成20高峰期可能瞬间挤压出40个连接请求后端连接池直接被打爆。还有一个更隐蔽的点一次请求持有许可的时间可能不是固定的。比如异步调用第三方接口线程在等待远程返回期间一直拿着许可那么同样数量的许可能支持的每秒请求数会明显下降。这种情况要把“平均持有时间”纳入估算。简单的估算思路是目标并发数 × 单请求平均持有许可数 基础许可需求在基础值上留20%-30%余量应对突发如果单请求持有许可时间波动很大许可数要结合超时时间一起设计更稳妥的做法是把许可数做成配置中心参数压测通过后先灰度放量再逐步收紧。不要直接把数字写死在代码里否则每次调并发都要发一次版。4.3 tryAcquire的滥用会让系统“静默限流”tryAcquire是个好东西但被滥用时最可怕的地方在于故障是“静默”的。接口依然在响应监控里看不出系统崩溃只有下降的通过率和大量降级日志能暗示问题。我见过一个支付回调场景开发用tryAcquire(10, TimeUnit.MILLISECONDS)做限流本意是“稍微等一下拿不到就让请求快速失败”。结果第三方回调频繁10毫秒排队根本进不来大量回调被当成“失败”打了回去。第三方收到失败后不断重试形成了恶性循环。这里的问题是超时阈值不是随便填的。tryAcquire的超时应该代表“业务可以容忍的排队等待时间”而不是“我不想等这么久”。如果一次正常业务处理需要200毫秒那你至少应该给排队线程留出几百毫秒级别的时间预算再配合失败打点和熔断统计。用tryAcquire做限流建议同时做四件事超时时间可配置、降级分支打点、availablePermits和线程阻塞时长上报监控、失败率阈值触发告警。缺了监控的tryAcquire等于把一个危险阀门藏在了业务链路里。4.4 异步场景里release时机错位导致限流穿透这是最近遇到的一个新坑。代码长这样semaphore.acquire(); CompletableFuture.supplyAsync(() - { // 真正耗时的业务逻辑 return result; }).thenAccept(r - { // 后处理 semaphore.release(); });表面看逻辑没问题共享线程池执行完后释放许可。但实际运行中发现主线程acquire后立刻提交异步任务异步任务最终也真的会release。问题出在提交的瞬间——如果异步线程池的队列很短任务会进入队列排队而主线程在“认为任务已受理”后就结束了。并发量一高实际同时在跑的异步任务数远超许可数Semaphore变成了摆设。我的经验是Semaphore的许可持有期必须严格对应用户真正占用稀缺资源的那段生命周期。如果你想限制的是第三方并发请求数Semaphore要包住真正发起请求的代码而不是入口代码。如果业务必须拆成多个线程段传播那要么把Semaphore的acquire和release放到同一个异步任务内部首尾要么改用“有界线程池饱和策略”这类更贴合调度模型的工具。5. 把Semaphore放进真实业务场景连接池与接口限流的完整思路5.1 用Semaphore实现一个简易连接池连接池是Semaphore的经典教学场景。我用最简代码演示核心思想一批资源借走一个许可就少一个归还时补回来。public class SimpleConnectionPool { private final ListConnection connections; private final Semaphore semaphore; public SimpleConnectionPool(int size) { connections new ArrayList(size); semaphore new Semaphore(size, true); for (int i 0; i size; i) { connections.add(createConnection()); } } public Connection borrow() throws InterruptedException { semaphore.acquire(); return connections.remove(0); } public void giveBack(Connection conn) { connections.add(conn); semaphore.release(); } private Connection createConnection() { return null; // 实际场景里创建真实连接 } }这段代码的borrow和giveBack正好对应停车场的“进场拿卡”和“离场还卡”。如果借用者中途抛异常Connection没有被归还Semaphore的许可也少了所以真实项目里giveBack调用的严谨性比演示代码要求高得多。当然生产环境不会自己手写这种简化版连接池HikariCP之类的成熟框架已经处理好了连接失效、空闲回收、等待超时等问题。但把简化版写一遍的价值在于理解HikariCP的底层骨架本质上就是“有界连接资源加等待协调”Semaphore帮你把“同时最多持有几个连接”这个约束表达出来了。5.2 接口限流阻塞等待还是超时降级接口限流是Semaphore最常见的落地场景但策略选择直接影响用户体感。内部批量任务场景建议用acquire()阻塞等待。比如大数据量导入、定时拉取任务这类逻辑要求“宁可慢不能失败”。任务排队几秒都是可以接受的阻塞等待能保证最终执行不会因为拿不到许可而丢任务。外部API场景建议用带超时的tryAcquire。用户请求在排队等待时体验会随等待时长迅速恶化正确的做法是设置一个最大排队时间超过就直接返回“系统繁忙请稍后重试”或HTTP 429。这样既保护了后端资源又避免调用方被无限拖住。if (!semaphore.tryAcquire(500, TimeUnit.MILLISECONDS)) { throw new BusyException(系统繁忙请稍后重试); } try { // 执行业务 } finally { semaphore.release(); }这个场景里超时时间要和线程池参数、下游接口的响应时间联动设置。如果下游平均响应200毫秒你只给tryAcquire留100毫秒很多正常请求都会被拒。可以先跑一轮压测统计“获取许可前的等待时间”的P99值再确定超时阈值。5.3 动态调整信号量的小技巧临时扩流与优雅降级Semaphore没有提供公开的setPermits方法线上如果发现并发许可总量不合适是不是只能重启服务不是有一个相对安全的动态替换方案。思路是用volatile引用替换整个Semaphore实例private volatile Semaphore limiter new Semaphore(20); public void updateLimiter(int newPermits) { // 直接替换引用新请求走新实例 limiter new Semaphore(newPermits); }业务代码里所有acquire和release都通过limiter引用去调用更新配置时直接替换引用。新请求会使用新的许可数量老请求仍然在旧实例上释放互不干扰。这个方案的优点是简单可操作适合发布期间临时扩流、大促前放量这类场景。代价是切换的瞬间可能有一小段“双实例共存期”并发峰值会在新老实例之间产生短暂叠加。如果业务对并发上限极其严格这个方案就不够用了需要结合注册中心做全链路协同调整或者引入更复杂的中间层。但通常来说比起为调整一个数字重启整个服务volatile替换已经是性价比很高的做法了。6. 结合场景再谈一下什么情况不该用Semaphore6.1 限速场景管并发和管速率是两回事Semaphore管的是“同一时刻最多几个线程在跑”不管“每秒能跑几个”。这个区别经常被混淆。举个例子限流要求“接口每秒最多处理20个请求”用Semaphore实现的话结果会是这样某一秒来了20个请求全部通过占满了所有许可下一秒又放行下一批20个。如果每个请求处理很快一秒内可能远远超过20个总量如果处理很慢容量又会闲置。也就是说Semaphore并不能保证固定速率。真正的速率限制需要令牌桶或漏桶算法Guava的RateLimiter是常见的单机实现。令牌桶按照固定速率生成令牌请求必须拿到令牌才能执行和Semaphore的“固定份额抢注”模型完全不同。区分这两者的关键是你要限制的是“瞬间并发量”还是“一段时间内的总流量”。前者用Semaphore后者用限速器。6.2 分布式环境单机Semaphore的边界Semaphore是进程内的工具它只能对本进程内的线程做协调。如果你的服务部署了5个实例每个实例各自new一个Semaphore(20)那么全局实际同时处理量是5×20100而不是20。所以多实例下的“全局限流”不能指望Semaphore需要借助Redis加Lua脚本做分布式限流或者直接在网关层统一限制。Semaphore的合适定位是“本地保护”防止单实例内部资源被打爆它是最后一道防线不是全局控制阀。如果确实有全局并发的强约束比如“整个系统同时只能有10个任务写同一份文件”那就必须用支持分布式锁或分布式信号量的中间件。单机Semaphore在这种场景下不但没用还会造成“我明明限了流为什么还是超限”的迷惑。6.3 简单需求别上信号量过度设计比不用更可怕最后说一个方向性的问题不要为了用而用。如果目标只是防止一个共享变量被多个线程同时写synchronized、ReentrantLock或者AtomicInteger已经足够直接没必要引入Semaphore增加阅读门槛。Semaphore真正发挥价值的地方是“多线程协作共享有限数量资源”的模型一旦模型不匹配它带来的语义复杂度会让人费解。我见过一个团队给简单的缓存更新逻辑加了个Semaphore(1)后来新同学以为是限流在业务里放了一堆没必要的acquire和release排查问题的时候绕了好大一圈。我个人这几年的使用习惯可以总结成三条第一用Semaphore之前先在注释里写清楚“这份许可代表哪个资源的份额”说不清楚就不要用第二所有release统一放finally并配合日志打印许可余量第三许可数量和超时时间全部做成配置项压测后动态调整。只要坚持这三点Semaphore在绝大多数场景里都能用得又稳又简洁。
返回列表