参考博客
AbstractQueuedSynchronizer详细分析
博客中源码分析的很详细,但是还是有一些地方漏掉了,本文作为一个补充。
唤醒节点
接下来看看如何唤醒后继节点。
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 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);
}
由于node节点已经取消,所以不需要设置waitStatus,然后查找下一个没有取消的节点,将其唤醒。注意这里对s==null的判断和之前的情况3一样。
独占锁获取和释放
博客中虽然给出了release方法的分析,博客中说“节点h的状态是0,表示没有CLH队列中没有被阻塞的线程”。下面解释releas方法如何保证释放资源并唤醒节点。release方法流程分为两步
- 释放资源
- 如果头节点状态为则设置为SIGNAL就唤醒节点然后返回
aquire方法简化流程分为下面两步。
- 检查是否有资源,如果有资源直接返回
- 如果没有资源,检查头节点状态
- 如果头节点状态为0,则设置为SIGNAL,然后从第一步再次执行
- 头节点状态为SIGNAL,阻塞线程
根据上面的流程可以发现有两个共享变量,一个是资源一个是头节点状态,所以这两个共享变量的访问顺序影响着程序的正确执行。aquire方法中至少有两次对是否资源的检查,因此释放资源的时间有如下三种情况
- 在第一次检查资源之前释放资源,请求锁的线程可以拿到锁。
- 在第一次检查资源和设置状态之前释放资源,这时头节点状态为0,释放锁的线程不会唤醒后续节点。但是请求锁的线程会在第二次检查资源时获得资源。
- 在设置头节点状态和第二次检查资源之间释放资源,这时头节点状态为SIGANL,所以释放锁的线程需要唤醒后续的线程,无论是否唤醒,请求锁的线程都能在第二次检查资源时获得资源。
- 在第二次检查资源之后释放资源,这是头节点状态为SIGNAL,释放锁的线程会唤醒后续节点。(个人猜想唤醒线程和中断状态类似,线程在准备阻塞时会检查是否被唤醒,如果被唤醒则清除唤醒标记然后直接返回)
通过上面的分析,可以发现正是因为acquire至少会检查两次资源状态,才保证锁的正确释放。如果没有第二次检查,那么在上面的第2种情况中请求锁的线程将不会获得已经被释放的锁。如果释放锁的线程无论头节点处于什么状态都尝试唤醒后续节点也是可以的,但是因为唤醒后续节点是从队尾开始遍历所以比较耗时。
共享锁的获取和释放
前面看完了独占模式,接下来看共享模式。共享模式和独占模式的操作很类似,博客中也针对其区别做了一定分析,虽然网上大部分资料都是简单一句共享模式和独占模式很类似,然后简单的把共享模式过了一遍。然而共享模式和独占模式的却别其实挺大的,下面我们从acquireShared开始来分析共享模式。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
首先通过tryAcquireShared方法,判断是否能获得锁,如果tryAcquireShared返回值小于0获取锁失败,需要将其加入等待队列。下面继续跟踪源码。
private void doAcquireShared(int arg) {
//代码省略
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//代码省略
}
这里当前节点对tryAcquireShared的返回值进行判断,可以理解为一共10个资源,线程A占用5个资源,然还剩下5个资源可以给其他线程使用,所以如果后续节点也是共享模式的化就可以尝试唤醒后续节点。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* 如果满足以下2个条件条件,就唤醒队列中的节点:
* 1、调用者显式指定需要propagation,或者之前的操作设置了需要 * propagation(可能在setHead之前,也可能在之后设置的)。
* (这里需要注意,对waitStatus进行sign-check是因为PROPAGATE可能* 被修改成SIGNAL。)
* 2、队列里面的节点是共享模式,或者null。
*
* 这种悲观主义式的检查策略可能造成不必要的唤醒,但是这种情况只
* 有可能发生在acquires或releases竞争的情况下,所以大部分情况下
* 正好或者不久后需要唤醒后面的节点。
*
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
。这里propagate > 0 的判断正是前面说所的是否有多余的资源供其他线程使用。如果有多余的资源或者头节点状态为SIGNAL和PRIOAGATE那么就通过doReleaseShared方法唤醒后面的节点。(对于head == null以及(h = head) == null的判断不知道什么情况下头节点以及当前节点会变成null)
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;
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
在共享模式下,release和acquire都可以被多个线程同时调用,所以共享模式下的操作要更复杂一些。首先是分析release方法,release方法中两个自旋CAS操作,保证唤醒一定成功。具体步骤如下
- 获取头节点,如果队列为空执行最后一步,否则继续执行
- 如果节点需要唤醒(头节点状态为SIGNAL),CAS设置将头节点状态设置为0,设置成功就唤醒节点并执行最后一步,否则从第1步重新开始。
- 如果节点不需要唤醒(头节点状态为0),CAS设置将头节点状态设置为PROPAGATE,如果设置成功执行最后一步,否则从第1步重新开始。
- 判断在上面的操作中头节点是否被改变,如果头节点被改变,从第1步重新开始。
通过循环,release方法保证一定能唤醒需要唤醒节点或者设置头节点状态为PROPAGATE。因为唤醒节点需要从队尾开始遍历节点,开销大,所以通过第一个CAS锁保证只有一个线程能唤醒节点。如果发现节点不需要唤醒之后直接返回,会导致这个唤醒操作失效,因为第二次释放资源发生在被唤醒节点检查剩余资源数之后,那么被唤醒的节点发现没有多余的资源所以不会唤醒后续节点。所以设置头节点的状态提示获得资源的节点是否需要唤醒后续节点