AQS共享模式和独占模式的流程类似,这里不赘述共享模式下的获取锁、释放锁的流程,只说明一下AQS在共享模式下都遇到了哪些问题,又是如何解决的。
共享模式遇到的挑战
先看下共享模式获取锁和释放锁的代码:
/**
* Release action for shared mode -- signals successor and ensures
* propagation. (Note: For exclusive mode, release just amounts
* to calling unparkSuccessor of head if it needs signal.)
*/
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
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;
}
}
/**
* Sets head of queue, and checks if successor may be waiting
* in shared mode, if so propagating if either propagate > 0 or
* PROPAGATE status was set.
*
* @param node the node
* @param propagate the return value from a tryAcquireShared
*/
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
从代码上看,相比独占模式,共享模式获取、释放锁的代码变复杂了。独占模式和共享模式的区别在于,独占模式表示,同一时间只能由一个线程获取到锁,也只能获取到锁的线程来释放锁。而共享模式表示,同一时间可以有多个线程获取到锁,也可以有多个线程去释放锁。因为可以有多个线程去获取和释放锁,所以在处理acquire和share的时候,共享模式对并发时的细节处理也相对复杂。
先假设不处理上述的并发问题,按照独占模式的思路去写共享模式,那么上述较复杂的两段的代码setHeadAndPropagate和doReleaseShared方法,应该写成:
private void setHeadAndPropagate(Node node, int propagate) {
// 获取到锁,出队
setHead(node);
// 共享模式,如果还有共享资源,那么需要继续唤醒后续的结点
// 这需要建立在结点的状态是SIGNAL状态,表示后续的结点需要唤醒
if (propagate > 0 && node.waitStatus != 0) {
Node s = node.next;
// 后续的结点是共享结点才需要被唤醒,独占模式的结点不需要被唤醒
// 这里加非空判断的目的,是为了防止s.isShared()的时候出现空指针,
// 那既然是后继结点是null了,为什么还要继续唤醒后继结点呢?这么做是为了防止极端情况下,在执行到第7行时,s=node.next已经赋值完了,s为null,但是这个时候刚好有共享结点接到队尾,这个时候就会出现虽然共享资源没有耗尽,但是却无法唤醒这个结点的问题
if (s == null || s.isShared())
doReleaseShared();
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
这样写会有什么问题呢?
从独占模式的流程分析里我们知道,头结点的出队是在acquire中完成的,而且由于独占模式只允许同一时间有一个线程占有锁,因此出队操作没有并发问题。而现在在共享模式下,允许同一时间有多个线程占有锁,那么出队操作就会面临并发出队带来的问题。
那出队操作是在什么情况下引起的呢?头结点出队,是由releaseShared方法唤醒头结点的后继结点所代表的线程,然后通过将后继结点置为头结点,从而实现出队操作。也就是说,当出现并发调用releaseShared方法的时候,就有可能出现并发出队的问题。所以,在releaseShared时,头结点发生变更,就可能出现并发问题。
所以,针对这个情况,可以有下面的问题的归类:
为了更好地描述后续的问题,假设队列中的结点如图:
releaseShared在setHead之前
在unparkSuccessor方法里,出队的结点,当它的waitStatus小于0时,是会被赋值为0的。考虑下面的时序:
那如何解决这个问题呢?
出现这个问题的时候,不是会将新头结点的waitStatus置为0嘛,那就让它在releaseShared的时候不变成0。当发现为0的时候,就将它置为一个小于0的值。waitStatus的状态值为负的只有3种,SIGNAL,CONDITION,PROPAGATE,设置为SIGNAL不合适,因为release的时候就是要将头结点的状态从SIGNAL变成0。CONDITION表示的是条件队列,也不符合这个场景。所以只能使用PROPAGATE这个状态,这也就是为什么waitStatus会多出一个PROPAGATE状态的原因。那原来的releaseShared方法就可以改成下面这样:
private void doReleaseShared() {
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);
}
}
}
但是这样改了之后还不够,CAS只能设置一次,不一定能成功。考虑这样一种情况,当判断ws == 0为真后,在执行 compareAndSetWaitStatus(h, 0, Node.PROPAGATE) 之前,此时队列中进来一个结点,然后将它的前置结点置为了SIGNAL,接着再执行compareAndSetWaitStatus(h, 0, Node.PROPAGATE) ,这样CAS肯定会失败。头结点的状态还是没有变化。
因此为了保证它的状态能更新成功,这里要使用循环+CAS的方式,确保在releaseShared的时候,头结点的更新是成功的。那这里可以改成:
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;
}
}
}
}
那循环的终止条件是什么呢?回顾这么修改的原因,是由于在并发releaseShared的过程中,头结点发生了变更,后继结点成为了头结点,导致后续的结点无法被唤醒。那么也就是说,只要头结点没有变化,就不会有这个问题,现在头结点变了,才要用循环+CAS来改变它的状态。因此这里就容易推导出,当在循环的过程中,头结点都没变化,循环就可以结束,否则都要用这种方式来处理releaseShared过程中头结点不是原来的结点的情况。因此,releaseShared的代码就可以改为:
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;
}
}
if (h == head) {
break;
}
}
}
至此,就能推导出,为什么doReleaseShared的写法会变得这么复杂,就是为了解决上述的这个问题。
releaseShared在setHead之后
考虑这样一种情况:
doReleaseShared在setHead方法执行之后执行,那么此时在doReleaseShared记录的头结点就不是node1而是node2了。从上面的时刻表可以看到,如果按照独占模式那样的写法,也会出现无法唤醒后继结点的情况。
此外,判断propagate > 0 && head.waitStatus != 0的情况,也有问题。因为存在多线程releaseShared的情况,在执行到这个判断条件时,即使此时propagate不大于0,也可能会出现执行完这个判断之后,有线程调用了releaseShared导致同步状态值又大于0的情况。在这种情况也是应该唤醒后继线程的。
如何解决呢?
对于第一个问题,releaseShared只能从头结点开始定位,对于这种情况再从releaseShared开始尝试解决这个问题的话,就有点显得力不从心了,所以只能从acquireShared去想办法。如果按无并发release的情况,前任头结点node1的状态,应该由release方法去改变。但现在由于并发调用了release,让release方法错误地将头结点记录成了node2,也就是说,当出现了这个问题时,因为release方法已经无法对node1更新状态了,所以前任头结点node1的状态依然为SIGNAL或PROPAGATE,那么加上对它的状态值的判断,就可以规避这个问题。
对于第二个问题,propagate <= 0,但满足新旧两个头结点的状态值任意一个 < 0时,也应该唤醒后继线程,让它们去尝试获取锁。所以原来的 propagate > 0 && head.waitStatus != 0 的判断,应该改成 propagate > 0 || oldHead.waitStatus < 0 ||newHead.waitStatus < 0
综合上述两个问题,可以推导出acquireShared这个方法里,setHeadAndPropagate的写法就是为了解决上述两个问题而被改造成这样的。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // 记录前任头结点,后续需要判断状态,为了解决第一个问题
setHead(node);
// 判断条件从&&改成||,为了解决第2个问题
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
对于共享模式中遇到的这些坑,我们可以从这个链接下发现其中的端倪:bugs.openjdk.java.net/browse/JDK-…
总结时刻
虽然共享模式和独占模式的流程大致相同,但是基于它本身的特点,需要更多对并发细节的处理。在阅读共享模式的源码时,对releaseShared和setHeadAndPropagate的写法一度无法理解,直到搜索资料时,看到这个bug记录才开始豁然开朗。在分析的过程中,逐渐意识到,所谓的并发问题,都是因为变化而导致的。那么分析变化产生的原因,才能知道是什么并发场景才会引起问题。只要能运用MECE原则,穷尽所有引起变化的场景,就能分析清楚并发带来的问题都有哪些,找到了问题,自然也就能对症下药。
对源码的分析,加深了对AQS本身的理解,这对我们提升程序的鲁棒性固然重要,但更重要的是要从源码分析中体会到分析的思路,体会到对自己的哪些能力、或者哪些方面的效率有提升,这是源码分析带来的更深刻的影响。