【多线程】Semaphore使用

【多线程】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 拿到许可,恢复运行)

💡 代码要点

  1. Semaphore 管的是"数量",不是"数据"。它不保证互斥、不提供可见性:3 个许可意味着 3 个线程同时进入这段代码,共享变量照样有竞态。
  2. acquire() 内部做的是"许可数 -1,不够就睡觉";release() 是"许可数 +1,叫醒一个排队的"。线程阻塞时被挂起,不空转、不耗 CPU
  3. 底层是 AQS 的共享模式 :state 就是剩余许可数,允许多个线程同时通过------与第 4 篇的 CountDownLatch 同族。区别在于:CountDownLatch 的计数只减不增、一次性 ;Semaphore 的许可借了还、还了借,可反复复用
  4. 默认非公平 :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 中)

💡 代码要点

  1. tryAcquire() 不抛 InterruptedException------它从不等待,自然没有"可被中断的等待"。
  2. tryAcquire 返回 false 后绝不能再 release():没借到却还了,许可被凭空超发(见 2.4)。
  3. 超时值要覆盖"资源占用方的最长归还时间"。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();

💡 代码要点

  1. 跨线程 release 是合法特性,不是 bug:可以主线程统一 acquire 掌控额度、工作线程完成后代还(集中管理模式);Demo03 里"管理员替素不相识的持有人交卡"正是这个模式。
  2. 硬币的另一面是全靠自律release 次数超过 acquire,可用许可会超过初始值(超发);少还则泄漏。锁的持有者校验在这里不存在。
  3. availablePermits() 只是个瞬时读数------读到"还有 1 个"的瞬间可能就被别人抢走,不能 作为"先判断再操作"的依据(同 BlockingQueue 的 size() 误区)。
  4. 第二波请求故意用 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,两个线程在中间点互换信物,不见不散。

相关推荐
数智启示录1 小时前
PostgreSQL 内存调优实战(第 12 篇):work_mem 只调大 64 倍,峰值为什么远不止 64 倍
运维·数据库·经验分享·postgresql·面试
秋名RG1 小时前
Java 核心特性一览
java·开发语言
霸道流氓气质1 小时前
Spring AI 技术细节:RAG QuestionAnswerAdvisor 设计与实现
java·人工智能·spring
斑马1392 小时前
Linux软件编程学习笔记(十二)——TCP并发服务器模型
java·服务器·网络
冬栈程序设计2 小时前
Springboot宿舍管理系统【670099】 -附源码(开箱即用)
java·毕业设计·springboot·课程设计·程序设计·大作业
2601_962284502 小时前
北京Java全栈开发培训 Web前端开发 软件测试就业培训班
java·软件测试·web前端·全栈开发·就业培训
姜太小白2 小时前
【Oracle】记排查sh脚本调用SQL执行卡顿的排查总结
数据库·sql·oracle
bgy66662 小时前
MariaDB 数据库 基础详解
数据库·mariadb
自动化监测Learner2 小时前
QGIS 把geojson数据导入 PostgreSQL/PostGIS 的完整流程
数据库·postgresql