Semaphore详解

217 阅读13分钟

一、概述

Semaphorejava.util.concurrent 包中一个经典的同步工具类,它通过许可(permits)的概念来控制同时访问某一资源的线程数量。从本质上讲,Semaphore 维护了一个内部计数器,每个线程在执行临界区代码前需要先获取许可acquire),执行完毕后释放许可release)。当计数器为零时,后续试图获取许可的线程将被阻塞,直到有可用许可释放。

Semaphore 非常适合用于资源池限流(如数据库连接池)、并发任务限数流量控制等场景。JDK 8 中的 Semaphore 基于 AbstractQueuedSynchronizer(AQS)实现,支持公平非公平两种模式,并提供了可中断、不可中断以及超时获取等多种语义。

本文基于 JDK 8 源码,从核心方法、特性原理、时序流程、应用场景、吞吐量分析及注意事项六个维度,全面剖析 Semaphore 的实现细节与设计思想。


二、核心方法说明

Semaphore 提供了丰富的获取/释放许可的方法,以下按方法签名、参数、返回值、异常分别说明。

方法签名参数返回值异常描述
void acquire()voidInterruptedException获取一个许可,若无可用的则阻塞直到可用或被中断。
void acquire(int permits)permits - 需要获取的许可数voidInterruptedException, IllegalArgumentException获取指定数量的许可,阻塞直到全部获得。
void acquireUninterruptibly()void获取一个许可,不可中断(忽略中断标志)。
void acquireUninterruptibly(int permits)permits - 许可数voidIllegalArgumentException获取指定数量许可,不可中断。
boolean tryAcquire()boolean - 是否成功尝试获取一个许可,立即返回成功/失败。
boolean tryAcquire(long timeout, TimeUnit unit)timeout - 超时时间
unit - 时间单位
boolean - 是否成功InterruptedException在超时时间内尝试获取一个许可,可中断。
boolean tryAcquire(int permits)permits - 许可数booleanIllegalArgumentException尝试获取指定数量许可,立即返回。
boolean tryAcquire(int permits, long timeout, TimeUnit unit)同上组合booleanInterruptedException, IllegalArgumentException带超时的指定数量尝试获取。
void release()void释放一个许可,唤醒等待线程。
void release(int permits)permits - 释放的许可数voidIllegalArgumentException释放指定数量许可。
int availablePermits()int - 当前可用许可数查询当前剩余许可数(瞬时值)。
int drainPermits()int - 获取到的许可数获取并返回所有当前可用的许可,将可用许可置为零。
protected void reducePermits(int reduction)reduction - 要减少的数量voidIllegalArgumentException减少若干许可(非公开,供子类扩展)。
boolean isFair()boolean - 是否公平模式判断当前信号量是否公平。
boolean hasQueuedThreads()boolean - 是否有等待线程查询是否有线程在等待获取许可。
int getQueueLength()int - 等待线程数返回等待队列中的线程估计数。

所有可能抛出 IllegalArgumentException 的方法均因 permits < 0 触发。acquire 系列方法如果线程被中断,会抛出 InterruptedException


三、核心特性及其实现原理

Semaphore 的核心功能全部委托给内部的 Sync 子类(FairSyncNonfairSync),而 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 系列方法不阻塞线程,立即返回成功/失败。其内部调用 nonfairTryAcquireSharedtryAcquireShared 进行一次 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: 执行临界区

详细描述:

  1. 初始化Semaphore 内部 state = 1,表示有 1 个可用许可。
  2. T1 获取:调用 acquire() -> tryAcquireShared(1) 检测到 state=1>0,通过 CAS 将 state 更新为 0,返回剩余 0(>=0),成功获取,线程继续。
  3. T2 获取:此时 state=0tryAcquireShared 返回 -1。AQS 将其加入等待队列(CLH 队列尾),并调用 park() 挂起 T2。
  4. T1 释放:调用 release() -> tryReleaseShared(1),CAS 将 state 从 0 改为 1,返回 true。AQS 检测到队列有等待节点,调用 unpark() 唤醒 T2。
  5. 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 的 headtail 字段,导致多核 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 的原理与最佳实践。