《Java 并发编程·JUC 源码与容器》4.1 Semaphore 与许可模型

拆开 Semaphore 的 Sync,看许可如何直接复用 AQS 的 state、公平版为何只多一次排队检查、许可为负时 reducePermits 与 drainPermits 的欠账语义,再用下游通知扇出的限流实验验证并发上限确实收敛到 2。

承接的问题

Semaphore 是 JUC 里最像计数器的同步器:它不关心许可被谁拿走,只关心还剩几个。正因为没有持有者,它才能一次放行多个线程,是共享模式最直白的应用。1.3 已经讲了共享模式的传播链,这一节只回答与 Semaphore 自身设计有关的问题:许可存在哪、公平版多做了什么、许可数为什么会变成负数,以及用信号量做并发上限时哪些边界会咬人。

Semaphore 对外有两条获取路径:acquire() 会阻塞排队,tryAcquire() 只尝试一次。这两条路径在源码里走的是不同的方法,公平性也因此不同,后面会看到。

许可就是 AQS 的 state

Semaphore 只有一个实例字段 private final Sync sync,许可数直接放在 AQS 的 int state 里。Sync 是抽象类,只放公平与非公平共用的逻辑:

// JDK 25 源码摘录,已删减
abstract static class Sync extends AbstractQueuedSynchronizer {
    Sync(int permits) {
        setState(permits);
    }

    final int getPermits() {
        return getState();
    }

    final int nonfairTryAcquireShared(int acquires) {
        for (;;) {
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }

    protected final boolean tryReleaseShared(int releases) {
        for (;;) {
            int current = getState();
            int next = current + releases;
            if (next < current) // overflow
                throw new Error("Maximum permit count exceeded");
            if (compareAndSetState(current, next))
                return true;
        }
    }
}

三处设计值得注意。

第一,tryAcquireShared 返回的是扣减后的剩余许可,不是布尔值。失败时返回 remaining(一个负数),成功时返回 0 或正数。1.3 讲过,JDK 25 的 acquire 只判断 >= 0,所以这里返回 -3 还是 -1 对 AQS 没有区别;但返回值对调用者有意义,tryAcquire() 就是拿它和 0 比较。

第二,tryReleaseShared 恒返回 true。这与 CountDownLatch 相反:闩锁只有减到 0 那一次返回 true,而信号量每释放一个许可都应该唤醒一个等待者,所以每次 release 都要让 releaseShared 走到 signalNext(head)。如果这里返回 false,许可数会增加,但等待者永远不会醒。

第三,上溢和下溢都抛 Error 而不是异常。next < current 说明 int 溢出,next > current 说明减多了。这两个分支正常业务下不可达,写成 Error 是在声明“这是编程错误,不是运行时状况”。

公平版只多了一次排队检查

非公平版直接把调用转给 nonfairTryAcquireShared。公平版则先问一句“队列里有人吗”:

// JDK 25 源码摘录,已删减
static final class FairSync extends Sync {
    protected int tryAcquireShared(int acquires) {
        for (;;) {
            if (hasQueuedPredecessors())
                return -1;
            int available = getState();
            int remaining = available - acquires;
            if (remaining < 0 ||
                compareAndSetState(available, remaining))
                return remaining;
        }
    }
}

hasQueuedPredecessors() 在“有排队线程排在当前线程前面”时返回 true。公平版一旦看到它,就直接返回 -1 去排队,连 CAS 都不试。非公平版没有这一步,新来的线程可以和队首抢同一个许可。

后果有两条:非公平版吞吐更高,因为它减少了线程唤醒和上下文切换;非公平版也不承诺顺序,某个等待者可能长期抢不到。Semaphore(int permits) 默认非公平,Semaphore(int permits, boolean fair) 才走 FairSync。

还有一个容易忽略的点:tryAcquire() 在 JDK 25 里直接调用 sync.nonfairTryAcquireShared(1),即使 fair = true 也不排队。这跟 ReentrantLock.tryLock() 恒为非公平是同一个取舍:一次试探不值得付出排队的代价。需要“公平的尝试”就别用 tryAcquire(),用带超时的 tryAcquire(timeout, unit),它会走 tryAcquireSharedNanos,而后者在公平版里会检查队列。

acquire 的三种阻塞语义

都叫 acquire,但中断行为不同,选错了会在压测或停机时表现得很奇怪:

方法底层入口等待中收到中断
acquire() / acquire(int)acquireSharedInterruptibly抛 InterruptedException,清除中断标志
acquireUninterruptibly()acquireShared继续等待,返回时再设置中断标志
tryAcquire(timeout, unit)tryAcquireSharedNanos抛 InterruptedException,或超时返回 false

acquire(int permits) 还有两个容易忽略的细节。一是参数为负会直接抛 IllegalArgumentException,不会出现“负数许可被当成 release”的诡异行为;这一点源码里是一行显式校验。二是它原子地拿够 permits 个许可,而不是循环调用 acquire(),所以要么一次拿到全部,要么一个都不拿。

中断语义在 javadoc 里写得很具体:线程在等待多个许可时被中断,已经“预分配”给它的许可不会被吞掉,而是像调用过 release() 一样转给其他等待者。这保证了许可总数不会因为中断而漂移。自己封装信号量时不要用 try { acquire(n) } catch (InterruptedException e) { release(n) } 去“补偿”,那会凭空多出 n 个许可。

许可可以是负数

reducePermits 和 drainPermits 是不阻塞的状态操作,前者是 protected,只能给子类用:

// JDK 25 源码摘录,已删减
final void reducePermits(int reductions) {
    for (;;) {
        int current = getState();
        int next = current - reductions;
        if (next > current) // underflow
            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;
    }
}

reducePermits 的 javadoc 说它用于“用信号量跟踪会变得不可用的资源”的子类,也就是资源池。池里有 5 个连接,其中一个判定损坏被销毁,容量从 5 降到 4,就调用 reducePermits(1)。如果 5 个许可此刻全部借出,state 会变成 -1:这表示“欠一个许可”。下一次 release 只是把欠账还上,不会真的唤醒新线程。

写成 protected 是有意的:缩容是池的所有者才能做的决定,随便哪个调用方都能减许可会让计数失去意义。本机实测(子类暴露 reducePermits 后调用):

initial permits=3
drainPermits=3, then permits=0
drainPermits again=0
after release(1) + reducePermits(2): permits=-1
tryAcquire when permits<0: false
after release(1): permits=0, tryAcquire=false
after tryAcquire: permits=0

drainPermits 返回的是“被抽走的数量”,不是剩余数量,所以第二次调用返回 0。release(1) 把 -1 补到 0 之后仍然没有可借的许可,tryAcquire() 继续失败。这也是负许可最实用的地方:池缩容时不需要等待在借的资源归还,欠账会在后续归还中自动结清。

在 libraryd-conc 里落地

libraryd-conc 的借阅成功通知要扇出到 mail、sms、push 三个下游。线程池可以给 6 个线程,但下游运维只答应“每个下游最多 2 个并发调用”,超了会触发对端限流。这是一个典型的“资源数量固定、但不关心谁在用”的场景,正好用信号量:

Semaphore permits = new Semaphore(2);       // 下游只接受 2 个并发调用
AtomicInteger inFlight = new AtomicInteger();
AtomicInteger peak = new AtomicInteger();
ExecutorService pool = Executors.newFixedThreadPool(6);

for (Delivery job : jobs) {
    futures.add(pool.submit(() -> {
        permits.acquire();                  // 中断发生在拿许可之前,不会泄漏许可
        try {
            int now = inFlight.incrementAndGet();
            peak.accumulateAndGet(now, Math::max);
            System.out.printf("%s acquire %-4s %-13s inFlight=%d left=%d%n",
                    LocalTime.now(), job.downstream(), job.isbn(), now,
                    permits.availablePermits());
            Thread.sleep(50);               // 冒充一次 HTTP 调用
        } finally {
            inFlight.decrementAndGet();
            permits.release();
        }
        return null;
    }));
}

三个 ISBN、三个下游,共 9 个投递任务,inFlight 记录此刻真正在调用下游的任务数,peak 记录峰值。本机真实输出(省略重复行,elapsed 是单次运行值,不作基准):

00:38:42.815807 acquire sms  9787111213826 inFlight=2 left=0
00:38:42.815814 acquire mail 9787111213826 inFlight=1 left=0
00:38:42.883979 acquire mail 9787111544937 inFlight=2 left=0
00:38:42.887303 acquire push 9787111544937 inFlight=2 left=0
00:38:42.944145 acquire push 9787111213826 inFlight=2 left=0
00:38:42.947700 acquire sms  9787111544937 inFlight=2 left=0
00:38:42.995162 acquire mail 9787111641247 inFlight=2 left=0
00:38:43.003586 acquire sms  9787111641247 inFlight=2 left=0
00:38:43.047557 acquire push 9787111641247 inFlight=2 left=0
peak concurrency=2, elapsed=325 ms

peak concurrency=2 是这段代码唯一要证明的事:线程池有 6 个线程,但同一时刻最多 2 个在调用下游。left 全是 0 或 1,因为打印时另一个持有者还没释放。

注意 acquire() 写在 try 之外。如果把它放进 try 并在 finally 里无条件 release(),一次在等待中被中断的调用就会凭空多出一个许可——这是信号量最常见的一类计数漂移。

把这段逻辑收进一个薄封装,比在每个调用点手写 acquire/finally 可靠:

package com.plume.conc.core;

import java.util.concurrent.Semaphore;

/** 在许可内调用下游,不负责线程调度。 */
public final class BoundedNotifier {
    private final int capacity;
    private final Semaphore permits;
    private final DownstreamNotifier delegate;

    public BoundedNotifier(int capacity, DownstreamNotifier delegate) {
        this.capacity = capacity;
        this.permits = new Semaphore(capacity);
        this.delegate = delegate;
    }

    public void notify(String isbn, String downstream) {
        permits.acquireUninterruptibly();   // 通知是已决策的副作用,不在半路取消
        try {
            delegate.send(isbn, downstream);
        } finally {
            permits.release();
        }
    }

    public int inFlight() {
        return capacity - permits.availablePermits();
    }

    public interface DownstreamNotifier {
        void send(String isbn, String downstream);
    }
}

这里选 acquireUninterruptibly() 而不是 acquire():通知一旦决定要发,就属于“已经发生的业务事实”,中断只应该影响下一单,不应该让这一单凭空消失。代价是停机时必须等所有在途调用结束,所以 send 里要有自己的超时。inFlight() 可以直接接进 conc-ops 的指标,和 availablePermits() 的长期漂移一起看。

许可数怎么定,经验上按下面的顺序推:

依据做法风险
下游承诺的并发上限取其 60%–70% 作为许可数留出的余量用来吸收重试和突发
本端线程池大小许可数不超过池大小超过只会让任务在池里排队,白占内存
下游 P99 延迟许可数 ÷ 单次耗时 ≈ 稳态吞吐只用于估算,不要当 SLA

三个数取最小值。许可数设成线程池大小是常见错误:线程池 200、下游只接受 20 并发时,180 个线程会同时卡在 acquire 上,池被“等待许可”的任务占满,其他不经过这一层的任务也拿不到线程。

这个设计的代价

没有持有者,就没有归还校验。 release() 可以被任何线程调用,多调一次就多一个许可,AQS 不会报错。上面的 finally 是唯一的保险;一旦有人把 release() 写进 catch 分支或重复调用,许可总数会缓慢上涨,表现为“限流逐渐失效”,而不是抛异常。生产上要监控 availablePermits() 的长期漂移,卷三第 3 章展开。

不做时间窗口。 Semaphore 限制的是同时进行的数量,不是单位时间的速率。想限“每秒 100 次”,需要额外的令牌填充线程或者时间轮,4.3 与 14.3 会给出组合方式;直接把 Semaphore 当限流器用,突发流量会被平滑,但平均速率无法约束。

公平版有成本。 每次获取都多一次队列检查,且严格顺序意味着频繁的线程唤醒。只有在业务真的要求“先来先得”(例如按提交顺序分配稀缺资源)时才开公平。

什么时候不要用

不要用信号量替代可重入锁。信号量不可重入:同一个线程第二次 acquire() 会把许可数减到 0 以下并把自己挂起,看起来像死锁。需要“同一线程可重复进入”的语义,用 ReentrantLock。

不要用它做“等待某件事发生”的闩锁。Semaphore(0) 加 release() 确实可以模拟一次性开门,但没有“计数归零”的语义,也没有 getCount();这类需求用 CountDownLatch(4.2)。

不要在持有可能抛异常的代码里忘记 finally。信号量不会在异常时自动归还,这一点和 try-with-resources 管的资源不同。

最后,acquire() 响应中断,但中断发生时它不会持有许可;如果业务需要在中断后区分“没拿到”和“拿到了但被中断”,用 tryAcquire(timeout, unit) 的返回值判断,而不是捕获 InterruptedException 后猜。

常见错误与边界

把 availablePermits() 当精确值做业务判断。 它是一次 state 读取,读到的瞬间就可能被其他线程改变。用它做监控可以,用它决定“还要不要再发”会产生竞态;判断必须放进 tryAcquire() 这种原子操作里。

在 Sync 之外直接改 state。 Semaphore 没有暴露 setState,子类里也不能绕过 tryAcquireShared 的 CAS 语义去写 state,否则 release 的计数和实际持有量会脱节。

混用 drainPermits 和业务缩容。 drainPermits 把许可清零并返回被抽走的数量,它不会唤醒等待者,也不改变队列;如果此时队列里有人,清零只会让他们等更久。它是“管理动作”,不是“通知动作”。

用 release(n) 补偿一次失败的 tryAcquire(n)。 失败时没有拿到任何许可,再 release(n) 就是净增 n 个许可。这类 bug 不会立即可见,只会在几天后表现为限流慢慢失效。

在公平信号量上依赖 tryAcquire() 的顺序。 它走的是非公平路径;需要顺序保证时只能用阻塞 acquire() 或带超时的 tryAcquire(timeout, unit)。

小结

Semaphore 把许可直接放进 AQS 的 state,用共享模式实现“一次放行多个”。非公平版用 CAS 直接扣减,公平版在扣减前多问一次 hasQueuedPredecessors();tryAcquire() 绕开公平性,是有意的取舍。许可数可以为负,reducePermits 与 drainPermits 让资源池能在不阻塞的情况下缩容和清空。落地时的关键不是源码,而是把 acquire 放在 try 外、把 release 放进 finally,并接受“没有持有者校验”这个前提。

阅读导航:上一节 3.3 StampedLock 的乐观读 | 下一节 4.2 CountDownLatch、CyclicBarrier、Phaser

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收