《Java 并发编程·JUC 源码与容器》14.3 并发限流与信号量池

用 Semaphore 加租约表实现有界资源池,靠 availablePermits 与持有时间两条线索抓出忘记 release 的泄漏;对照公平与非公平的排队延迟,用 reducePermits 做动态缩容,并说清令牌桶限速率、信号量桶限并发的区别。

承接的问题

4.1 讲过 Semaphore 的许可模型:许可放在 AQS 的 state 里,release 不校验持有者。这个「不校验」在限流场景是优点——下游只关心同时有几个调用,不关心是谁——但在资源池场景就是最大的风险:任何线程都能多释放一个许可,而少释放一个许可没有任何人报错。

这一节把信号量做成一个真正的池:借出有租约、归还可校验、泄漏能检测、容量可收缩。最后区分两个经常被混用的东西:信号量桶限制的是并发数,令牌桶限制的是速率。

池子是许可加一张租约表

裸 Semaphore 只能告诉你「还剩几个」,不能告诉你「谁还没还」。加一张 ConcurrentHashMap<Long, Lease> 就有了持有者信息:

record Lease(long id, String owner, long acquiredAt) {}

final class LeakyPool {
    private final int capacity;
    private final Semaphore permits;
    private final Map<Long, Lease> outstanding = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();
    private final long start = System.nanoTime();

    LeakyPool(int capacity) {
        this.capacity = capacity;
        this.permits = new Resizable(capacity);
    }

    Lease borrow(String owner, long timeoutMillis) throws InterruptedException {
        if (!permits.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)) {
            return null;                      // 超时:没拿到许可,什么都没发生
        }
        Lease lease = new Lease(sequence.incrementAndGet(), owner, System.nanoTime());
        outstanding.put(lease.id(), lease);
        return lease;
    }

    void release(Lease lease) {
        if (outstanding.remove(lease.id()) != null) {
            permits.release();
        }                                  // 重复归还被 remove 挡住,不会多出许可
    }

    int available() {
        return permits.availablePermits();
    }

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

release 里的 remove 是关键:它让归还变成幂等操作。裸 Semaphore.release() 调用两次就是两个许可,而这里的第二次调用 remove 返回 null,许可数不变。代价是每次归还多一次哈希表操作,相对一次下游调用可以忽略。

反过来,borrow 里 tryAcquire 超时返回 null 是有意的:没拿到许可就不该产生租约。如果先 put 租约再等许可,超时后租约表里会多一条永远不释放的记录,检测器会把正常超时误报成泄漏。

inUse() 用 capacity - availablePermits() 推导,而不是 outstanding.size()。两者正常情况下相等,不相等就说明有人绕过了租约表直接 release——这本身就是一个值得告警的不变量。

泄漏检测:许可数和持有时间一起看

只看 availablePermits() 会误判:一个合法的长任务持有许可 10 秒,期间可用许可一直是 0。所以检测器要同时看两件事:池里还有多少许可,以及每个租约已经持有了多久。

void detect(long thresholdNanos) {
    long now = System.nanoTime();
    for (Lease lease : outstanding.values()) {
        long held = now - lease.acquiredAt();
        if (held > thresholdNanos) {
            System.out.printf("[%6.1f ms] detector LEAK-SUSPECT lease=%d owner=%-8s held=%6.1f ms available=%d%n",
                    (now - start) / 1e6, lease.id(), lease.owner(), held / 1e6,
                    permits.availablePermits());
        }
    }
    System.out.printf("[%6.1f ms] detector held=%d leases=%d available=%d%n",
            (now - start) / 1e6, capacity - permits.availablePermits(),
            outstanding.size(), permits.availablePermits());
}

检测器是一个普通的守护线程,每 100 ms 跑一次 detect(300ms)。本机真实输出(capacity=3,其中一个调用借了不还,中间还有一个等待者超时):

=== 资源池 capacity=3, 泄漏阈值 300 ms ===
[   5.9 ms] detector held=0 leases=0 available=3
[ 222.4 ms] detector held=0 leases=0 available=3
[ 260.9 ms] after normal batch: available=3
[ 261.2 ms] leaky borrowed lease=4, available=2 (no release)
[ 261.3 ms] two good leases borrowed, available=0
[ 323.5 ms] detector held=3 leases=3 available=0
[ 363.1 ms] waiter result=TIMEOUT after 101.6 ms, available=0
[ 363.5 ms] good leases released, available=2, leak still held
[ 424.6 ms] detector held=1 leases=1 available=2
[ 530.4 ms] detector held=1 leases=1 available=2
[ 631.2 ms] detector LEAK-SUSPECT lease=4 owner=leaky    held= 370.1 ms available=2
[ 631.2 ms] detector held=1 leases=1 available=2
[ 715.1 ms] admin released leaked lease=4, available=3
[ 715.8 ms] duplicate release ignored, available=3

这段输出说明了检测器的三条判据:

  • 租约年龄超过阈值:lease=4 held=370.1 ms,超过 300 ms 的阈值才报,前面 323.5 ms 那次心跳只报总量,因为当时三个租约都还没到期。
  • 许可数长期低于容量:available=2 持续了 300 多毫秒,期间没有新的借出请求,这不是「忙」而是「丢了」。
  • held 与 leases 相等:两个数一直相等,说明没有绕过租约表的 release。如果 held > leases.size(),说明有人直接调了裸信号量的 release,那是另一种 bug。

输出里还能看到一个合理的误报边界:363.5 ms 时两个正常租约已归还,只剩泄漏的那一个,检测器把 held 从 3 降到 1,没有继续对「已经还了」的租约告警。检测器只扫 outstanding,归还即消失。

管理员最终显式归还了 lease=4,available 回到 3;随后重复归还没有把许可变成 4,这是 remove 幂等性的直接体现。

acquire 必须在 try 之外

信号量池最常见的事故不是泄漏,而是多释放,根源是把 acquire 写进了 try:

// 错误写法
try {
    permits.acquire();          // 如果这里被中断,异常直接跳到 finally
    useResource();
} finally {
    permits.release();          // 于是凭空多出一个许可
}

acquire() 响应中断。线程在等待许可时被中断,会抛 InterruptedException,此时它没有拿到任何许可;但 finally 照样执行,release() 就把许可数加了 1。跑一天下来,限流会慢慢失效。

正确写法只有一种:

// 正确写法
permits.acquire();              // 放在 try 外:到这里一定已经持有许可
try {
    useResource();
} finally {
    permits.release();          // 只归还确实拿到的那一个
}

如果还需要「拿到许可之后才能登记租约」,登记动作放在 acquire 与 try 之间;这段代码不能抛异常,否则许可就漏了。用 tryAcquire(timeout) 的版本则是先判断返回值再进 try:

if (!permits.tryAcquire(100, TimeUnit.MILLISECONDS)) {
    return Result.BUSY;         // 没有许可,不进入 try,也就没有 finally
}
try {
    return useResource();
} finally {
    permits.release();
}

公平性影响的是排队延迟的分布

new Semaphore(n, true) 走公平队列,false 走非公平。4.1 已经讲过源码差异:公平版每次获取前多一次 hasQueuedPredecessors()。这里只看它对延迟的影响。本机实测(3 个许可、24 个任务、每个持有 10 ms,连跑三轮):

run1  fair=false total=103.6 ms avgWait=36.68 ms maxWait=66.11 ms
run1  fair=true  total=114.2 ms avgWait=42.45 ms maxWait=91.54 ms
run2  fair=false total=105.9 ms avgWait=37.52 ms maxWait=81.34 ms
run2  fair=true  total=106.2 ms avgWait=44.00 ms maxWait=86.34 ms
run3  fair=false total=110.7 ms avgWait=44.76 ms maxWait=89.62 ms
run3  fair=true  total=111.7 ms avgWait=42.91 ms maxWait=92.79 ms

本机 load average 113,单次运行、随调度波动,这组数字不能作为公平性更慢或更快的结论。三轮里公平版的 avgWait 有两轮更高、maxWait 三轮都更高,方向与「公平版减少插队、但增加唤醒与切换」的预期一致,但幅度都在噪声范围内。

能确定的是语义差异,不是性能差异:

模式保证代价
非公平无顺序保证,新来的线程可以和队首抢可能出现长尾,某个等待者迟迟拿不到
公平近似 FIFO,先排队的先拿每次多一次队列检查,唤醒更频繁

资源池默认用非公平就够了:池里的资源是同质的,谁先拿到不影响正确性。只有业务真的要求「按提交顺序分配」(例如按订单先后分配稀缺的打印配额)时才开公平。

reducePermits 让池子能缩容

reducePermits 是 protected,只能给子类用。JDK 25 的实现仍然是一个 CAS 循环:

// 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;
    }
}

它不阻塞、不唤醒,只是把 state 减掉。如果许可已经全部借出,state 会变成负数——这是「欠账」语义:后续的 release 先把欠账补上,补完之后才会真正多出可用许可。本机输出(capacity 3,三个全部借出后缩到 2):

=== reducePermits 缩容:capacity 3 -> 2,3 个许可全部借出 ===
after acquire 3: available=0
after shrink 1: available=-1 (negative = 欠账)
after release 1: available=0, tryAcquire=false
after release 1: available=1, tryAcquire=true

第一次 release 后 available=0,tryAcquire 仍然失败——那个许可用来还账了。第二次 release 之后才真正空出 1 个位置。

缩容的典型触发是「资源真的少了」:连接池里一个连接被判定损坏、许可证到期、下游把并发上限从 3 调到 2。这时调 shrink(1) 比「等现有租约全部归还再重建池」平滑得多。反过来扩容不能直接 release——那会和业务归还混在一起,正确做法是子类里加一个同样受保护的 grow,或者干脆重建池。

令牌桶限速率,信号量桶限并发

两者经常被当成一回事,其实约束的维度不同:

维度信号量桶令牌桶
约束同时进行的数量单位时间通过的请求数
状态剩余许可剩余令牌 + 填充速率
突发上限就是容量容量决定可积攒的突发量
空闲后不会「攒」出额外能力令牌可攒到容量,允许一次突发
典型实现SemaphoreSemaphore + 定时补充,或时间轮

用信号量拼一个最简令牌桶,只需要一个定时补充线程:

final class TokenBucket {
    private final Semaphore tokens;
    private final int capacity;

    TokenBucket(int capacity, int permitsPerSecond, ScheduledExecutorService timer) {
        this.tokens = new Semaphore(capacity, false);
        this.capacity = capacity;
        long periodMillis = Math.max(1, 1000L / permitsPerSecond);
        timer.scheduleAtFixedRate(() -> {
            if (tokens.availablePermits() < capacity) {
                tokens.release();          // 每个周期补一个令牌,攒不超过 capacity
            }
        }, periodMillis, periodMillis, TimeUnit.MILLISECONDS);
    }

    boolean tryAcquire() {
        return tokens.tryAcquire();
    }
}

这里有两个近似:1000 / permitsPerSecond 取整会带来速率误差;availablePermits() < capacity 与 release() 之间有竞态,极端情况下会多补一个令牌,被容量上限吸收。要精确速率需要记录上次填充时刻、按纳秒补令牌,那就该用专门的时间轮而不是 ScheduledExecutorService 的固定周期。

选择标准很简单:保护下游并发用信号量(连接池、线程池、下游并发上限);保护下游速率用令牌桶(第三方 API 的 QPS 配额)。两者可以叠加:先过令牌桶拿到「允许发起」,再过信号量拿到「允许同时进行」。

在 libraryd-conc 里落地

mail 下游的连接池是信号量池的典型用法:最多 4 条连接,借出时登记租约,归还时校验;后台每 5 秒扫一次超过 30 秒的租约;某条连接被判定损坏时缩容 1,并异步重建。

package com.plume.conc.core;

import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

public final class MailConnectionPool implements AutoCloseable {
    private static final class Resizable extends Semaphore {
        Resizable(int permits) {
            super(permits, false);
        }
        void shrink(int delta) {
            reducePermits(delta);        // protected,只能由子类暴露
        }
    }

    private final Resizable permits;
    private final Map<Long, Lease> outstanding = new ConcurrentHashMap<>();
    private final AtomicLong sequence = new AtomicLong();
    private final ScheduledExecutorService housekeeping =
            Executors.newSingleThreadScheduledExecutor(r -> {
                Thread t = new Thread(r, "mail-pool-housekeeping");
                t.setDaemon(true);
                return t;
            });
    private final long maxHoldNanos;

    public MailConnectionPool(int capacity, long maxHoldMillis) {
        this.permits = new Resizable(capacity);
        this.maxHoldNanos = TimeUnit.MILLISECONDS.toNanos(maxHoldMillis);
        housekeeping.scheduleAtFixedRate(this::sweep, 5, 5, TimeUnit.SECONDS);
    }

    public Lease acquire(String owner, long timeoutMillis) throws InterruptedException {
        if (!permits.tryAcquire(timeoutMillis, TimeUnit.MILLISECONDS)) {
            return null;
        }
        Lease lease = new Lease(sequence.incrementAndGet(), owner, System.nanoTime());
        outstanding.put(lease.id(), lease);
        return lease;
    }

    public void release(Lease lease) {
        if (outstanding.remove(lease.id()) != null) {
            permits.release();
        }
    }

    public void shrink(int delta) {
        permits.shrink(delta);
    }

    private void sweep() {
        long now = System.nanoTime();
        for (Lease lease : outstanding.values()) {
            if (now - lease.acquiredAt() > maxHoldNanos) {
                System.err.printf("mail pool lease leak: id=%d owner=%s held=%d ms available=%d%n",
                        lease.id(), lease.owner(),
                        (now - lease.acquiredAt()) / 1_000_000, permits.availablePermits());
            }
        }
    }

    @Override
    public void close() {
        housekeeping.shutdownNow();
    }

    public record Lease(long id, String owner, long acquiredAt) {}
}

两点落地细节。

shrink 需要 reducePermits,而它是 protected,所以池里持有一个 Semaphore 的私有子类 Resizable,只暴露一个 shrink 方法。把缩容能力收在子类里,外部调用方就无法随手改许可数。

告警不要只打日志。sweep 里发现超时租约时应该同时增加一个计数器,接到 conc-ops 的 8091 端口,用「超时租约数 > 0 持续 1 分钟」做告警条件。只打日志的检测器在容器里没人看,等于没有。

这个设计的代价

租约表是有状态的,池子不再是纯无锁结构。 每次借还都有一次 ConcurrentHashMap 操作,高并发下这张表本身会成为热点。许可数少于几百时无所谓;要管理上万个租约,就该分片或者只采样。

超时阈值是一个猜出来的数。 设短了误报,设长了发现得晚。合理做法是按下游 P99 的 3–5 倍设,并且把「接近阈值」的租约也打点,观察分布再调整。

reducePermits 之后没有回头路。 缩容容易,扩容要么重建池,要么自己实现 grow。而重建池意味着等待所有在借租约归还,这期间新请求全部失败。

什么时候不要用

不要用信号量池管理有身份的资源(例如「必须用 3 号连接」)。信号量不记录谁拿了哪个,租约表只记录线程和时刻;需要绑定具体实例时,用 BlockingQueue 把资源本身放进队列,取出来就是那一个。

不要用信号量做速率限制。100 个许可不会限制每秒 100 次,只会限制同时 100 个;空闲时突发可以瞬间打满,这正是令牌桶要解决的问题。

不要在持有许可期间做阻塞等待另一个池的操作。两个池互相等待会死锁,而且信号量没有超时传播——A 池的租约持有者等 B 池,B 池的持有者等 A 池,双方都不会释放。跨池调用要么固定顺序,要么给每一层都设超时。

最后,不要把泄漏检测器当成修复手段。它只能告诉你「有租约没还」,不能替你归还;真正减少泄漏的还是 try/finally 和「acquire 在 try 外」这两条编码纪律。

小结

资源池 = 信号量 + 租约表。release 用 remove 做成幂等,acquire 必须在 try 外,泄漏检测同时看许可数和租约年龄,reducePermits 让池子能在不阻塞的情况下缩容。公平性影响的是排队顺序与延迟分布,不是吞吐结论;令牌桶限速率、信号量桶限并发,两者维度不同,不能互相替代。

阅读导航:上一节 14.2 批量聚合与背压 | 下一节 15.1 双重检查锁定的正确写法

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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