Java 多线程:Semaphore 信号量与并发限流
一、Semaphore 的基本原理
1. 什么是 Semaphore?
Semaphore(信号量)是 Java JUC 并发包提供的同步工具,主要用于控制同时访问某个资源的任务数量。
其核心机制是许可证(Permit)。
例如:
Semaphore semaphore = new Semaphore(3);表示初始有 3 个许可证。
当所有业务线程都必须先获得一个许可证才能进入受控区域时,同一时刻最多允许 3 个线程进入。
初始许可证:3
线程 A:acquire() 成功,剩余 2
线程 B:acquire() 成功,剩余 1
线程 C:acquire() 成功,剩余 0
线程 D:没有许可证,等待
线程 E:没有许可证,等待
线程 A:release(),释放许可证
线程 D:获得许可证,继续执行重点:Semaphore 限制的是同时进入受控区域的任务数量,而不是系统创建的线程总数量。
2. Semaphore 的核心方法
其中最常用的是:
semaphore.acquire();
semaphore.release();
semaphore.tryAcquire();3. acquire() 和 release()
完整 Java 8 示例:
import java.util.concurrent.Semaphore;
public class SemaphoreDemo {
private static final Semaphore SEMAPHORE =
new Semaphore(2);
public static void main(String[] args) {
for (int i = 1; i <= 5; i++) {
Thread thread = new Thread(() -> {
try {
// 获取许可证,没有则等待
SEMAPHORE.acquire();
try {
System.out.println(
Thread.currentThread().getName()
+ " 开始执行"
);
Thread.sleep(2000);
} finally {
// 释放许可证
SEMAPHORE.release();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "worker-" + i);
thread.start();
}
}
}运行结果的具体顺序不固定,但正常情况下最多只有两个线程同时进入受控区域。
执行过程:
Semaphore(2)
|
v
线程 A 获取许可证
线程 B 获取许可证
|
v
许可证数量变为 0
|
v
线程 C、D、E 等待
|
v
A 或 B 完成业务
|
v
release()
|
v
等待线程获得许可证
|
v
继续执行4. 为什么必须在 finally 中 release()?
不推荐:
semaphore.acquire();
executeTask();
semaphore.release();如果 executeTask() 抛出异常,release() 就不会执行,导致许可证泄漏。
推荐:
semaphore.acquire();
try {
executeTask();
} finally {
semaphore.release();
}这里有一个重要细节:
acquire() 应该放在 try-finally 之前,或者使用 acquired 标记,确保只有成功获取许可证后才释放。
因为如果 acquire() 被中断并抛出 InterruptedException,它并没有成功获取许可证,不应该执行 release()。
二、Semaphore 底层原理:AQS 共享模式
1. Semaphore 为什么基于 AQS?
Semaphore 底层使用 AQS(AbstractQueuedSynchronizer)实现同步控制。
结构:
Semaphore
|
v
Sync(继承 AQS)
|
├── NonfairSync(非公平)
|
└── FairSync(公平)AQS 使用一个 volatile int 类型的 state 记录同步状态:
private volatile int state;在 Semaphore 中:
state 表示当前可用许可证数量。
例如:
Semaphore semaphore = new Semaphore(3);初始:
state = 3线程 A 获取一个许可证:
state = 2线程 B 获取一个许可证:
state = 1线程 C 获取一个许可证:
state = 0线程 D 再次尝试获取,没有可用许可证,就会进入 AQS 同步队列等待。
2. acquire() 底层如何实现?
Semaphore 使用 AQS 共享模式。
Java 8 中核心调用关系:
Semaphore.acquire()
|
v
AQS.acquireSharedInterruptibly(1)
|
v
tryAcquireShared(1)
|
├── 许可证充足
| |
| v
| CAS 扣减 state
| |
| v
| 获取成功
|
└── 许可证不足
|
v
进入 AQS 同步队列
|
v
LockSupport.park()
|
v
等待许可证释放非公平获取许可证的核心逻辑可简化为:
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0) {
return remaining;
}
if (compareAndSetState(available, remaining)) {
return remaining;
}
}注意:
CAS 保证许可证数量修改的原子性。
CAS 失败时可以重新尝试。
许可证不足时,通过 AQS 管理等待线程。
被唤醒不代表一定获取成功,还需要重新检查同步状态。
3. release() 底层如何实现?
当线程调用:
semaphore.release();会通过 AQS 的共享释放机制增加 state。
简化过程:
release()
|
v
tryReleaseShared()
|
v
CAS 增加 state
|
v
执行共享模式唤醒传播
|
v
等待线程有机会重新获取许可证例如:
state = 0
线程 A:release()
state = 1
线程 B:被唤醒
线程 B:尝试 acquire()
成功后:
state = 0这里的关键是:
release() 增加许可证,并让等待线程有机会继续竞争,但不保证某个指定线程立即获得许可证。
4. 为什么是共享模式?
AQS 有两种重要模式:
例如:
Semaphore semaphore = new Semaphore(3);允许最多三个线程各获取一个许可证。
因此 Semaphore 采用 AQS 共享模式。
三、公平信号量与非公平信号量
1. 非公平信号量
Semaphore semaphore = new Semaphore(3);默认使用非公平模式。
也可以显式指定:
Semaphore semaphore = new Semaphore(3, false);非公平模式下,新来的线程可能直接通过 CAS 获取刚释放的许可证,即使已经有其他线程在队列中等待。
因此可能发生插队。
2. 公平信号量
Semaphore semaphore = new Semaphore(3, true);公平模式会考虑 AQS 队列中是否有排在前面的等待线程。
Java 8 中涉及:
hasQueuedPredecessors();如果当前线程前面已经存在等待线程,公平获取逻辑通常不会允许它直接插队。
3. 公平与非公平的区别
注意:
即使创建公平信号量:
Semaphore semaphore = new Semaphore(3, true);直接调用:
semaphore.tryAcquire();仍然可能插队。
因为无参 tryAcquire() 不遵循公平性检查。
如果希望遵循公平策略,可以使用:
semaphore.tryAcquire(0, TimeUnit.SECONDS);该方法还支持中断,需要处理 InterruptedException。
四、Semaphore 与 ReentrantLock 的区别
虽然两者都用于控制并发,但作用不同。
1. ReentrantLock
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 同一时刻只有一个线程执行
} finally {
lock.unlock();
}适合需要互斥访问共享资源的场景。
2. Semaphore
Semaphore semaphore = new Semaphore(3);
semaphore.acquire();
try {
// 最多允许三个任务各持有一个许可证
} finally {
semaphore.release();
}适合限制同时执行的任务数量。
3. 为什么 Semaphore 不具备可重入语义?
ReentrantLock 会记录当前锁的持有线程以及重入次数。
Semaphore 不会记录某个线程持有多少许可证。
例如:
Semaphore semaphore = new Semaphore(1);
semaphore.acquire();
// 当前线程再次申请一个许可证
semaphore.acquire();如果没有其他线程释放许可证,第二次 acquire() 就会等待。
即使当前线程已经获取过许可证,也不会自动允许重入。
重点:Semaphore 不识别线程所有权,只管理许可证数量。
4. 为什么其他线程可以调用 release()?
例如:
// 线程 A
semaphore.acquire();
// 线程 B
semaphore.release();这是允许的。
因为 Semaphore 不会检查释放者是否曾经获取许可证。
但是正常业务中必须保证许可证获取与释放配对,否则可能导致许可证数量异常。
五、生产实践:Spring Boot 并发控制
1. 为什么需要限制数据库并发?
假设某个商品查询接口需要执行复杂 SQL。
如果同时有大量请求:
100 个请求
|
v
100 个线程尝试查询数据库
|
v
数据库连接池竞争
|
v
数据库负载增加可以使用 Semaphore 限制进入这类复杂查询的并发数量。
例如最多允许 10 个请求同时执行。
2. Spring Boot 示例
import org.springframework.stereotype.Service;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.RejectedExecutionException;
@Service
public class ProductQueryService {
// 当前 JVM 最多允许 10 个受控查询同时执行
private final Semaphore semaphore =
new Semaphore(10);
public String queryProduct(Long productId) {
boolean acquired = false;
try {
// 最多等待 500 毫秒
acquired = semaphore.tryAcquire(
500,
TimeUnit.MILLISECONDS
);
if (!acquired) {
throw new RejectedExecutionException(
"当前查询请求过多,请稍后重试"
);
}
return doQuery(productId);
} catch (InterruptedException e) {
// 恢复中断标记
Thread.currentThread().interrupt();
throw new IllegalStateException(
"等待查询资源时被中断",
e
);
} finally {
// 只有获取成功才释放
if (acquired) {
semaphore.release();
}
}
}
private String doQuery(Long productId) {
// 举例:调用 Mapper 查询数据库
return "商品 ID:" + productId;
}
}这个示例包含几个生产实践:
Semaphore 定义为 Spring 单例 Service 的成员变量,供当前实例中的多个请求线程共享。
控制同一时刻最多 10 个线程执行受控查询。
限制等待时间,避免无限期阻塞请求。
获取成功才释放许可证,防止错误增加许可证。
通过 finally 防止异常导致许可证泄漏。
响应 InterruptedException,并恢复中断标记。
注意:示例中的查询方法只是占位逻辑,实际接入时应替换为 Mapper 或其他数据库访问代码。
3. Semaphore 与数据库连接池
这里需要区分两个概念:
Semaphore:控制进入某段业务代码的并发任务数量。
HikariCP:管理和复用数据库连接。
例如:
HikariCP 最大连接数:20
Semaphore 许可证:10代表该受控查询最多允许 10 个线程同时进入,但不代表数据库只能使用 10 个连接。
其他未经过 Semaphore 的业务仍然可能占用数据库连接。
因此,Semaphore 不能替代数据库连接池,也不能单独保证数据库不会过载。
4. Semaphore 不等于 QPS 限流
这是一个非常重要的区别。
Semaphore 控制并发数,不直接控制单位时间内的请求数量。
例如:
Semaphore semaphore = new Semaphore(10);表示最多 10 个任务同时进入。
假设每个请求耗时 100 毫秒,且资源充足、任务能够持续执行,理论吞吐量可达到约:
10 ÷ 0.1 = 100 次/秒如果每个请求只耗时 10 毫秒,理论吞吐能力又会增加。
因此 Semaphore(10) 不代表每秒只能处理 10 次请求。
如果要求接口每秒最多处理 100 次请求,应该考虑专门的限流算法,而不是只使用 Semaphore。
5. Docker 多实例注意事项
假设 Spring Boot 部署了三个容器:
容器 A:Semaphore(10)
容器 B:Semaphore(10)
容器 C:Semaphore(10)每个容器都有自己的 JVM。
因此三个容器合计可能同时有:
10 + 10 + 10 = 30个任务进入受控区域。
Semaphore 是 JVM 内部并发控制工具,不是分布式信号量。
如果需要限制整个集群的并发数量,需要使用跨实例协调机制。
六、Semaphore 的常见陷阱
1. release() 调用次数超过 acquire()
例如:
Semaphore semaphore = new Semaphore(3);
// 没有获取许可证,直接释放
semaphore.release();执行后:
初始许可证:3
release() 后:4Semaphore 不检查释放者身份,也不会自动把许可数量限制在构造时的初始值。
因此不能随意调用 release()。
2. 使用 availablePermits() 判断后再获取
不推荐:
if (semaphore.availablePermits() > 0) {
semaphore.acquire();
}因为检查和获取不是一个原子操作。
例如:
线程 A:检查许可证数量为 1
线程 B:获取最后一个许可证
线程 A:调用 acquire()
线程 A:只能等待推荐:
if (semaphore.tryAcquire()) {
try {
// 执行业务
} finally {
semaphore.release();
}
}直接使用 tryAcquire() 完成原子获取判断。
3. 长时间等待导致请求堆积
例如:
semaphore.acquire();如果业务耗时很长,许可证迟迟不释放,大量请求线程就可能进入等待状态。
这会增加 Web 服务器线程池压力。
对于 HTTP 请求,通常更推荐:
semaphore.tryAcquire(
500,
TimeUnit.MILLISECONDS
);限制等待时间,并根据业务定义超时后的处理策略。
4. 不要在异常路径错误释放许可证
例如:
boolean acquired = false;
try {
acquired = semaphore.tryAcquire();
if (!acquired) {
return;
}
// 执行业务
} finally {
if (acquired) {
semaphore.release();
}
}使用 acquired 标记,可以防止获取失败时错误调用 release()。
5. 不要将 Semaphore 当作分布式锁
Semaphore 本身不保证跨 JVM 的互斥。
即使将许可证数量设置为 1:
Semaphore semaphore = new Semaphore(1);它也只是在当前 JVM 中限制一个持许可任务。
而且许可证不与线程所有权绑定,因此它也不等价于 ReentrantLock。
七、重点总结
需要重点记住:
Semaphore 通过许可证控制同时访问资源的任务数量。
acquire() 获取许可证,没有许可证时等待。
release() 释放许可证,并使等待线程有机会继续执行。
Semaphore 底层基于 AQS 共享模式实现。
AQS 的 state 表示当前可用许可证数量。
Semaphore 不具备 ReentrantLock 的可重入和线程所有权语义。
默认使用非公平模式。
无参 tryAcquire() 即使在公平模式下也可能插队。
成功获取许可证后,必须保证正确释放。
Semaphore 控制并发数,不直接控制 QPS。
Semaphore 只能协调当前 JVM 中共享同一个实例的线程。
生产环境中应考虑获取许可证的等待超时、异常释放和请求堆积问题。
八、常见问题与答案
1. Semaphore 是什么?
Semaphore 是通过许可证控制并发访问数量的同步工具。
2. Semaphore 为什么使用 AQS 共享模式?
因为它允许多个线程同时获得许可证,而不是像独占锁一样只允许一个线程持有锁。
3. acquire() 没有许可证时会发生什么?
线程会等待获取许可证;如果在等待期间被中断,可能抛出 InterruptedException。
4. release() 有什么作用?
增加可用许可证数量,并让等待线程有机会继续获取许可证。
5. Semaphore 的许可证与线程绑定吗?
不绑定。其他线程也可以 release(),但业务中通常需要保证获取与释放配对。
6. Semaphore 和 ReentrantLock 有什么区别?
ReentrantLock 用于互斥访问,Semaphore 用于控制同时进入受控区域的任务数量。
7. 公平与非公平 Semaphore 有什么区别?
公平模式尽量遵循队列顺序;非公平模式允许新线程插队,通常吞吐量更高。
8. Semaphore 可以限制 QPS 吗?
不能直接限制。Semaphore 控制同时执行的任务数量,而不是每秒请求次数。
9. Semaphore 可以跨 Docker 容器使用吗?
不能直接跨容器协调。各 JVM 中的 Semaphore 相互独立。
10. 如果 release() 多执行一次会怎样?
许可证数量可能超过初始值,导致实际允许进入的任务数量超过原计划。
11. 为什么不能使用 availablePermits() 先判断再 acquire()?
因为两次操作之间可能被其他线程插入,判断结果可能失效。
12. Semaphore 可以代替数据库连接池吗?
不能。Semaphore 只能限制进入受控代码的任务数量,连接池负责实际数据库连接的创建、管理和复用。
九、复习测试
Semaphore 是什么?主要用于解决什么问题?
创建 Semaphore(5),表示什么?
acquire()、tryAcquire() 和 release() 有什么区别?
如果没有可用许可证,acquire() 会发生什么?
为什么 Semaphore 使用 AQS 共享模式?
Semaphore 的 state 表示什么?
公平信号量和非公平信号量有什么区别?
为什么无参 tryAcquire() 可能不遵循公平性?
为什么 Semaphore 不具有 ReentrantLock 的可重入语义?
为什么 Semaphore 允许不同线程分别调用 acquire() 和 release()?
为什么成功获取许可证后需要使用 finally 释放?
如果 release() 比 acquire() 多调用一次,会发生什么?
Semaphore 限制 10 个并发,是否代表每秒最多处理 10 次请求?
为什么不能通过 availablePermits() 判断后直接 acquire()?
为什么三个 Docker 容器分别创建 Semaphore(10),可能同时有 30 个任务进入受控区域?
ReentrantLock、Semaphore 和数据库连接池分别解决什么问题?
