信号量的基本概念与核心原理
信号量本质上是一个计数器,用来控制同时访问某个特定资源的线程数量。这个概念最早由操作系统领域的Dijkstra提出,后来被引入到各种编程语言的并发工具库中。在Java里,Semaphore类位于java.util.concurrent包下,它维护了一组虚拟的许可,线程在访问资源前需要先从信号量中获取许可,访问结束后再归还许可。如果许可不够用,获取操作会阻塞线程,直到有其他线程释放许可为止。

从底层实现来看,Semaphore是基于AQS(AbstractQueuedSynchronizer)构建的,这一点和ReentrantLock类似。它内部有一个继承自AQS的Sync同步器,分为公平和非公平两种实现。许可数就存储在AQS的state变量中,acquire操作本质上是对state做原子减一,减完之后如果小于零,线程就会被封装成节点加入同步队列等待;release操作则是对state做原子加一,并唤醒队列中等待的线程。理解了这一点,就能明白为什么信号量可以做到线程安全,也能明白为什么它没有独占的概念——它可以被多个线程同时持有,这与锁有本质区别。
创建信号量的语法非常简单,构造方法传入许可数量即可,还可以传入第二个布尔参数指定是否为公平模式:
// 创建一个有3个许可的信号量,默认非公平模式 Semaphore semaphore = new Semaphore(3); // 创建一个公平模式的信号量,先到的线程先获取许可 Semaphore fairSemaphore = new Semaphore(3, true);
需要特别强调的是,信号量的许可数量并不代表资源本身,它只是一个逻辑上的计数约束。释放许可的次数甚至可以超过初始许可数,信号量不会校验你是不是先acquire过。这就要求使用者自己保证获取和释放的配对逻辑,否则很容易出现计数混乱的问题。
Semaphore的典型使用场景与代码示例
信号量最常见的应用场景是限流和资源池管理。比如一个系统同时只允许5个线程访问外部接口,或者一个数据库连接池中只有10个连接,都可以用信号量来约束。下面通过一个完整的例子演示如何限制同时执行的线程数量:
import java.util.concurrent.Semaphore;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class SemaphoreDemo {
public static void main(String[] args) {
// 同时只允许3个线程执行任务
Semaphore semaphore = new Semaphore(3);
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 1; i <= 10; i++) {
final int taskId = i;
pool.execute(() -> {
try {
semaphore.acquire(); // 获取许可,不够则阻塞
System.out.println("任务 " + taskId + " 开始执行,剩余许可:" + semaphore.availablePermits());
Thread.sleep(2000); // 模拟业务耗时
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 务必在finally中释放
System.out.println("任务 " + taskId + " 执行完毕并释放许可");
}
});
}
pool.shutdown();
}
}运行这段代码会发现,10个任务中最多只有3个同时处于执行状态,其余任务在acquire处排队等待。这正是信号量的核心价值:用极小的代码成本实现了并发数量的硬性约束。除了这种基本用法,Semaphore还提供了一些增强方法值得了解。比如tryAcquire方法会尝试获取许可但不阻塞,拿不到就直接返回false,适合快速失败的场景;acquire(timeout, unit)支持带超时的阻塞等待,避免线程无限期挂起;acquireUninterruptibly则在等待过程中不响应中断,适合对中断敏感的特定场景。
另一个经典场景是用信号量为1的实例实现互斥访问,这相当于一个轻量的锁。虽然功能上和ReentrantLock重叠,但信号量的好处在于它没有持有者的概念,理论上可以由一个线程获取、另一个线程释放,这为某些特殊的线程协作模式提供了灵活性。比如一个线程负责生产并消耗许可,另一个线程消费许可,这种单生产者多消费者的限流模式用锁很难简洁地表达,用信号量却非常自然。
使用信号量的注意事项与常见误区
第一个也是最致命的坑是忘记释放许可,或者在获取许可之前就执行了释放。如果acquire成功后业务代码抛出异常,许可没有被归还,那么随着时间推移可用许可会越来越少,最终整个信号量"耗尽",所有线程都卡在获取阶段,系统表现为大面积假死。因此正确的姿势永远是把release放在finally块中,保证无论业务是否异常都能归还许可:
try {
semaphore.acquire();
// 业务逻辑
doBusiness();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
semaphore.release(); // 保证许可一定被归还
}第二个需要注意的点是公平模式与非公平模式的选择。默认的非公平模式下,新来的线程可以直接抢占刚刚被释放的许可,不需要排队,吞吐量更高,但可能导致等待队列中的线程长时间得不到许可,出现所谓的饥饿现象。公平模式则严格按照FIFO顺序分配许可,各线程获得机会均等,但由于每次释放都要检查队列状态并可能挂起抢占线程,性能会有一定损耗。在实际开发中,除非业务明确要求公平性,一般建议使用默认的非公平模式,把性能放在优先位置。
第三个误区是把信号量当成锁来滥用。有些开发者发现信号量为1时行为和互斥锁类似,就到处用信号量替代锁,这是不恰当的。锁有明确的持有者概念,支持可重入、条件等待、读写分离等丰富特性,还提供了死锁检测的依据;而信号量没有持有者,无法判断当前是哪个线程占用了许可,也不支持可重入——同一个线程重复acquire两次就会把自己阻塞住。如果是保护临界区的场景,老老实实用synchronized或者ReentrantLock更合适,信号量应该专注在控制并发数量这个它最擅长的领域。
最后补充一点调试和排查技巧。当怀疑信号量出现问题时,可以通过availablePermits方法打印当前剩余许可数,通过getQueueLength方法查看排队等待的线程数量,通过hasQueuedThreads判断是否有线程在等待。把这些指标输出到日志或监控系统中,能够快速定位许可泄漏、并发数超限等典型故障。掌握了这些细节之后,信号量会成为你在多线程资源控制工具箱中一件称手的武器。