在并发编程中,我们经常遇到一类典型问题:资源是有限的,但请求是无限的。比如一个MySQL实例最多承受200个连接,一个外部接口每秒只允许50次调用,一台服务器上同时打开的文件句柄数也有上限。如果没有对这些资源做并发控制,高流量一来系统就会崩掉。Java并发包中的Semaphore(信号量)正是为这类场景设计的,它通过维护一组“许可”来限制同时访问某类资源的线程数量,是实现资源池化管理和限流的利器。

一、Semaphore的核心原理是什么
Semaphore本质上是一个基于AQS(AbstractQueuedSynchronizer)实现的计数器。初始化时指定许可总数,线程调用acquire()方法时计数器减一,调用release()时计数器加一。当计数器归零后,后续调用acquire()的线程会被阻塞挂起,进入AQS的同步队列排队等待,直到有其他线程释放许可。
它和synchronized、ReentrantLock的区别在于粒度:锁是“多个线程抢一把锁”,同一时刻只有一个线程能进入临界区;而Semaphore是“多个线程抢N个名额”,允许最多N个线程同时通过。这个特性使它非常适合表达“资源池里还有几个空闲连接”这样的语义。举个例子,如果数据库连接池容量是20,就创建一个许可数为20的Semaphore,每个线程拿到连接前先acquire,用完连接后release,这样系统任意时刻最多只有20个线程在真正使用连接。
Semaphore还有一个容易被忽视的细节:许可数可以大于初始化值。也就是说release()可以在没有调用过acquire()的情况下执行,计数器会超过初始值。这在修复老代码的许可泄漏问题时偶尔有用,但也意味着如果你在代码里多调了一次release,限流能力就被悄悄放大了,排查起来非常困难。
二、用Semaphore手写一个数据库连接池
下面是一个简化版的连接池实现,核心思路是:用Semaphore控制可用连接数量,用阻塞队列存放空闲连接。线程获取连接前必须先拿到许可,归还连接时先放回队列再释放许可,保证顺序正确。
public class SimpleConnectionPool {
// 假设这是真实的数据库连接对象
static class Connection {
private final String id;
Connection(String id) { this.id = id; }
String getId() { return id; }
}
private final Semaphore semaphore;
private final BlockingQueue<Connection> pool;
private final int size;
public SimpleConnectionPool(int size) {
this.size = size;
this.semaphore = new Semaphore(size);
this.pool = new LinkedBlockingQueue<>(size);
// 初始化时预先创建好全部连接
for (int i = 0; i < size; i++) {
pool.offer(new Connection("conn-" + i));
}
}
public Connection getConnection() throws InterruptedException {
// 先拿许可,拿不到说明池子已满,线程在此阻塞或超时
semaphore.acquire();
return pool.take();
}
public void releaseConnection(Connection conn) {
if (conn != null && pool.offer(conn)) {
// 连接成功归还后再释放许可,顺序不能颠倒
semaphore.release();
}
}
}这段代码里最关键的是acquire和release的配对关系。任何一个获取了连接却没归还的线程,都会让池子的可用容量永久减一,这就是所谓的许可泄漏。要缓解这个问题,建议用try-catch-finally结构把释放动作放在finally块里,或者直接使用下面的非阻塞超时版本。
三、限流实战:tryAcquire超时与快速失败
在真实的线上环境里,让线程无限期等待往往不如让它快速失败。假设一个接口依赖下游服务,下游最多承受30个并发,超过的部分应该立即返回“系统繁忙”而不是堆积等待。这时可以用带超时的tryAcquire方法。
public class ApiLimiter {
private final Semaphore limiter = new Semaphore(30);
public String callDownstream() {
boolean acquired = false;
try {
// 最多等待500毫秒,拿不到许可就放弃
acquired = limiter.tryAcquire(500, TimeUnit.MILLISECONDS);
if (!acquired) {
return "系统繁忙,请稍后重试";
}
// 模拟调用下游服务
Thread.sleep(100);
return "调用成功";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "请求被中断";
} finally {
if (acquired) {
limiter.release();
}
}
}
}注意finally块里的判断:只有成功acquire过才release。如果acquire失败了也去release,就会凭空增加许可,限流形同虚设。另外,捕获InterruptedException后记得调用Thread.currentThread().interrupt()恢复中断标记,这是并发编程的基本礼貌,否则上层框架无法感知线程被中断。
四、公平模式与非公平模式怎么选
Semaphore的构造函数有两个重载:只传数字时默认使用非公平模式,传true则启用公平模式。非公平模式允许线程“插队”,只要有许可释放,正在acquire的线程无论先后都有机会抢到;公平模式则严格按照FIFO顺序唤醒等待队列中的线程。
两者的取舍点在于吞吐量和延迟分布。非公平模式的吞吐量通常更高,因为减少了线程挂起和唤醒的开销,但可能导致队列中某些线程长时间抢不到许可(饥饿问题)。公平模式没有饥饿问题,可预测性强,代价是整体吞吐下降。对于数据库连接池这类场景,建议使用非公平模式追求性能;如果是订单处理、任务调度这种对处理顺序敏感的场景,公平模式更稳妥。
还需要提醒一点,Semaphore只能控制并发数量,不能控制速率。如果你的需求是“每秒最多100个请求”,Semaphore本身无法做到时间窗口维度的控制,应该结合令牌桶算法或者使用Guava的RateLimiter。两者也可以组合使用:外层RateLimiter限速率,内层Semaphore限并发,形成双重保护。
五、常见坑点与最佳实践总结
第一个坑是许可泄漏,前面已经反复强调,解法是保证acquire和release严格配对,release必须放在finally块中。第二个坑是把release写在业务逻辑中间而不是末尾,导致连接还没真正归还就有新线程拿到许可去取连接,引发空取或状态错乱。正确顺序是:先归还资源到容器,再释放许可。
第三个坑是初始化许可数与实际资源数不一致。比如连接池配置了50个连接,Semaphore却只给了30个许可,结果20个连接永远闲置;反过来许可多于资源,则会有线程拿到许可却取不到连接,白白阻塞。建议把许可数和资源容量定义成同一个变量,从源头上保证一致。
总结一下实践要点:资源池场景下Semaphore配合阻塞队列是经典组合;对外服务入口处用tryAcquire做快速失败保护;常规业务用非公平模式保证吞吐;如果需要速率控制请叠加RateLimiter;所有释放操作统一放finally块。掌握这些原则,Semaphore就能在你的系统里稳定承担资源调度和过载保护的角色。