【多线程】Semaphore使用
Semaphore:不限一个,限 N 个------并发世界的停车场道闸
文章目录
- 【多线程】Semaphore使用
-
- 引言
- 一、场景故事:它在解决什么问题?
- 二、核心机制:从代码看原理
-
- [2.1 基本用法:许可的借与还](#2.1 基本用法:许可的借与还)
- [2.2 tryAcquire 家族:要不要等,调用方说了算](#2.2 tryAcquire 家族:要不要等,调用方说了算)
- [2.3 锁 vs 信号量:互斥 ≠ 限额](#2.3 锁 vs 信号量:互斥 ≠ 限额)
- [2.4 没有持有者:跨线程归还与许可泄漏](#2.4 没有持有者:跨线程归还与许可泄漏)
- 三、理解偏差:这些坑别踩
-
- [偏差 1:Semaphore 也能保护共享数据的线程安全](#偏差 1:Semaphore 也能保护共享数据的线程安全)
- [偏差 2:release 放在方法末尾,和放 finally 差不多](#偏差 2:release 放在方法末尾,和放 finally 差不多)
- [偏差 3:谁 acquire 谁才能 release(Semaphore 有持有者)](#偏差 3:谁 acquire 谁才能 release(Semaphore 有持有者))
- [偏差 4:默认是公平的,先到先得](#偏差 4:默认是公平的,先到先得)
- [易混淆点:Semaphore vs Lock vs CountDownLatch](#易混淆点:Semaphore vs Lock vs CountDownLatch)
- [四、记忆卡片:核心 QA 对](#四、记忆卡片:核心 QA 对)
- 五、知识网络:相似、互斥与学习路径
-
- [🔗 相似知识(同类不同派):CountDownLatch(第 4 篇)](#🔗 相似知识(同类不同派):CountDownLatch(第 4 篇))
- [⚔️ 互斥/替代知识(选型二选一)](#⚔️ 互斥/替代知识(选型二选一))
- [📚 前置与后续(学习路径)](#📚 前置与后续(学习路径))
- 六、配套代码运行指南
- 结语

引言
想象你维护着一个数据库,连接池里只有 20 个连接。某天运营搞活动,瞬时涌进 200 个请求------如果不做任何限制,200 个线程同时去抢连接,连接池瞬间枯竭,数据库被打挂,全线请求超时。
用 synchronized 行不行?行,但矫枉过正:它只允许同时 1 个请求访问,20 个连接的吞吐能力被活活锁成单车道。放任不管更不行------"限额"缺失的代价是系统崩溃。
我们需要的是一个能精确表达"同时最多 N 个 "的工具。这就是 Semaphore(信号量):acquire() 拿一个名额,release() 还一个,拿不到就在门口排队。本文讲透五件事:许可语义 → 与锁的本质区别 → tryAcquire 家族 → 无持有者特性 → 许可泄漏如何锁死整个系统。
一、场景故事:它在解决什么问题?
📖 故事:商场的三层立体停车场
一家商场有个立体停车场,共 3 个车位。入口立着一台发卡机,旁边一块大屏幕实时显示"剩余车位数",出口处有个收卡箱。
周末车流汹涌,第 4 辆车开到入口时,屏幕已经显示"剩余 0"。司机不需要给任何管理人员打电话,也不需要认识停车场里的任何一位车主------他只是把车停在道闸前排队。第 1 辆车办完事开到出口,把卡投进收卡箱的那一瞬间,道闸"嘀"一声把卡吐给队头那辆车,放行。
这套机制运转的每一天,不需要一个调度员:
- 车来了:先看屏幕(还剩几个名额),然后取卡;取不到卡,车就横在道闸前等;
- 车走了:交卡离场;卡一被回收,排队车辆立刻补位;
- 车位总量 :从物理上就是 3 个,任何时刻在场车辆绝不可能超过 3 辆------不是靠人盯着,而是靠"卡只有 3 张"这个规则本身。
没有人指挥,没有人协调,"最多 3 辆"却像铁律一样被遵守。这就是信号量的全部思想。
💡 对应关系:
| 故事中 | 技术中 |
|---|---|
| 3 个车位 | new Semaphore(3):初始 3 个许可(permit) |
| 取卡进场 | acquire():许可 -1,没了就阻塞 |
| 离场交卡 | release():许可 +1,唤醒一个排队的线程 |
| 屏幕上的"剩余车位数" | availablePermits()(只能看,不能当判断依据) |
| 道闸前排队 | 许可用尽后线程挂入 AQS 等待队列(阻塞,不耗 CPU) |
| 卡只有 3 张 → 场内绝不超过 3 辆 | 同时访问某资源的线程数量上限(限流本质) |
| 车位是稀缺资源,停车是短暂占用 | 数据库连接、文件句柄、下游接口配额...... |
基于这个直觉,我们来看正式机制。
二、核心机制:从代码看原理
2.1 基本用法:许可的借与还
Demo01 用停车场模型验证那条铁律------3 个车位、8 辆车,在场数永远 ≤ 3:
java
// Demo01_Basic.java
/** 3 个车位 = 3 个许可。Semaphore(3) 就是那个"只发 3 张卡"的入口道闸 */
static final Semaphore parking = new Semaphore(PARK_SPOTS); // PARK_SPOTS = 3
static void park(int carId) {
try {
parking.acquire(); // 取停车卡:有剩余许可则立即通过;没有则在此阻塞排队
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
// 走到这里 = 已经拿到卡(许可 -1)。在场车辆数 +1,并刷新峰值
int now = inPark.incrementAndGet();
peak.accumulateAndGet(now, Math::max);
// ......随机停 1~2 秒,模拟资源占用......
inPark.decrementAndGet();
parking.release(); // 交还停车卡(许可 +1)→ 道闸立刻放行一辆排队的车
}
程序结尾打印 在场车辆峰值 = 3(车位只有 3 个,全程从未超员)------用计数器实证了限流生效。
简化模型下,许可计数这样流转:
Semaphore(3) 内部的许可计数
车辆-1 acquire ──▶ 3 → 2 (立刻拿到,继续跑)
车辆-4 acquire ──▶ 0,不够了! (挂入 AQS 等待队列,阻塞,不耗 CPU)
车辆-1 release ──▶ 0 → 1 (唤醒队头 → 车辆-4 拿到许可,恢复运行)
💡 代码要点:
- Semaphore 管的是"数量",不是"数据"。它不保证互斥、不提供可见性:3 个许可意味着 3 个线程同时进入这段代码,共享变量照样有竞态。
acquire()内部做的是"许可数 -1,不够就睡觉";release()是"许可数 +1,叫醒一个排队的"。线程阻塞时被挂起,不空转、不耗 CPU。- 底层是 AQS 的共享模式 :state 就是剩余许可数,允许多个线程同时通过------与第 4 篇的 CountDownLatch 同族。区别在于:CountDownLatch 的计数只减不增、一次性 ;Semaphore 的许可借了还、还了借,可反复复用。
- 默认非公平 :release 的瞬间,刚到的线程可能直接抢走许可(插队),吞吐更高。要严格排队用
new Semaphore(permits, true)。
2.2 tryAcquire 家族:要不要等,调用方说了算
acquire() 的语义是"拿不到就无限等"。但很多场景不能等:接口限流要做快速失败(拿不到名额直接返回"系统繁忙"),而不是让请求干等到雪崩。Demo02 让 3 辆车面对同一个满员停车场,采用 3 种策略:
java
// Demo02_TryAcquire.java
boolean got = parking.tryAcquire(); // 策略一:不阻塞,有位 true,没位立刻 false
got = parking.tryAcquire(500, TimeUnit.MILLISECONDS); // 策略二:最多等 500ms,超时返回 false
got = parking.tryAcquire(3, TimeUnit.SECONDS); // 策略三:愿意等到有位为止(约 1.9s 时等到)
运行结果(毫秒数为约数):
[车辆-3] 客满!tryAcquire() 立即返回 false(耗时 0ms)→ 一秒都不等,掉头去别的停车场
[车辆-4] 等了 501ms 还是没有车位(前车要停 2 秒)→ 超时放弃
[车辆-5] 等了 1903ms 终于等到车位 ▶ 成功进场
完整的 acquire/release 家族一张表横着记:
| 方法 | 拿不到许可时 | 会抛 InterruptedException? | 典型用途 |
|---|---|---|---|
acquire() |
一直阻塞 | 是 | 资源池借资源 |
acquire(n) |
阻塞到凑齐 n 个 | 是 | 一次借多个(批量资源) |
tryAcquire() |
立即返回 false | 否(从不阻塞) | 快速失败限流 |
tryAcquire(t, unit) |
限时等待,超时返回 false | 是 | 带兜底的有限等待 |
acquireUninterruptibly() |
阻塞且不响应中断 | 否 | 不愿被中断取消的关键路径 |
release() / release(n) |
从不阻塞,许可 +1 / +n | 否 | 归还(必须在 finally 中) |
💡 代码要点:
tryAcquire()不抛InterruptedException------它从不等待,自然没有"可被中断的等待"。- tryAcquire 返回 false 后绝不能再 release():没借到却还了,许可被凭空超发(见 2.4)。
- 超时值要覆盖"资源占用方的最长归还时间"。Demo02 里前车固定停 2 秒,所以"等 500ms"必失败、"等 3 秒"必成功------这个确定性正是刻意设计的观察窗口。
2.3 锁 vs 信号量:互斥 ≠ 限额
这是 Semaphore 最需要建立的心智模型。两者 API 形状几乎一样(acquire/lock,release/unlock),回答的却是不同的问题:
synchronized / Lock ------ 独木桥:同时只许 1 人通过(互斥)
Semaphore(3) ------ 3 车道公路:同时可过 3 辆,第 4 辆等(限额)
| Lock(第 2 篇) | Semaphore(本篇) | |
|---|---|---|
| 保证的同时进入数 | 1 | N(构造参数指定) |
| 保护共享数据 | 是(互斥 + 可见性) | 否(只数人头) |
| 持有者 / 重入 | 有,谁锁谁解 | 没有,见 2.4 |
| 近似关系 | ------ | Semaphore(1) 才近似互斥,但仍无持有者校验 |
所以限流的正确姿势是"每请求先 acquire":Demo01 里 8 个线程并发执行同一段 park(),共享的只是"许可"这个计数,谁也不需要保护谁。
2.4 没有持有者:跨线程归还与许可泄漏
Demo03 演示了 Semaphore 与锁差异最大、也最容易出事的两个特性。
其一:许可泄漏 = 慢性锁死。 release 没放 finally,业务异常会把归还动作整个跳过:
java
// Demo03_Pitfalls.java(❌ 反面教材)
pool.acquire(); // 借走一个连接(许可 -1)
if (true) { throw new RuntimeException("模拟业务异常"); }
pool.release(); // ← 永远执行不到!异常直接跳过了归还动作
// 运行日志:许可被逐渐耗尽,然后全员拒绝服务
// [请求-1] 借到连接,开始处理业务...(剩余许可 2)
// [请求-3] 借到连接,开始处理业务...(剩余许可 0)
// >>> 第一波结束:此刻没有任何线程在工作,剩余许可却是 0(3 个许可全部泄漏)
// [请求-4] 等了 1000ms 也拿不到连接 → 拒绝服务!
每漏还一次,系统并发容量就永久 -1 ;线程都活着、服务还在跑,却再也服务不了任何人------"慢性锁死"。修复只有一行:release 放进 finally(对照 Demo01 的正确写法)。
其二:没有持有者,谁都能 release。 锁会校验"谁加锁谁解锁",Semaphore 完全不记录许可属于谁:
java
// Demo03_Pitfalls.java
gate.acquire(2); // 主线程一口气拿走全部 2 个许可(资源全部扣下)
new Thread(() -> {
gate.release(2); // 管理员线程从未 acquire 过,却可以合法地替人归还
}, "管理员").start();
new Thread(() -> {
gate.acquire(); // 顾客线程:阻塞中......直到管理员 release 放出许可
gate.release();
}, "顾客").start();
💡 代码要点:
- 跨线程 release 是合法特性,不是 bug:可以主线程统一 acquire 掌控额度、工作线程完成后代还(集中管理模式);Demo03 里"管理员替素不相识的持有人交卡"正是这个模式。
- 硬币的另一面是全靠自律 :
release次数超过acquire,可用许可会超过初始值(超发);少还则泄漏。锁的持有者校验在这里不存在。 availablePermits()只是个瞬时读数------读到"还有 1 个"的瞬间可能就被别人抢走,不能 作为"先判断再操作"的依据(同 BlockingQueue 的size()误区)。- 第二波请求故意用
tryAcquire(1s)而不是acquire():泄漏后acquire()会永远阻塞,演示代码就再也跑不完了------这也是排查线上锁死时的取证技巧。
三、理解偏差:这些坑别踩
偏差 1:Semaphore 也能保护共享数据的线程安全
❌ 常见误解:acquire/release 和 lock/unlock 长得一样,两者之间就是"临界区",里面的共享变量是安全的。
✅ 正确理解 :Semaphore 只数人头、不护数据。3 个许可 = 3 个线程同时进入,对共享变量的并发读写照样竞态 。需要保护数据请用锁或并发容器;Semaphore(1) 才近似互斥,且仍不同于锁(无持有者、无重入语义)。
🧠 为什么容易错:API 形状与锁几乎一致,"acquire = 上锁"的直觉平移过去就错了。
🧪 自测方法:看着代码问自己"这段区域同时最多几个线程?"------答案大于 1,就不是临界区,别指望信号量保数据。
偏差 2:release 放在方法末尾,和放 finally 差不多
❌ 常见误解:业务方法最后一行调 release 就行,异常是小概率事件。
✅ 正确理解 :异常恰恰发生在业务处理中,末尾的 release 第一时间被跳过;而泄漏是累积的------每漏一次并发容量永久 -1,漏 3 次一个 3 许可的池子就彻底锁死(Demo03 全过程)。"小概率 × 高频调用 = 必然发生"。
🧠 为什么容易错:测试几乎只覆盖正常路径,泄漏是慢性病,往往在流量高峰才爆发,且表象(请求超时)离病根(少一次 release)很远。
🧪 自测方法 :code review 时看到 acquire(),立刻去找配对的 finally { release(); };找不到,即 bug,没有例外。
偏差 3:谁 acquire 谁才能 release(Semaphore 有持有者)
❌ 常见误解 :和 ReentrantLock 一样,A 线程拿的许可必须由 A 还,别的线程 release 会抛 IllegalMonitorStateException。
✅ 正确理解 :Semaphore 不记录持有者 ,任何线程可以对它 release 任意次。这是特性(主线程统一管理、跨线程代还),也是隐患:多还 → 许可超发 (可用数超过初始值),少还 → 泄漏。谁借谁还只是最佳实践,不是语法强制。
🧠 为什么容易错:先学了锁的"持有者 + 重入"模型,误以为所有同步器都自带这层保护。
🧪 自测方法 :写个 main------new Semaphore(1) 后连续 release() 两次,再打印 availablePermits():输出 2,无任何异常。亲手跑一遍,终身免疫。
偏差 4:默认是公平的,先到先得
❌ 常见误解:停车场里车在"排队",那肯定是先来的先拿卡。
✅ 正确理解 :默认非公平 ------release 的瞬间,一个刚到的线程可能直接抢走许可,排队最久的线程继续等。这样吞吐更高(省去排队调度);确需严格 FIFO 用 new Semaphore(permits, true),代价是吞吐下降。绝大多数限流场景用默认即可。
🧠 为什么容易错:停车场故事的"排队"意象太强,而 AQS 的默认策略恰恰是非公平。
🧪 自测方法 :记住"排队只是结果,不是承诺";再回头看构造函数的第二个参数 boolean fair 的 Javadoc。
易混淆点:Semaphore vs Lock vs CountDownLatch
| 维度 | Lock(第 2 篇) | Semaphore(本篇) | CountDownLatch(第 4 篇) |
|---|---|---|---|
| 回答的问题 | 谁能进?------同时 1 个 | 同时能进几个?------最多 N 个 | 什么时候能开始?------等计数归 0 |
| 底层 AQS 模式 | 独占(Exclusive) | 共享(Shared) | 共享(Shared) |
| 持有者概念 | 有,谁锁谁解,可重入 | 没有,跨线程可 release | 无(纯计数器) |
| 复用性 | 可反复加解锁 | 借了还能反复复用 | 只减不增,一次性 |
| 保护数据 | 是 | 否 | 否 |
四、记忆卡片:核心 QA 对
| # | 问题 | 答案 |
|---|---|---|
| 1 | 定义:Semaphore 是什么? | 维护一组许可(permit)的计数器:acquire() 拿一个(没了就阻塞 ),release() 还一个并唤醒排队线程。控制同时访问某资源的线程数量上限。 |
| 2 | 机制:底层怎么实现? | AQS 共享模式 :state 即剩余许可数,多个线程可同时通过;与 CountDownLatch 同族,但可反复借还、可复用。 |
| 3 | 场景:什么时候用它? | 接口限流 (拿不到许可快速失败)、数据库连接池、文件句柄等稀缺资源的池化管理。 |
| 4 | 权衡:公平还是非公平? | 默认非公平 (吞吐高,可能插队);new Semaphore(permits, true) 公平(严格 FIFO,吞吐下降)。 |
| 5 | 区分:与锁的本质区别? | 锁保互斥 (同时 1 个),Semaphore 保限额 (同时 N 个);Semaphore(1) 才近似互斥;它不保护数据 、没有持有者。 |
| 6 | 边界:什么情况下会出事? | release 忘放 finally → 许可泄漏 → 慢性锁死 ;多 release → 许可超发 (超过初始值);availablePermits() 是瞬时值,不能做协调判断。 |
| 7 | 延伸:acquire 家族有哪些? | acquire(n) / tryAcquire() / tryAcquire(t, unit) / acquireUninterruptibly();归还对应 release() / release(n)。 |
五、知识网络:相似、互斥与学习路径
🔗 相似知识(同类不同派):CountDownLatch(第 4 篇)
两者都是 AQS 共享模式的"计数器",但方向相反:
| 对比维度 | CountDownLatch | Semaphore |
|---|---|---|
| 计数方向 | 只减不增(countDown 到 0) | 可减可增(借了还、还了借) |
| 生命周期 | 一次性,用完作废 | 可反复复用 |
| 回答的问题 | "等都完成了再开始" | "同时最多 N 个" |
| 一句话 | 倒计时门闩 | 可循环借还的计数道闸 |
顺带一提:Semaphore + 队列就能搭出一个最小资源池(对象借出归还),这正是第 3 篇 BlockingQueue 的兄弟用法------队列管排队,信号量管名额。
⚔️ 互斥/替代知识(选型二选一)
| 替代方案 | 与 Semaphore 的关系 | 何时选它 |
|---|---|---|
synchronized / Lock |
互斥 = Semaphore(1) 的特例,但更强(重入、持有者、Condition) |
要保护共享数据,不只是限数量 |
| 令牌桶限流(如 Guava RateLimiter) | Semaphore 限"并发数 ",RateLimiter 限"速率(QPS)" | 要平滑控制每秒放行多少请求 |
| 线程池本身 | 池大小 + 队列就是内建的并发上限 | 控制对象是"任务"而非任意稀缺资源 |
选型依据:限"同时多少个线程在用资源"→ Semaphore;限"每秒多少个请求"→ 令牌桶;保护数据 → 锁;管任务 → 线程池。
📚 前置与后续(学习路径)
- 前置知识 :
- synchronized 与竞态条件(第 1 篇):先分清"互斥"与"限额"是两个需求
- CountDownLatch(第 4 篇):AQS 共享模式入门,本篇的 state 语义直接对照
- 后续知识 :
- 第 7 篇 Exchanger:同步工具箱的下一位成员,两个线程之间的成对交换
- 第 8/9 篇 Future / CompletableFuture:限流之后,如何优雅地收集并发任务的结果
- 线程池调优:
Semaphore包住池外实现全局限流,是生产常见的组合拳
- 最佳搭档 :
- finally:release 的唯一正确归宿,没有例外
- tryAcquire + 快速失败响应:限流落地到接口层的标准姿势("系统繁忙,请稍后再试")
六、配套代码运行指南
a06Semaphore/
├── Demo01_Basic.java 3 车位停车场进 8 辆车:acquire/release 基本限流,峰值实证 ≤ 3
├── Demo02_TryAcquire.java 3 种获取策略:立即失败 / 超时放弃 / 限时等待成功
└── Demo03_Pitfalls.java 两大易错点:许可泄漏慢性锁死 + 无持有者跨线程 release
每个类都含 main 方法,直接运行即可(JDK 17)。建议重点观察:
- Demo01:在场数字永远 ≤ 3;车辆-4~8 到达后有一段没有"驶入"日志的真空期------那就是道闸前的排队阻塞;结尾"在场车辆峰值 = 3"是限流生效的实证;
- Demo02:车辆-3 耗时 0ms 返回 false,车辆-4 恰好在 ~500ms 放弃,车辆-5 在 ~1.9s 等到前车让位------三种策略的结果差异一目了然;
- Demo03:第一波日志"剩余许可 2 → 1 → 0"逐渐耗尽,第二波全员"拒绝服务"------系统活着却已锁死;后半段管理员线程从未 acquire 却能 release,没有持有者校验。
结语
Semaphore 的价值在于把"最多 N 个"从散落各处的 if 判断和手工计数,变成一行声明式的 new Semaphore(n):acquire 取名额、release 还名额、tryAcquire 决定等不等------限流、连接池、文件句柄管理,都是这一对操作的不同皮囊。学完本篇请带上三条肌肉记忆:它不保护数据、它没有持有者、release 必须进 finally。至此,我们已经认识了"集体排队"(CyclicBarrier)和"集体限流"(Semaphore),下一篇登场的是最"二人转"的同步工具------Exchanger,两个线程在中间点互换信物,不见不散。