一、概述
semaphore
,字面意思是信号量,官方的如下:
* A counting semaphore. Conceptually, a semaphore maintains a set of
* permits. Each {@link #acquire} blocks if necessary until a permit is
* available, and then takes it. Each {@link #release} adds a permit,
* potentially releasing a blocking acquirer.
* However, no actual permit objects are used; the {@code Semaphore} just
* keeps a count of the number available and acts accordingly.
*
* Semaphores are often used to restrict the number of threads than can
* access some (physical or logical) resource.
是不是感觉,这解释有点不接地气,又是信号量,又是许可啥的。
下面简单用一个场景来解释,一下子你就可以明白了。
比如说,某个银行大堂,只有三个办事窗口。
这时来了一堆人办业务,都被门口保安给拦住了。
保安说三个窗口,只让三个人进大堂。其它人只能在外边等着。
这时有个人办完业务,出来了。保安这时就放一个人进去。
总之,大堂里,最多只有三个人办理业务。
semaphore
的功能,和这个保安差不多,只让三个人进去。
其实 Semaphore 是 AQS 框架实现的,和 ReentrantLock 实现原理相似。
之前写过一篇《ReentrantLock 的源码分析》,如果看过,这篇就不用细看了。
二、示例代码
下面的代码,创建了 10 个线程,模拟 10 个线程抢 3 个许可的场景。
public static void main(String[] args) throws Exception {
Semaphore semaphore = new Semaphore(3,false); // 创建三个许可
CountDownLatch downLatch = new CountDownLatch(1);
for(int i = 0; i < 10; i++){
new Thread(() -> {
try {
downLatch.await();
semaphore.acquire(); // 获取许可
int randomSecond = RandomUtil.randomInt(1, 10);
Thread.sleep(randomSecond);
log.info("当前线程名:{},占用窗口时间:{},准备出来了。", Thread.currentThread().getName(),randomSecond);
semaphore.release(); // 解放许可
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
Thread.sleep(2000);
log.info("-------10个线程创建完毕,准备开始抢位置了……");
downLatch.countDown();
简单解释下代码
Semaphore semaphore = new Semaphore(3,false);
这句和意思,是创建3个许可,即上面例子中,保安说,只有三个窗口,只让三个人进去。
另: 这里参数 false,影响不大,是公平模式还是非公平模式,后面再具体说。
semaphore.acquire();
这句指使用一个许可。即上面例子中,某人占用了一个办事窗口。
如果执行这个方法时,许可用完了,即上面例子中,三个窗口都有人,那代码阻塞在这一行。
semaphore.release();
指许可使用完了,归还许可。即上面例子中,一个人办完业务,出来了。
执行这个方法时,会唤醒被阻塞的线程,使其尝试去申请许可。
前面说过 Semaphore 是 AQS 框架的,结合下面的图,说说 Semaphore的实现原理。
new Semaphore(3,false)
,即 state 设置为3,
semaphore.acquire();
有两种情况,
- state 大于 0,将 state 的值减 1,结束。(上面例子中,有空窗口,即有许可)
- state 小于等于0,进入队列中,等待被唤醒。(上面例子中,没有空窗口,即无许可)
semaphore.release();
将 state 值加1,并唤醒队列中第一个节点,让其去获取许可。
原理就这么点,也不复杂,是不是感觉,和 ReentrantLock 差不多呢?
确实差不多,都是有线程竞争时排队,都会限制访问某资源。
ReentrantLock 可以解决高并发问题,Semaphore 能解决高并发问题么?
不一定。new Semaphore(1,false),当把这个参数设置为 1时,跟 ReentrantLock 就一模一样了。
其它情况下,就不一定了。
比如说上面银行大堂的例子,保安是只让三个人进来办业务,
假如第一个窗口,银行前台妹子特别漂亮。
这三个人都抢着去第一个窗口,保安不干涉这个。
即,Semaphore 可以限制资源访问,但不保证原子性。
三、源码解析
源码部分是比较枯燥的,上面解释的差不多可以应对面试了。下面的源码解释可以不用看哈。
- 创建实例
Semaphore
类中有个内部类,Sync
,该类继承了 AQS
。
另外公平模式与非公平模式,各自是一个内部类,都继承了 Sync
。
abstract static class Sync extends AbstractQueuedSynchronizer {
……
}
static final class NonfairSync extends Sync {
……
}
static final class FairSync extends Sync {
……
}
- AQS 本身的属性
private transient Thread exclusiveOwnerThread; // 标识拿到锁的是哪个线程
private transient volatile Node head; // 标识头节点
private transient volatile Node tail; // 标识尾节点
private volatile int state; // 同步状态,为0时,说明可以抢锁
- AQS 里面的
内部类Node
的几个属性
volatile int waitStatus; // Node里,记录状态用的
volatile Thread thread; // Node里,标识哪个线程
volatile Node prev; // 前驱节点(这个Node的上一个是谁)
volatile Node next; // 后继节点(这个Node的个一个是谁)
用张图来说明 AQS,大概是这样。
// 以非公平模式讲解
Semaphore semaphore = new Semaphore(3,false);
这行代码,对应的源码很简单。在 Semaphore 类中,
public Semaphore(int permits, boolean fair) {
sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}
NonfairSync(int permits) {
super(permits);
}
Sync(int permits) {
setState(permits);
}
protected final void setState(int newState) {
state = newState;
}
不难理解,本例中,创建实例时,AQS中 state 会被设置为 3
- acquire()
总逻辑:
state 大于 0,将 state 的值减 1,结束。
state 小于等于0,进入队列中,等待被唤醒。
public void acquire() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted()) // 线程有中断,抛出异常
throw new InterruptedException();
if (tryAcquireShared(arg) < 0) // state 状态
doAcquireSharedInterruptibly(arg); // 入队阻塞
}
- 细分逻辑
tryAcquireShared
protected int tryAcquireShared(int acquires) {
return nonfairTryAcquireShared(acquires);
}
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState(); // 看 state 是多少
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
protected final int getState() {
return state;
}
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
for (;;)
这个是死循环,高大尚的名字叫自旋。
以上文的例子,10个线程来抢许可。(status = 3, acquires= 1)
同时执行 int remaining = available - acquires;
这行,结果是 2
那10个线程都执行 compareAndSetState(available, remaining))
这行,
这是 CAS 的原子操作,只可能有一个线程执行成功。
该线程将 status 设置为 2,return 2( 大于0) acquire 方法结束。
其它线程进入下一次循环。
循环三次之后,将有三个线程抢到许可,status = 0;
进行第四轮循环时,remaining 小于0,直接返回,
即 tryAcquireShared
返回一个小于0的结果,执行 doAcquireSharedInterruptibly
这个方法
- 细分逻辑
doAcquireSharedInterruptibly
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED); // 入队
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor(); // 获取前驱节点
if (p == head) {
int r = tryAcquireShared(arg); // 再次抢许可
if (r >= 0) {
setHeadAndPropagate(node, r); // 抢到许可,该节点从队列中剔除
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个方法也是一个死循环,首先是入队
要么在循环中抢到许可
要么没抢到,执行 park 方法,即阻塞。
addWaiter
这个方法是将节点放入队列中,若队列未初始化,先初始化后再入队。tryAcquireShared
这个方法刚刚讲过了,就是抢许可shouldParkAfterFailedAcquire
判断 Node节点,是不是 -1,不是就设置为-1,是就返回ture;parkAndCheckInterrupt
在上个方法返回true的情况下,执行该方法,执行 park 方法,即阻塞线程,
在《ReentrantLock 源码解析》中详细分析了 以上四个方法的源码,本篇不再分析。
直接输入方法名搜索,就能看到对应的解析。
《park 与 unpark 是什么》,如果不清楚,先简单看下这篇。
- release 方法
public void release() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) { // status 加 1
doReleaseShared(); // 唤醒头节点
return true;
}
return false;
}
相对来说,release 方法简单些,status 减 1, 唤醒节点,即执行 unpark 方法。
- 细分逻辑
tryReleaseShared
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;
}
}
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
tryReleaseShared
这个方法也是一个死循环,直到成功将 status 加 1,返回true。
compareAndSetState
这个方法,是CAS 操作,如果失败,则进行下一次循环。
- 细分逻辑
doReleaseShared
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
doReleaseShared
这个方法,还是死循环,当且仅当 waitStatus
= -1时,进行 CAS 操作,将 -1,改这 0 ,不成功,进入下一次循环,成功,则执行 unparkSuccessor
题外话: 什么时候,队列中的 waitStatus是 -1?
当抢许可有竞争时,会有入队操作,shouldParkAfterFailedAcquire 方法会将前驱节点的 waitStatus设置为 -1。
- 细分逻辑
unparkSuccessor
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0); // 将 waitStatus 设置为 0
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
// 从队列尾部开始向前遍历,找到目标节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread); // 唤醒目标节点
}
这个方法代码不难理解,最终是唤醒 head 的后继节点。(head 是空节点)
可仔细看下,这里只有唤醒节点,却没有 出队的操作,为什么呢?
不是源码出错,而是写出队操作,很隐蔽。我慢慢说。
看这张图,LockSupport.unpark(s.thread)
会唤醒 thread=C 的那个 节点。
在讲acquire()
源码时,说过 doAcquireSharedInterruptibly
这个方法,这是一个死循环,最终会在parkAndCheckInterrupt
方法中调用 park
方法,线程被阻塞。
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r); // 重新设置头节点
p.next = null; // help GC
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} finally {
if (failed)
cancelAcquire(node);
}
}
thread=C 的那个节点,被唤醒后,再次进入 doAcquireSharedInterruptibly
的 for (;;)
循环,
进入 if (p == head)
这个分支
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
failed = false;
return;
}
}
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
setHeadAndPropagate
方法会重新设置头节点, p.next = null
这个是旧的头节点出队。
到这里,release
方法就讲完了。
四、公平与非公平
公平模式与非公平模式,差别不大。
只要是在队列中的节点,只有头节点可以抢许可。这是代码里规定的,没有为什么。
- 非公平模式:有竞争时,在入队前,有机会同头节点抢许可,若抢失败,就入队。
- 公平模式:有竞争时,首先入队,只有头节点可抢许可。
想弄清楚原理,可以参考《公平锁与非公平锁》
五、共享锁与排他锁
ReentrantLock 是排他锁,相对应的,Semaphore 是共享锁。
不要被要貌似高大上的名词给忽悠了,其实差别很小,见下图 exclusiveOwnerThread
属性。
ReentrantLock
加锁实现中,某线程将 state 设置为 1 时,
同时 exclusiveOwnerThread
将指向该线程,即 该锁为该线程独有。
同样解锁,将state 设置为 0 时,同时 exclusiveOwnerThread
设置为 null。
Semaphore
获取许可与释放许可,仅操作 state 这一个属性,即 几个线程共享这个锁。
你看,是不是差别很小,也很明了,弄一个 共享锁-排他锁 的大词儿出来,
瞬间逼格提高了不少,这就是套路!
顺便再说个题外话:ReentrantLock 的可重入性,说简单点,就是每加一次锁,state 就加 1,
同一个线程,可以重复上锁,state 一直往上加。
从这个角度说,Semaphore 也是可重入的,同一个线程,可以连续调用 acquire
方法