如何用 Java Semaphore 控制稀缺资源的并发访问?
说明 Java Semaphore 通过许可计数限制数据库连接、第三方接口配额等稀缺资源的并发访问数,并给出最小示例、用 tryAcquire 设置超时以及用 finally 释放许可来避免许可泄漏的实践要点。
学完这篇你可以用 Java 的 Semaphore 把数据库连接、打印任务、第三方接口配额这类“一次只允许固定数量线程使用”的稀缺资源限制在一个确定的上限里,并且知道怎么设置超时、怎么避免许可泄漏。
第一步:先确认 Semaphore 管理的是什么
这一步要理解 Semaphore 的本质,搞清楚了才不会用错地方。Semaphore 内部维护一个许可(permit)计数器,acquire() 把计数减 1,计数为 0 时调用线程阻塞;release() 把计数加 1,唤醒等待的线程。它控制的是“同时有多少个线程能进入某段逻辑”,并不知道那段逻辑里用的是什么资源。
所以两类场景适合用它:
- 资源实体数量有限:连接池只有 5 条连接、只有 2 台打印机、只有 3 个许可证。
- 外部服务有并发上限:某个第三方 API 合同写明 QPS 上限,超过就限流。
注意:Semaphore 只控制并发数量,不保证被保护对象本身线程安全。比如你用 3 个许可保护一个
ArrayList,3 个线程同时写照样会出问题,那需要另外加锁或换CopyOnWriteArrayList。
第二步:写一个最小可跑的例子
这一步要得到一个能直接运行的示例,亲眼看到并发数被卡住。新建一个类,构造函数里传入并发上限,用 new Semaphore(n) 创建:
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
public class LimitedTask {
private final Semaphore permits;
public LimitedTask(int maxConcurrent) {
this.permits = new Semaphore(maxConcurrent);
}
public void run(String name) throws InterruptedException {
permits.acquire(); // 拿不到就阻塞
try {
System.out.println(name + " 开始,剩余许可 "
+ permits.availablePermits());
Thread.sleep(1000); // 模拟占用稀缺资源
} finally {
permits.release(); // 必须放在 finally
}
}
}
再用一个 ExecutorService 起 10 个线程调用它,传入上限 3,你会看到同一时刻最多只有 3 行“开始”打印出来。availablePermits() 返回当前剩余许可数,调试时很有用。
注意:
release()一定要写在finally里。中间抛异常没释放,许可就永久少了 1 个,跑一段时间后整个池子会卡死,这是最常见的事故。
第三步:给 acquire 加超时,别让线程无限等
上一步的 acquire() 没有超时,资源被长期占用时调用线程会一直挂着。这一步改成带超时的 tryAcquire,让拿不到许可的请求快速失败或者降级。
boolean got = permits.tryAcquire(500, TimeUnit.MILLISECONDS);
if (!got) {
throw new IllegalStateException("获取许可超时,稍后重试");
}
try {
// 使用资源
} finally {
permits.release();
}
tryAcquire(long timeout, TimeUnit unit) 在超时时间内拿不到许可就返回 false,不会抛异常。还有 tryAcquire() 无参版本,立即返回布尔值,适合“拿不到就走缓存”的场景。
注意:
acquire()和tryAcquire(timeout, unit)都会抛InterruptedException,捕获后要记得Thread.currentThread().interrupt()恢复中断标记,否则上层依赖中断的停止逻辑会失效。
第四步:需要排队公平时打开公平模式
默认是非公平模式,新来的线程可能插队抢走刚释放的许可,极端情况下早到的线程会饿死。这一步是针对有严格排队要求的场景。
把构造函数改成 new Semaphore(3, true),第二个参数 fair 为 true 即公平模式,按 FIFO 顺序发放许可。
注意:公平模式吞吐量会明显下降,因为要维护等待队列、频繁切换线程。除非业务确实要求先来先得(比如工单式资源分配),否则保持默认非公平模式。
第五步:封装成资源池,顺手避开三个坑
最后一步把上面的逻辑封装成一个通用方法,调用方只管传任务,不用碰 Semaphore:
public <T> T execute(Supplier<T> task, long timeoutMs) throws Exception {
if (!permits.tryAcquire(timeoutMs, TimeUnit.MILLISECONDS)) {
throw new IllegalStateException("资源繁忙,请稍后重试");
}
try {
return task.get();
} finally {
permits.release();
}
}
三个必须避开的坑:
- release 次数必须和 acquire 匹配。多调一次
release()等于凭空多出一个许可,上限形同虚设。想批量释放用release(int permits),但参数要和acquire(int permits)对称。 - 不要在持有时调用可能永久阻塞的方法。持有许可的线程卡在另一个锁上,整个池子会被拖死,必要时给下游调用也加超时。
- 需要一次性拿走全部许可时用
drainPermits(),它把剩余许可清零并返回数量,适合“停止服务、拒绝新请求”的优雅关闭流程。
小结
- Semaphore 用许可计数控制并发数量,适合连接池、许可证、外部接口配额这类稀缺资源。
acquire()会阻塞,tryAcquire(超时)更安全,生产代码优先用它。release()无条件放finally,少一次许可泄漏,多一次上限失效。- 默认非公平模式吞吐高,需要先来先得才传
true。 - Semaphore 只管并发数,不管被保护对象的线程安全,那是另一层的事。
转载请注明出处,版权归原作者所有。