一、概述
Semaphore 是 java.util.concurrent 包中一个经典的同步工具类,它通过许可(permits)的概念来控制同时访问某一资源的线程数量。从本质上讲,Semaphore 维护了一个内部计数器,每个线程在执行临界区代码前需要先获取许可(acquire),执行完毕后释放许可(release)。当计数器为零时,后续试图获取许可的线程将被阻塞,直到有可用许可释放。
Semaphore 非常适合用于资源池限流(如数据库连接池)、并发任务限数、流量控制等场景。JDK 8 中的 Semaphore 基于 AbstractQueuedSynchronizer(AQS)实现,支持公平与非公平两种模式,并提供了可中断、不可中断以及超时获取等多种语义。
本文基于 JDK 8 源码,从核心方法、特性原理、时序流程、应用场景、吞吐量分析及注意事项六个维度,全面剖析 Semaphore 的实现细节与设计思想。
二、核心方法说明
Semaphore 提供了丰富的获取/释放许可的方法,以下按方法签名、参数、返回值、异常分别说明。
| 方法签名 | 参数 | 返回值 | 异常 | 描述 |
|---|---|---|---|---|
void acquire() | 无 | void | InterruptedException | 获取一个许可,若无可用的则阻塞直到可用或被中断。 |
void acquire(int permits) | permits - 需要获取的许可数 | void | InterruptedException, IllegalArgumentException | 获取指定数量的许可,阻塞直到全部获得。 |
void acquireUninterruptibly() | 无 | void | 无 | 获取一个许可,不可中断(忽略中断标志)。 |
void acquireUninterruptibly(int permits) | permits - 许可数 | void | IllegalArgumentException | 获取指定数量许可,不可中断。 |
boolean tryAcquire() | 无 | boolean - 是否成功 | 无 | 尝试获取一个许可,立即返回成功/失败。 |
boolean tryAcquire(long timeout, TimeUnit unit) | timeout - 超时时间unit - 时间单位 | boolean - 是否成功 | InterruptedException | 在超时时间内尝试获取一个许可,可中断。 |
boolean tryAcquire(int permits) | permits - 许可数 | boolean | IllegalArgumentException | 尝试获取指定数量许可,立即返回。 |
boolean tryAcquire(int permits, long timeout, TimeUnit unit) | 同上组合 | boolean | InterruptedException, IllegalArgumentException | 带超时的指定数量尝试获取。 |
void release() | 无 | void | 无 | 释放一个许可,唤醒等待线程。 |
void release(int permits) | permits - 释放的许可数 | void | IllegalArgumentException | 释放指定数量许可。 |
int availablePermits() | 无 | int - 当前可用许可数 | 无 | 查询当前剩余许可数(瞬时值)。 |
int drainPermits() | 无 | int - 获取到的许可数 | 无 | 获取并返回所有当前可用的许可,将可用许可置为零。 |
protected void reducePermits(int reduction) | reduction - 要减少的数量 | void | IllegalArgumentException | 减少若干许可(非公开,供子类扩展)。 |
boolean isFair() | 无 | boolean - 是否公平模式 | 无 | 判断当前信号量是否公平。 |
boolean hasQueuedThreads() | 无 | boolean - 是否有等待线程 | 无 | 查询是否有线程在等待获取许可。 |
int getQueueLength() | 无 | int - 等待线程数 | 无 | 返回等待队列中的线程估计数。 |
所有可能抛出
IllegalArgumentException的方法均因permits < 0触发。acquire系列方法如果线程被中断,会抛出InterruptedException。
三、核心特性及其实现原理
Semaphore 的核心功能全部委托给内部的 Sync 子类(FairSync 或 NonfairSync),而 Sync 继承自 AbstractQueuedSynchronizer。理解 AQS 是掌握 Semaphore 的关键。以下逐条分析 Semaphore 的核心特性,并给出 JDK 8 关键源码片段。
1. 许可计数管理
Semaphore 内部使用 AQS 的 state 变量来保存当前可用许可数。state 是一个 volatile int,通过 CAS 进行原子更新。
// AQS 中的 state 字段
private volatile int state;
// Sync 构造器设置初始许可数
Sync(int permits) {
setState(permits);
}
// 获取可用许可数
final int getPermits() {
return getState();
}
特性: 许可数的增减是线程安全的,因为所有修改都通过 CAS 或 AQS 的锁机制完成。
2. 获取许可(acquire)的阻塞与唤醒
当线程调用 acquire() 时,若 state > 0,则通过 CAS 将 state 减 1,并立即返回;若 state == 0,则线程被包装成节点加入 AQS 的等待队列并阻塞(park)。当其他线程调用 release() 增加许可后,会尝试唤醒队列头部线程。
源码关键点(以非公平模式 NonfairSync 为例):
// Semaphore.NonfairSync
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
// Sync.nonfairTryAcquireShared
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
- 返回值
remaining < 0表示许可不足,需要阻塞;remaining >= 0表示获取成功,直接返回。 - AQS 的
acquireSharedInterruptibly会根据tryAcquireShared的返回值决定是否进入等待队列。
释放许可:
// Sync.tryReleaseShared
protected final boolean tryReleaseShared(int releases) {
for (;;) {
int current = getState();
int next = current + releases;
if (next < current) // 防止溢出
throw new Error("Maximum permit count exceeded");
if (compareAndSetState(current, next))
return true;
}
}
- 释放成功后,AQS 的
releaseShared会调用doReleaseShared唤醒后继节点。
3. 公平模式与非公平模式
- 非公平模式(默认):新来的线程在尝试获取许可时,不检查等待队列,直接尝试 CAS 抢占。这可能导致等待队列中的线程“饥饿”。
- 公平模式:新线程首先检查
hasQueuedPredecessors()(队列中是否有比自己等待更久的线程),若有则直接失败进入队列,保证先来先服务。
公平模式的 tryAcquireShared:
// FairSync.tryAcquireShared
protected int tryAcquireShared(int acquires) {
for (;;) {
// 关键:如果有等待线程,直接返回 -1 让当前线程入队
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
特性: 公平模式避免饥饿,但吞吐量较低;非公平模式吞吐量高,但可能造成某些线程长时间得不到许可。
4. 可中断与不可中断获取
Semaphore 提供了两组方法:acquire()(可中断)和 acquireUninterruptibly()(不可中断)。可中断的实现依赖于 AQS 的 doAcquireSharedInterruptibly,当线程在阻塞过程中收到中断信号,会抛出异常退出。不可中断版本则循环重试,不响应中断(但会记录中断状态)。
源码差异(AQS 中):
// 可中断
private void doAcquireSharedInterruptibly(int arg) throws InterruptedException {
// ... 入队
for (;;) {
// ...
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException(); // 响应中断
}
}
// 不可中断(Semaphore 自己实现?实际调用 AQS acquireShared 方法)
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg); // 该版本不抛异常
}
5. 尝试获取(tryAcquire)
tryAcquire 系列方法不阻塞线程,立即返回成功/失败。其内部调用 nonfairTryAcquireShared 或 tryAcquireShared 进行一次 CAS 尝试,不会进入 AQS 等待队列。超时版本则通过 LockSupport.parkNanos 实现有限等待。
// 非公平尝试,不阻塞
public boolean tryAcquire() {
return sync.nonfairTryAcquireShared(1) >= 0;
}
6. 动态调整许可数
Semaphore 支持 reducePermits(减少许可)和 drainPermits(清空许可)。这些方法主要供子类或特殊场景(如系统降级)使用。
// 减少指定数量的许可
final void reducePermits(int reductions) {
for (;;) {
int current = getState();
int next = current - reductions;
if (next > current) // 防止负向溢出(实际上 current<reductions 会导致负数)
throw new Error("Permit count underflow");
if (compareAndSetState(current, next))
return;
}
}
// 一次性获取所有剩余许可
final int drainPermits() {
for (;;) {
int current = getState();
if (current == 0 || compareAndSetState(current, 0))
return current;
}
}
四、Mermaid 时序图及详细描述
以下以非公平模式下,两个线程(T1 成功,T2 阻塞)获取许可,随后 T1 释放许可并唤醒 T2 的过程为例,绘制时序图。
sequenceDiagram
participant T1 as 线程 T1
participant T2 as 线程 T2
participant Sem as Semaphore<br/>(NonfairSync)
participant AQS as AQS队列<br/>(CLH)
participant P as LockSupport
Note over Sem: 初始 permits = 1
T1->>Sem: acquire()
activate Sem
Sem->>Sem: tryAcquireShared(1)
Sem->>Sem: getState()=1, CAS(1->0)成功
Sem-->>T1: 成功,继续执行
deactivate Sem
T2->>Sem: acquire()
activate Sem
Sem->>Sem: tryAcquireShared(1)
Sem->>Sem: getState()=0, remaining=-1
Sem->>AQS: 返回 <0,进入等待队列
AQS->>AQS: addWaiter(),将T2加入队列尾
AQS->>P: park() 阻塞T2
deactivate Sem
Note over T1: 执行临界区代码
T1->>Sem: release()
activate Sem
Sem->>Sem: tryReleaseShared(1)
Sem->>Sem: getState()=0, CAS(0->1)成功
Sem->>AQS: releaseShared() 唤醒后继
AQS->>P: unpark(T2)
deactivate Sem
T2-->>P: 被唤醒
T2->>Sem: 重新尝试 acquire (自旋)
Sem->>Sem: tryAcquireShared(1)
Sem->>Sem: getState()=1, CAS(1->0)成功
Sem-->>T2: 成功获取许可
T2->>T2: 执行临界区
详细描述:
- 初始化:
Semaphore内部state = 1,表示有 1 个可用许可。 - T1 获取:调用
acquire()->tryAcquireShared(1)检测到state=1>0,通过 CAS 将state更新为 0,返回剩余 0(>=0),成功获取,线程继续。 - T2 获取:此时
state=0,tryAcquireShared返回 -1。AQS 将其加入等待队列(CLH 队列尾),并调用park()挂起 T2。 - T1 释放:调用
release()->tryReleaseShared(1),CAS 将state从 0 改为 1,返回 true。AQS 检测到队列有等待节点,调用unpark()唤醒 T2。 - T2 唤醒后:从
park()返回,继续自旋尝试获取许可,此时state=1,CAS 成功,state变回 0,T2 获取许可并执行。
注意:非公平模式下,若在 T1 释放许可后、T2 被唤醒前,有新的 T3 调用
acquire(),T3 可能直接 CAS 成功“插队”,导致 T2 仍需继续等待。这正是非公平模式吞吐量高的原因之一。
五、实际应用场景与代码举例分析
场景一:限制数据库连接池的并发连接数
假设数据库连接池最大允许 10 个连接,每个线程使用连接前需获取许可,使用后释放。
public class DatabaseConnectionPool {
private final Semaphore semaphore;
private final Connection[] connections;
private final boolean[] used;
public DatabaseConnectionPool(int maxConnections) {
this.semaphore = new Semaphore(maxConnections, true); // 公平模式
this.connections = new Connection[maxConnections];
this.used = new boolean[maxConnections];
// 初始化连接对象...
}
public Connection getConnection() throws InterruptedException {
semaphore.acquire(); // 获取许可,没有可用则阻塞
return getNextAvailableConnection();
}
public void releaseConnection(Connection conn) {
markConnectionUnused(conn);
semaphore.release(); // 释放许可
}
private synchronized Connection getNextAvailableConnection() {
for (int i = 0; i < used.length; i++) {
if (!used[i]) {
used[i] = true;
return connections[i];
}
}
throw new RuntimeException("No free connection, but permit acquired?");
}
// ... 其他辅助方法
}
分析:通过 Semaphore 控制同时活跃的连接数,避免超出数据库限制。使用公平模式可防止某些线程长期得不到连接,但吞吐量略低。
场景二:限流器(限制 QPS)
限制每秒最多处理 10 个请求,可以使用 tryAcquire 配合定时重置许可。
public class SimpleRateLimiter {
private final Semaphore semaphore;
public SimpleRateLimiter(int maxPermitsPerSecond) {
this.semaphore = new Semaphore(maxPermitsPerSecond);
// 定时任务:每秒重置许可
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
executor.scheduleAtFixedRate(() -> {
int current = semaphore.availablePermits();
if (current < maxPermitsPerSecond) {
semaphore.release(maxPermitsPerSecond - current);
}
}, 0, 1, TimeUnit.SECONDS);
}
public boolean tryAcquire() {
return semaphore.tryAcquire();
}
}
分析:每个请求调用 tryAcquire() 尝试获取许可,获取成功则放行,失败则拒绝。定时任务每秒将许可补回最大值,实现简单的平滑限流。注意:release 方法可以超额释放,因此需要计算差值。
场景三:控制并发任务数,防止内存爆炸
例如需要处理 10000 个文件,但为了避免内存和 CPU 压力,同时只允许 5 个任务并行处理。
ExecutorService executor = Executors.newFixedThreadPool(20);
Semaphore semaphore = new Semaphore(5);
for (File file : files) {
executor.submit(() -> {
try {
semaphore.acquire();
processFile(file); // 耗时操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release();
}
});
}
六、吞吐量分析(为什么非公平模式吞吐量更高?)
1. 实验对比
假设同一台机器上,多个线程竞争一个许可数固定的 Semaphore,持续进行 acquire-release 循环。通过 JMH 测试可得出:
- 公平模式:吞吐量通常较低,约为非公平模式的 30%~70%。
- 非公平模式:吞吐量更高,尤其在高并发(线程数 >> 许可数)场景下优势明显。
2. 原因分析
(1)减少线程挂起与唤醒的开销
- 公平模式:当一个线程释放许可后,AQS 会精确唤醒等待队列的头部线程。被唤醒的线程从内核态切换回用户态,然后重新竞争锁(此时可能发生上下文切换,耗时约几微秒到几十微秒)。
- 非公平模式:释放许可的线程在唤醒队列头部线程之前,新来的线程有可能直接通过 CAS 获取到许可并继续执行。这样一来,队列中的线程可能永远不会被挂起(如果后续请求总是能立刻获得许可),或者挂起次数显著减少。上下文切换的减少直接提升了吞吐量。
(2)减少“惊群”与“无效竞争”
公平模式下,每个线程释放许可后只唤醒一个后继线程。但被唤醒的线程需要重新自旋获取许可,而在此期间如果其他线程(非队列头)也尝试 CAS,由于公平模式会检查 hasQueuedPredecessors(),它们会失败并加入队列,导致队列长度增长,后续唤醒成本增加。这种“唤醒 -> 入队 -> 再唤醒”的链式反应在公平模式下更加严重。
非公平模式允许所有线程(包括刚释放许可的线程)立即重试 CAS,减少了队列长度和唤醒次数。虽然 CAS 本身有一定 CPU 开销,但相比上下文切换,CAS 的代价小得多。
(3)缓存一致性与锁竞争角度
- 公平模式:为了保证顺序,每次获取前都要调用
hasQueuedPredecessors(),该方法需要读取 AQS 的head和tail字段,导致多核 CPU 缓存频繁失效。 - 非公平模式:直接
getState()然后 CAS,操作更简单,缓存一致性协议的开销较低。
3. 代价:饥饿问题
非公平模式的高吞吐量牺牲了公平性。在极端情况下,一个线程可能长时间获取不到许可(例如一直有新线程插队)。实际应用中,如果任务执行时间非常短(微秒级),饥饿概率很低;如果任务执行时间长且许可数极少,饥饿可能明显,此时应选用公平模式。
七、注意事项及具体原因
1. 必须正确释放许可(finally 块)
原因:Semaphore 不会自动释放许可。如果在获取许可后临界区抛出异常,而没有在 finally 中调用 release(),许可将永久减少,最终导致所有线程阻塞(死锁)。
semaphore.acquire();
try {
// 业务逻辑
} finally {
semaphore.release(); // 确保释放
}
2. 许可数不是线程绑定的,任何线程都可以释放任意数量许可
原因:Semaphore 不记录许可的“拥有者”,线程 A 可以 release() 由线程 B 获得的许可,甚至可以在未获取时直接调用 release() 增加许可数。这可能导致许可数失控。设计上需格外小心,避免错误的释放操作。
3. drainPermits() 和 reducePermits() 会破坏许可计数一致性
原因:这两个方法直接修改 state,不检查是否超出范围。如果与其他 acquire/release 并发,可能导致 state 变为负数(虽然 reducePermits 有简单检查,但仍有竞态)。仅建议在单线程控制或初始化阶段使用,例如系统降级时主动减少许可。
4. 公平模式下吞吐量低,不适合高性能场景
原因:如前文分析,公平模式严格排队导致大量线程挂起/唤醒,且 hasQueuedPredecessors 带来额外开销。除非需要避免饥饿,否则优先使用非公平模式。
5. 避免 acquire(int permits) 与 release(int permits) 数量不匹配
原因:获取 3 个许可后只释放 1 个,会逐渐消耗许可总数。应确保成对使用相同数量。可以使用 try-finally 块并在本地保存 permits 变量。
6. 中断处理需谨慎
原因:acquire() 会抛出 InterruptedException,如果捕获后不恢复中断标志(Thread.currentThread().interrupt()),外层代码无法感知中断。在 finally 块中释放许可前应确保中断标志正确处理。
7. 许可数很大时,CAS 自旋可能造成 CPU 空转
原因:当许可数很大(例如 10 万)且多个线程竞争时,tryAcquireShared 中的 for(;;) 循环可能长时间 CAS 失败。虽然这种情况很少,但理论上应避免设计需要瞬间大量许可的 Semaphore。
总结
Semaphore 是 JUC 中轻量且强大的限流/并发控制工具,其基于 AQS 的设计体现了 Doug Lea 对锁、队列、CAS 的精妙结合。理解它的公平与非公平模式、许可管理机制以及底层 AQS 同步队列,有助于开发者在高并发系统中做出正确的选择。
- 需要严格顺序且可容忍一定性能损失 → 公平模式。
- 追求高吞吐、任务执行时间短 → 非公平模式。
- 永远记得在
finally中释放许可。 - 合理选用
tryAcquire避免线程无限阻塞。
希望本文能帮助读者深入掌握 Semaphore 的原理与最佳实践。