AQS共享锁
共享锁顾名思义,就是支持多线程获取的锁,常用的有:Semaphore、Countdownlatch、ReentrantRedWriteLock等,都是共享锁的实现。
共享锁实现
Semaphore:
应用在资源受限或场景受限的场景下,如停车场只有十个车位,每一辆车都是一个线程,共享锁可以控制只有存在空车位的时候(资源充足),才能开进去(获取锁),当车位被停满了,车辆只能在外等待。
Demo
// 停车场十个车位
Semaphore semaphore = new Semaphore(10);
for (int i = 1; i <= 11; i++) {
// 每个线程代表一辆车,申请一个车位
// 当第11辆车申请车位的时候,因为被申请完了,因此只能等待有车开出去
// 等待10s后,让第5辆车,等待十秒钟后就开出,第11辆车申请车位成功
new Thread(() -> {
try {
String tn = Thread.currentThread().getName();
System.out.println(tn + "申请车位");
semaphore.acquire();// 申请车位
System.out.println(tn + "申请车位成功");
if (tn.equals("car5")) {
TimeUnit.SECONDS.sleep(10);
System.out.println(tn + "让出车位");
semaphore.release();// 释放车位
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}, "car" + i).start();
}
CountDownLatch
Demo
应用资源消耗不可逆转的情况下,假如你有10万块,一个人用太费劲了,于是你给10个人用,但是这10个人它用钱时间不定,有的一下就用完了,有的用得很慢,用完你就去上班继续挣钱。
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
String name = Thread.currentThread().getName();
System.out.println(name + "取钱一万,开始花钱");
try {
TimeUnit.SECONDS.sleep(new Random().nextInt(3));
} catch (InterruptedException e) {
e.printStackTrace();
}
countDownLatch.countDown();
System.out.println(name + "钱花完了");
}, "person" + i).start();
}
// 等待这10个人用钱
countDownLatch.await();
System.out.println(Thread.currentThread().getName() + " 该上班了");
共享锁获取锁 acquireSharedInterruptibly:
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
// 检测中断
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}
tryAcquireShared(arg)
是否能够获取锁模板方法和独占锁的tryAcquire方法一样。不过需要注意的是,这个方法返回的是的剩余可获取资源而独占锁tryAcquire返回的却是boolean值。如果返回的可用资源<0则调用
doAcquireSharedInterruptibly进入阻塞队列。
Semaphore的tryAcquireShared
Semaphore的tryAcquireShared主要是功能是对当前的资源state减去需要获取的资源数,返回其结果。
Semaphore也有公平模式和非公平模式
// 非公平模式
final int nonfairTryAcquireShared(int acquires) {
for (;;) {
int available = getState();
// 获取一次,则减少指定资源,默认为1
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;// 最后返回修改后的资源数量
}
}
// 公平模式
protected int tryAcquireShared(int acquires) {
for (;;) {
// 与非公平模式的区别就是多了一个判断阻塞队列中是否有值,这个判断方法在独占锁已经分析了
if (hasQueuedPredecessors())
return -1;
int available = getState();
int remaining = available - acquires;
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
CountDownLatch的tryAcquireShared
CountDownLatch的tryAcquireShared主要功能是判断当前资源数量是否为0,如果不为0则阻塞,因为CountDownLatch要求资源被消耗完才能继续执行。
protected int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
doAcquireSharedInterruptibly(arg)
private void doAcquireSharedInterruptibly(int arg)
throws InterruptedException {
// 添加共享节点并生成线程阻塞节点或者初始化同步队列
final Node node = addWaiter(Node.SHARED);
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
return;
}
}
// 和独占锁一样,更新前驱节点为待唤醒状态
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
throw new InterruptedException();
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
setHeadAndPropagate(共享锁传播):
共享传播性方法的实现
// 在节点获取锁成功的情况下:唤醒后继节点,并设置当前节点为头结点
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;
// 唤醒后继节点
// s==null,这种情况可能有疑问,都TM没有后继节点了还唤醒个啥?可能存在判断的时候,整好有入队节点,即时没有,那么唤醒也不会做任何操作,无伤大雅
// s.isShared(),这个不用说了,就是后继节点肯定是共享锁阻塞节点了,需要唤醒。
if (s == null || s.isShared())
// 唤醒共享锁
doReleaseShared();
}
}
- propagate > 0: 说明资源充足,可以唤醒阻塞节点,让其自旋尝试获取锁
- h==null :按道理这里执行了addWaiter初始化了同步队列,这里不可能成立。但是有个非常特殊情况,执行完setHead(node);之后旧的head是不是就没有指针指向它了,因此这里是防止旧head在执行完setHead之后被gc回收的情况下才满足,满足这一条至少说明了队列被初始化过并且节点刚获取锁成功,因此可以尝试唤醒。
- h.waitStatus<0:节点为-1或者-3,则说明了肯定是有后继节点的,因为入队节点默认是0,状态只能后继节点修改成-1或者-3;还有一种情况就是唤醒的时候会有一个-1到-3的转换,这个后面会说到。反正知道这个条件满足,肯定有后继阻塞节点。
- (h = head) == null:这个条件在我看来是很难成立的,因为前面setHead已经对头节点指定了明确指向,而node肯定是不可能为null的。
- h.waitStatus < 0:这个判断和上面的h.waitStatus < 0逻辑是一样,不过一个是旧head判断一个是新head判断,结合这里可以分析出(h = head) == null这就是为了赋值防止空指针的一个写法,目的是为了判断这个条件。
我对两个head判断的理解:
第一个
h == null || h.waitStatus < 0判断主要是判断旧head后面是否存在待唤醒节点,这也是为什么他优先级高于新head判断,如果旧队列中存在待唤醒节点,那么我们可以尝试唤醒节点竞争锁。第二个
(h = head) == null || h.waitStatus < 0判断主要是新head后面是否存在待唤醒节点,则肯定是存在待唤醒了也得唤醒。第一个判断是可能存在,第二个判断是肯定存在待唤醒节点,这样做的目的就是为了加快共享锁传播,即时会引起 一些不必要的唤醒这也是可以接受的,这个在源码注释中有说到。
共享锁释放锁 releaseShared
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared(arg)
是否能够释放锁模板方法和独占锁的tryRelease方法一样。如果满足释放条件,则调用doReleaseShared唤醒阻塞队列里面的节点。
Semaphore的tryReleaseShared
Semaphore释放锁的主要功能一个是将持有的资源返还给AQS即可。
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;
}
}
CountDownLatch的tryReleaseShared
CountDownLatch释放锁的主要功能是消耗掉一个资源。
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
int c = getState();
if (c == 0)
return false;
int nextc = c - 1;
if (compareAndSetState(c, nextc))
// 如果资源消耗完了,则唤醒调用await被阻塞的线程
return nextc == 0;
}
}
doReleaseShared
唤醒阻塞节点的核心方法
private void doReleaseShared() {
// 在节点满足唤醒条件下,持续自旋唤醒后继节点;唤醒条件:h(oldHead) == head(newHead)
// h代表未被唤醒前的head
for (;;) {
Node h = head;
// case1:队列已经初始化了,并且存在后继节点
if (h != null && h != tail) {
int ws = h.waitStatus;
// case2:如果是待唤醒节点,则直接唤醒
if (ws == Node.SIGNAL) {
if (!h.compareAndSetWaitStatus(Node.SIGNAL, 0))
continue; // loop to recheck cases
unparkSuccessor(h);
}
// case4:如果head状态0,并且在阻塞队列里面还存在后续节点,则尝试将head状态更新为-3
else if (ws == 0 &&
!h.compareAndSetWaitStatus(0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// case3:如果旧的head和执行了上面操作的head相等,说明无后续待唤醒节点
if (h == head) // loop if head changed
break;
}
}
case1:如果head!=null说明队列一定是已经初始化过了并且满足了h != tail条件,则说明队列头不等于队尾,则队列中节点数量肯定是大于1的因此需要尝试执行唤醒操作。
case2:如果当前节点类型为待唤醒节点,则尝试唤醒后继节点,因为在获取锁失败入队列的时候都是一个逻辑,执行shouldParkAfterFailedAcquire方法,会将阻塞节点的前继节点修改为-1。
case3:如果在case2中,唤醒成功,则会执行unparkSuccessor方法执行唤醒并同时在获取锁成功的情况下更新被唤醒节点为新的head。因此如果满足case3的条件,说明head没发生过变化,代表竞争锁失败,则结束唤醒,否则自旋。这和被阻塞节点会修改前继节点为-1是一个道理,一定要在唤醒并获取锁后才能断定可能会有资源还能继续唤醒
case4:ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)这是一个非常特殊的情况,在最开始的AQS没有这一段逻辑,在1.6新增的 Node.PROPAGATE状态为了修复一个bug,这个Node.PROPAGATE状态可以看做和Node.SIGNAL状态一样的功能,只不过用作一个中间状态。
举例:在没有
ws == 0 && !h.compareAndSetWaitStatus(0, Node.PROPAGATE)这个逻辑的情况下出现问题的场景。首先明确能够执行到case4已经可以确定阻塞队列肯定不仅仅只有哨兵节点这一个节点了,并且队列哨兵节点还不为-1。要知道哨兵节点head除了在新建的时候,只有在unparkSuccessor才会修改成0,也就是说哨兵节点除了0就是1。哨兵节点变为了0的时候但是队列中还有节点,这肯定是有问题的。
那么什么情况下才会出现哨兵节点为0,并且阻塞队列中还有节点呢?这就是共享锁支持多线程引发的问题。
如果没有case4这代码会造成以下问题:
场景如下:
现有线程T1,T2,T3,T4;共享资源为2。此时T1,T2获取到了共享资源;T3,T4如阻塞队列,head->T3->T4->Node.SHARED。
T1释放锁,满足case2场景,执行释放锁调用了
unparkSuccessor更新了哨兵节点head为0唤醒T3,如果刚更新完,此时T3线程还未被唤醒,或者T3被唤醒后还未在doAcquireSharedInterruptibly方法里面获取到锁,没来得及执行更新head为-1的情况下。T2在这期间也释放锁,此时按道理T3和T4都能够被唤醒并获取锁成功的,此时T2,能够顺利进入case1场景,但是不满足case2场景,因此直接执行case3,不做任何唤醒退出了自旋,造成了T4没有及时通知到位唤醒。如果后续都没有释放,就会导致永远有一个共享资源得不到使用。综上分析,引入了
Node.PROPAGATE状态,该状态就是为了维持一个中间状态用于唤醒,在unparkSuccessor里面重置为0。if (ws < 0) node.compareAndSetWaitStatus(ws, 0);你可能会问了,为什么不直接修改为-1(Node.SIGNAL)呢?
我们前面分析了在并发编程有个很重要的原则,一定要在已经处于那个状态的时候才回去更新节点,比如更新前继节点为待唤醒节点;唤醒后才更新head节点等。我个人认为,因为这是有可能T4在执行更新-1的时候并不能确定是否有其它线程已经对该线程节点进行了唤醒,因此需要设置一个中间状态来走一遍自旋,让被唤醒的节点来明确这个状态(不一定对啊)。
情况如此复杂的原因就是因为共享锁能够同时被多线程竞争,无法确定当前线程执行通过队列操作的时候,其它线程是否也在执行,因此才需要这样设计