补充说明
共享模式与独占模式的区别就是对于后续节点的唤醒操作
独占模式下,只有线程释放锁后,去唤醒下一个节点
而共享模式下,有两个地方会有唤醒后继节点的操作,1.节点获取锁后,会检查是否还能再获取,可以的话则调用唤醒方法;2.线程释放锁时,会调用唤醒后继节点的方法
在具体分析代码前,还有一个逻辑要梳理一下,这个是之前一直卡住我的东西:
共享锁的唤醒操作,并不是同时发生的,虽然多个线程释放锁会同时调用doReleaseShared方法,但是具体的唤醒方法unparkSuccessor并不是同时发生的。
同一时刻,只有一个线程能争夺到唤醒节点的机会,被唤醒的节点在获取锁后,会将自己变更为头节点,然后去尝试唤醒下一个节点,当然,这时候之前释放锁的线程也可能来争夺这次的唤醒机会。
所以,其实只需要保证每次变更头节点时,唤醒操作能传播下去即可,并不是说每个线程释放锁时都一定能执行到唤醒操作,它只是能争夺节点的唤醒机会而已
源码分析
首先我们来贴出这两个方法的代码
private void setHeadAndPropagate(Node node, int propagate) {
//获取队列头节点
Node h = head;
//将当前节点设置为头节点
setHead(node);
//如果propagate>0表示该资源还可以被获取
//如果旧头节点为null或者旧头节点的状态小于0
//如果新头节点为null或者新头节点的状态小于0
if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
Node s = node.next;
//获取当前节点的后继节点,如果它为null或者它是共享节点,则唤醒头节点的后继节点
//读读共享,读写互斥,写写互斥
if (s == null || s.isShared()) {
//唤醒后继节点
doReleaseShared();
}
}
}
private void doReleaseShared() {
for (; ; ) {
Node h = head;
if (h != null && h != tail) {
//如果头节点不为空,且存在后继节点
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
//判断头节点状态是否为-1,如果不是,则继续循环
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) {
//判断头节点的状态是否为-1,不是的话,就等待下次循环
continue;
}
//如果头节点状态为-1,则将它更改为0,再来唤醒后继节点
unparkSuccessor(h);
} else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) {
//如果头节点状态为0,且未能将该节点的状态更改为-3,则继续下一次循环
continue;
}
}
if (h == head) {
//如果头节点还未发生变化,则跳出循环
break;
}
}
}
我们可以看到,这两个方法,相对于独占模式来说,要复杂很多,而且还引入了新的节点状态PROPAGETE(状态为-3)
其实,在最开始的时候,是没有这么复杂的,当时的代码如下:
private void setHeadAndPropagate(Node node, int propagate) {
//将当前节点设置为头节点
setHead(node);
if (propagate > 0 && h.waitStatus != 0 ) {
Node s = node.next;
//获取当前节点的后继节点,如果它为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) {
//头节点不为空,且状态不为0时,调用唤醒节点方法
unparkSuccessor(h);
}
return true;
}
return false;
}
这个代码与独占模式就几乎差不多了,只是setHeadAndPropagate方法多了个唤醒后继节点的逻辑
至于为什么要改成上面那种繁琐的代码,据我在网上查找,是为了解决JAVA6之前的一个bug
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6801020
我们先来根据下图来仔细分析下以前的代码会出现什么问题,以及为什么引入PROPAGETE:
- 线程ThreadA释放锁,并调用unparkSuccessor来唤醒后继节点,此时头节点Node的状态会更改为0,且Node1会从阻塞中被唤醒
- Node1会尝试获取锁,一般会获取成功,而且会返回0(表示当前线程能加锁成功,但是暂时没有多余的资源让其他线程获取了)
- 极端情况下,此时发生线程切换,Node1线程让出执行CPU,ThreadB释放锁
- 但是,因为头节点Node的状态为0,所以ThreadB调用releaseShared并不能去唤醒后继节点
- 此时,Node1线程又获取CPU执行权限,然后调用setHeadAndPropagate方法,且propagate还是旧数据0
- Node1变为头节点,但是因为propagate小于0,所以Node1也不会去唤醒Node2节点
- 这样bug就产生了,明明共享锁可能被多个线程持有,但是此时后续的唤醒操作全部终止了,只有等到Node1线程释放锁时,才可能恢复,而且极端情况下,可能会再次出现上述的情况(其实也也就是需要保证唤醒操作的传播性,让其在有剩余的锁资源时可以一个个去获取)
我们再来分析下新代码,首先是setHeadAndPropagate,它的判断条件更改为
propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0
- 当propagate>0,这说明还有剩余资源量可以加锁,直接去唤醒后继节点即可
- 当propagate<0,则判断旧头节点Node的状态是否小于0(-1,-3),如果是则可以去唤醒后继节点(至于h==null,是不可能成立的条件,因为代码能执行到这里,头节点就不可能为空,而且h引用了头节点,头节点也不会被GC,所以h==null永远不成立。至于为什么要加这个判断,我也不清楚,网上说是为了防止空指针)
- 以上条件都不满足时,则判断新头节点Node1的状态是否小于0,如果是则可以去唤醒后继节点(该条件会导致不必要的唤醒,比如ThreadA释放锁唤醒了Node1,Node1变为头节点,此时Node1状态是-1的,但是还没有任何线程释放锁,它也去做了唤醒操作)
相对于旧版来说,新版的条件写的是相当宽泛,只有一种情况它不会去执行唤醒操作:就是ThreadA唤醒了Node1节点,此时Node节点为0,然后Node1节点获取了锁,并且将自己更改为头节点,此时ThreadB释放锁,该线程获取的头节点是Node1,然后它执行了Node2的唤醒操作,此时Node1的状态也为0,这时Node1才无法执行到唤醒操作(但是,这时Node1也没必要执行唤醒操作了,因为Node2已经被唤醒了,等到Node2获取锁后自然会执行后续节点的唤醒操作,所以唤醒操作的传播并没有停止)
接下来是doReleaseShared,
- 首先,它加了一个死循环
- 当头节点Node状态为-1时,会先进行CAS更新头节点状态至0,再执行唤醒后继节点Node1的操作(主要是确保多个线程释放锁时,只有一个线程去唤醒Node1)
- 当头节点Node状态为0时,会尝试将Node状态更新为-3
- 如果顺利走到下面的逻辑,且头节点还未发生变化,则跳出循环
- 否则,说明有节点获取了锁,且将自己更新为头节点了,而这些线程会再次循环尝试去唤醒新的头节点的后继节点(个人觉得有点不能理解,新的头节点也会去调用唤醒操作,能保证唤醒操作传递下去,这里还要继续下次循环,只能是认为它能更快的去执行唤醒操作了,虽然这个说法很牵强)
我们再来回到上面那个bug
当Node1节点被唤醒,但是还未设置新的头节点时,此时头节点Node为0,线程ThreadB会将Node状态更新至-3。那么即使ThreadB无法唤醒Node2,Node1也可以在setHeadAndPropagate中去唤醒Node2,相当于ThreadB将唤醒操作传播出去了。
下面是doReleaseShared的流程图
总结
- PROPAGETE状态是为了防止极端情况下,释放锁的线程无法唤醒后继节点,让获取锁的线程能把唤醒操作继续下去
- 获取锁的线程和释放锁的线程,同一时刻只有一个线程能去唤醒头节点的后继节点,其他线程会跳出循环不做操作,当然如果线程还未跳出循环时,头节点就发生了变化,那么这些线程会再次争夺执行唤醒操作的机会
- 一个头节点的后继节点只会被唤醒一次