承接的问题
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
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。