AbstractQueuedSynchronizer那些事儿(三) 独占模式下的acquire

概述

我们采用自顶向下的思路来逐步深入源码,首先分析下独占模式下的 acquire方法,直奔主题

定义变量

node 当前节点
pred 前驱节点
nexd 后继节点
vpred 有效前驱节点
vnexd 有效后继节点
prev 前驱指针
next 后继指针

acquire

顾名思义,这个方法采用模板方法模式设计了独占模式下的获取lock的通用流程,该方法不响应中断,其中tryAcquire是个protected方法由子类实现来完成各种同步语义。

场景分析

case1 tryAcquire==true 说明当前线程获取锁成功了,会短路后面的判断条件直接return
case2 tryAcquire==false 说明当前线程获取锁失败了,会继续走后面的判断条件也就是acquireQueued,该方法返回一个boolean值,指示当前线程是否中断了
所以我们知道获取锁失败的线程最终都会通过addWaiter包装成node入队,然后在调用acquireQueued方法来让节点循环尝试获取锁

 public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            //这里其实就是重新设置当前线程的中断状态,因为在acquireQueued中清除了中断状态
            selfInterrupt();
    }

acquireInterruptibly

类似的,从名字就知道该方法是提供了一个响应中断的获取lock的通用流程,主要流程与acquire方法类似,不再分析,有兴趣的自己了解一下。

addWaiter

获取锁失败的方法线程都会调用该方法来包装自己入队,该方法根据传入的模式节点来设置自己的同步模式,对于acquire方法来说,传入的就是独占模式节点。该方法中写了一大段与enq中基本一样的代码,官方描述是为了尝试enq方法的快速执行路径,我猜测是为了性能提升,但提升了什么性能?优化点在哪?

 private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure 性能优化尝试enq方法的快速执行路径,否则尝试完整的enq方法
        Node pred = tail;
        //问题 此处如果是为了性能提升,到底提升了什么?貌似还不是需要一次非null判断?
        if (pred != null) {
            node.prev = pred;
            //这里都是对共享状态tail的操作,需要CAS保证
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

enq

node节点的真正入队方法,它的作用就是维护同步队列,确保同步队列在多线程操作时的一致性。先大致看一下流程

实现流程

private Node enq(final Node node) {
        for (;;) {
            //
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

1.读取tail节点,tail节点为空,同步队列就做初始化

很明显同步队列只应该初始化一次,所以通过CAS来保证只有一个线程初始化head,且也只有这个线程才能更新tail,所以tail=head这一句不需要CAS保证。那能否调整初始化顺序,比如CAS更新tail,然后在更新head呢?不能!因为这种时序只是保证了别的线程能看到新的tail值,但是仍然可能看到旧的head值,也就是说同步队列并没有完成初始化,然后别的线程就开始做插入操作了又更新了tail,此时需要做初始化的线程执行head=tail,发生了什么?本应该入队的节点出队了,这可太糟糕了!而采用CAS更新head的方式,如果别的线程读到了新的tail值,那么一定有一个线程更新了head,这样其他线程也一定看到的是新的head值!

2.tail节点不为空,先调整 node.prev -> 旧的tail节点

这一步不需要CAS保证因为此时的node并没有入队,即便多个线程看到的tail节点一致也没有关系,因为node此时还不是共享状态,有人会问那不就乱了吗?别急,后面的CAS操作会保证失败的节点会再次进入for循环来调整自己的prev指针

3.再调整 tail -> node

这里就是用CAS来保证看到tail节点一致的多个线程只有一个线程执行成功来更新tail,这样其他线程都会失败再次循环,读到新的tail值,从而再次更新自己的prev指针。注意再次参与竞争的线程不仅仅包括上一步中竞争失败的,也可能包括新入队的线程,因为它也看到了同样一致的tail节点,但永远只会有一个竞争成功!

4.最后调整 旧的tail节点.next -> node

上面CAS成功的线程可以确保自己看到的旧的tail节点和新的tail节点都是正确的,所以它可以安全的设置旧的tail节点.next指针,而不需要CAS

acquireQueued

该方法主要实现已入队线程的阻塞逻辑,以及已入队线程被唤醒后继续尝试获取锁的逻辑,所以需要一个for循环中去tryAcquire,如果获取不到就再次阻塞。

实现流程

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    //这里其实就是完成了旧head节点的出队
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

能够进入该方法起码说明了当时的同步队列已经达成了一致状态,至于后面有没有线程入队我也不关心,因为后入的节点一定修改的是tail节点。
step1 读取pred,如果pred是head节点了,说明当前线程有资格竞争锁了,所以会调用tryAcquire方法,如果获得锁成功了,自然需要让当前线程出队以防止其再次竞争锁,看一下它的出队实现

1.首先调整head -> node,这一步不需要CAS,为什么?反证法,如果存在另一个线程修改了head,必然是通过调用setHead方法,也就是说另一个线程必然执行过p == head && tryAcquire(arg)且为true,这说明另一个线程看到的pred跟当前线程看到的pred一致且都为head,而实际上这是不可能的,因为进入acquireQueued的入参节点node都是不同的节点,那它们入队以后必然看到的pred不一致,与上述假设矛盾,所以我们可以得出此处不需要CAS来保证head更新

2.设置旧的head节点.next=null,这一点注释说的很清楚,是为了帮助gc,至于prev,head节点本就为null

step2 所有不满足上述情况的会先调用shouldParkAfterFailedAcquire来判断当前线程是否需要阻塞,如果需要阻塞,当前线程就会阻塞等待唤醒来继续尝试获取锁,如果不需要阻塞,就直接从该方法返回继续走for循环来尝试获取锁,是不是觉得很晕?没事,后面会详细分析。

思考

1.曾经我一直很奇怪if (p == head && tryAcquire(arg))这句代码中为何要加上p == head,按理说唤醒的一定是head的后继节点,感觉没必要啊?
后来顿悟了,我们要知道这句话不仅仅是唤醒后会执行,也有可能在线程刚刚入队的时候初始化完成后大量线程同时进入到这里,自然入参node都是各不相同的,但是仅有一个是head节点,所以才需要这个判断啊

shouldParkAfterFailedAcquire

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * waitStatus must be 0 or PROPAGATE.  Indicate that we
             * need a signal, but don't park yet.  Caller will need to
             * retry to make sure it cannot acquire before parking.
             */
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

从方法名知道,它是在“获取锁失败”的情况下指明是否应该阻塞线程。但实际上还有一种情况也会调用这个方法,就是node不是head的后继节点并且也获取锁成功了,什么情况下会发生这个?

实现流程

step1 pred的waitStatus为SIGNAL,返回true表明node节点应该阻塞,其他任何状态都返回false
step2 在不满足step1的情况下,如果pred状态是CANCELLED,说明有另一个线程取消了pred,使得pred关联的线程放弃了竞争锁的权利,因为node即将再次进入for循环尝试获取锁,很自然的我们要更新node的prev指针链和vpred的next指针链,这个过程其实分成三步

1.找到vpred,这个过程就是循环执行pred=pred.prev,pred是什么?是函数的入参pred,指向了一个pred节点,所以这个变量的修改是线程隔离的
2.调整node.prev -> vpred,根据当前线程所找到的它以为的有效节点vpred,把自己node.prev指向vpred,注意其实vpred并不可靠!

从代码的角度来看上述俩个步骤是每次循环都会执行一次,来逐步更新自己的有效前驱,直到退出循环达到最终有效前驱,也就是
node.prev=pred=pred.prev

假设有线程A与线程B进入该流程,那必然有线程A和线程B的前驱节点为已取消,要注意的是线程A和线程B所绑定的node的状态一定不是CANCELLED,为什么?因为后面我们会知道cancelAcquire方法才会修改node状态为CANCELLED,而该方法的执行一定就是node所绑定的那个线程执行的,也就是说此时该线程在执行shouldParkAfterFailedAcquire,所以不可能节点已取消,那么无论线程A,线程B的执行时序如何,后入队的线程找它的vpred最坏的情况下也一定会找到先入队的那个线程的节点,且不为CANCELLED,从而导致退出循环,并设置自己的prev指针,不会产生prev指针链交叉的情况,换句话说,prev指针链没有被破坏!


3.调整vpred.next -> node,这一步就是完成当前线程所看到的vpred和node之间所有CANCELLED节点的出队,正如我们上面所分析的那样,此时的vpred实际上也不可靠,也是有可能是CANCELLED状态的,为什么?因为退出while循环时线程看到的vpred或许是未取消的,但是可能下一秒另一个线程就取消了这个vpred,导致其状态变为CANCELLED,但是这并不影响逻辑,因为执行完这句以后,线程会继续走for循环,并且如果再次进入该方法,它会读取到vpred的最新状态为CANCELLED,就会再次进入该分支逻辑调整它新的prev指针,有人或许会问?如果一直不断的发生这种状况,那不就死循环了吗?理论上来说确实存在可能,但是我们的同步队列是有限的,而且必须明白一点,如果node的状态变为CANCELLED,它就不可能在变成其他状态了,这是一个最终态,意味着最坏的情况也是同步队列中所有节点变成CANCELLED,所以循环一定是有限次数的。

step3 进入了这一步compareAndSetWaitStatus(pred, ws, Node.SIGNAL),说明prev节点的waitStatus要么为0要么为PROPAGATE,所以需要更新pred节点的状态为SIGNAL来执行当前node应该阻塞,为什么需要CAS?原理同上,因为也有可能另个线程取消了pred,导致其状态变化,通过CAS来保证当前节点会再次走for循环流程。

其实这里还是会存在SIGNAL丢失的情况,正如前面的分析,在当前线程再次进入该方法时,可能有另个线程取消了pred节点,就会导致pred节点的状态从SIGNAL转为CANCELLED,然后当前线程读到了最新的状态就会走CANCELLED流程,这就是它的设计精妙之处!它将是否需要唤醒通过一个状态机来实现不同状态的处理,通过for循环来让当前线程不断尝试,通过volatile关键字保证读取的一定是最新状态,即便如此,当前线程阻塞了也不代表pred节点的状态一定是未CANCELLED,因为可能当前线程阻塞了,但是另一个线程取消了pred啊!

从以上分析可知,是否应该阻塞只关心pred节点的waitStatus状态,不关心pred节点是否是头结点,也不关心当前线程是否获取到了锁,我更倾向于将此段代码看成一个状态机,经过有限次数的执行最终会变成阻塞状态。

cancelAcquire

一般只有出现异常的时候才会进入该方法,有哪些异常场景呢?我猜测就是tryAcquire这种可以被子类实现的方法,因为AQS也不知道这个方法的实现是什么样的!该方法主要就是设置节点的状态为CANCELLED,同时也负责做一些“善后“工作。

 private void cancelAcquire(Node node) {
        // Ignore if node doesn't exist
        if (node == null)
            return;
        //把节点绑定的线程赋null,帮助gc回收
        node.thread = null;

        Node pred = node.prev;
        while (pred.waitStatus > 0)
        //node.prev指针修改
            node.prev = pred = pred.prev;
        Node predNext = pred.next;
        //只有这里才设置为已取消状态
        node.waitStatus = Node.CANCELLED;

        // If we are the tail, remove ourselves.
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            // If successor needs signal, try to set pred's next-link
            // so it will get one. Otherwise wake it up to propagate.
            int ws;
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }

实现流程

step1 调整node的prev指针并更新node状态为CANCELLED

注意这里的调整只调整了prev指针,没有调整next指针,shouldParkAfterFailedAcquire方法中是俩个指针都调整了,有什么区别呢?有!调整的时机不同,shouldParkAfterFailedAcquire方法中的节点一定未CANCELLED,直接调整next指针,而cancelAcquire方法中节点CANCELLED,需要分场景来调整next指针,具体看下面分析。


先考虑一下多线程同时调用cancelAcquire的场景,假定某一时刻同步队列的状态如下图,线程A取消node2,线程B取消node3,假定node1状态已经CANCELLED,白色表示已取消
image
图3-1
node节点的prev指针调整其实是分为三步,a.调整pred指针,b.调整node.prev=pred,c.调整node状态CANCELLED,所以在调整prev指针前节点状态一定未取消!

场景1 无论线程A执行到了a还是b,此时node2的状态肯定还未取消,线程B的执行最坏的情况也是找到了node2就不会继续往前遍历,接下来的事情就很简单了,无论线程A先执行c还是线程B执行c都没有破坏prev指针链
AbstractQueuedSynchronizer那些事儿(三) 独占模式下的acquire_第1张图片
图3-2
场景2 线程A执行完了c,而线程B读到node2时,node2的状态已经是已取消了,所以node3的prev指针也指向了node0,这种情况下多个节点的前驱节点都指向了一个节点,也没有问题,因为我们可以看到这里的多个节点的状态都已经变成已取消了,在有线程唤醒时或者下面的逻辑中会主动调整pred.next
AbstractQueuedSynchronizer那些事儿(三) 独占模式下的acquire_第2张图片
图3-3

step2 node为tail节点的情况


如图可知只要更新tail指针和pred.next指针即可
1.CAS更新tail节点为找到的pred节点
tail节点并不可靠,tail节点在有新的线程添加节点的时候就会改变,如果不用CAS保证就有可能覆盖了新添加的节点导致节点丢失。注意如果此时CAS更新失败,就不会在去尝试更新pred.next了而是走下面的step3或者step4的逻辑了,因为此时的node虽然变成了CANCELLED,但是它又和新的tail节点建立关联了,所以需要做进一步处理。注意和第2点的区别。
2.CAS更新pred节点的next指针为null
这其实也是一个保证,第一步只是保证了我进入该语句时tail==pred,但是在我尝试更新时,很有可能另一个线程入队了,导致tail指向了新的node,所以需要CAS来保证我更新的时候别的线程没有更改tail指针,注意如果此时CAS失败,不做任何处理,直接退出,因为此时新的tail节点已经与pred节点建立关联了,CANCELLED状态的Node此时出队了。

step3 node不为tail节点且不是head节点的后继节点且满足一堆复杂的判断条件


这个场景说明当前被取消的node不是head的后继节点,而我们知道需要唤醒的一般都是head的后继节点,因此当前节点被取消了也不需要考虑去唤醒节点,应该只做pred和nexd节点的指针调整。在真正去看其调整指针的代码前,还需要先解决一堆判断的分析!实际上还需要满足这一堆判断逻辑才会去真正的调整指针。

“略显复杂”的判断条件

pred != head &&((ws = pred.waitStatus) == Node.SIGNAL ||(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && pred.thread != null

1.pred != head 如前所述
2.pred.waitStatus == Node.SIGNAL为true,这意味着node的有效前驱节点状态为signal,node节点本应该阻塞等待signal,但现在node节点被取消了,所以需要调整
3.pred.waitStatus不是signal状态,需要再次判断ws<=0,因为pred的状态有可能变成了CANCELLED,最后使用CAS更新来再次保证没有别的线程修改了pred的状态
4.pred.thread != null 再次检测是否有另一个线程正在取消pred节点导致pred节点的thread为null,看代码可知thread赋值null是在修改状态为CANCELLED之前的,所以这样判断可以更早的检测出pred即将被取消

综上所述,其实一系列判断都只是为了保证没有别的线程取消或者即将取消pred节点的情况下更新pred.next指针

更新pred.next

这里会检测取消节点node的后继节点是否已取消,如果取消了,我就不做任何修改,如果是的话就CAS更新pred.next

next.waitStatus <=0 的意义?因为将pred.next从指向一个已取消node变成指向另一个取消节点似乎没有意义,从另外一个角度,看图3-3,如果处于这种情况,不判断node的后继节点是否已取消,那么就有可能出现多个线程看到的vpred是同一个节点,并尝试更新这个节点的next指针。这会发生什么?先有一个线程先将node0的next指向一个有效节点,再有另一个线程就将node0.next指向了一个已取消节点,从而导致有效节点的丢失。我们可以确保在进入该方法时每个线程所绑定的节点都达到一致性的CANCELLED状态,所以通过node.next看到的后继节点如果被取消了一定能看到,从而避免了这种情况!
CAS更新pred.next,为什么需要CAS?考虑一下多种场景
1.有没有可能多个线程取消节点,并且它们都看到同一个pred节点并即将更新pred.next呢?不可能!因为这种情况说明pred节点与后入队线程的node之间的节点都已经变成了CANCELLED状态(也包括了先入队线程的node节点),所以先入队线程的判断条件就不可能满足,因为它会读取到CANCELLED状态,从而不会竞争pred节点
2.如果一个线程被唤醒执行shouldParkAfterFailedAcquire更新pred,一个线程取消节点也更新了pred呢?假设有个同步队列
node1->node2->node3->node4->node5
假设node2已取消,线程调用AcancelAcquire(node3),看到了node4的状态不为CANCELLED,然后线程A试图更新node1.next->node4,此时node5对应的线程B被唤醒执行shouldParkAfterFailedAcquire,看到node4的状态为CANCELLED(可能由于另一个线程取消了node4),所以线程B也会向前遍历找到node1,nodeB就会试图更新node1.next->node5,可以看到这与线程A要做的操作产生了竞争,所以需要CAS来保证更新的可靠性。

step4 node是head的后继节点 | node不是head的后继节点但是它的pred节点已经或者正在CANCELLED

1.node是head的后继节点
这种场景说明node本应该是有权利来竞争锁的,但是现在node被取消了,那我就手动调用unparkSuccessor方法来唤醒后继节点接力,要知道被唤醒的线程会再次进入for循环,因为此时的node只是状态为CANCELLED,并没有出队,所以被唤醒的后继节点并不会去获取锁,而是会进入shouldParkAfterFailedAcquire中它会读到它的pred节点状态CANCELLED,从而完成CANCELLED节点的出队,并再次进入for循环。
2.node不是head的后继节点但是它的pred节点已经或者正在CANCELLED
这种情况说明按理说应该争取让pred节点出队,官方的实现方式是调用unparkSuccessor方法,我们知道这会导致调用shouldParkAfterFailedAcquire方法来完成pred的出队操作,我姑且认为这是一种方法重用吧!

思考

1.为什么一定要调用unparkSuccessor呢?我走step3的更新pred.next指针的策略等待下一个被唤醒的线程来完成已取消节点的出队不也可以吗?
某位同学说的似乎很有道理,假如有这样一种情况,确实有个线程因为锁被释放唤醒了,但是它在获取锁的前恰好发生了中断并响应了中断,比较典型的就是doAcquireInterruptibly方法,那么会调用cancelAcquire方法继续等待锁的释放来唤醒新的线程,而实际上此时压根没有任何线程获取锁,自然也不会有释放锁的操作,所有线程都阻塞了多么糟糕!所以这里才采用的主动唤醒的策略,采用这种策略,即便线程响应中断并再次进入cancelAcquire方法,因为中断位被清空了,所以第二次的主动唤醒就不会进入cancelAcquire而是进入for循环尝试获取锁了!
2.为什么我要处理prev指针和next指针呢?交由被唤醒的线程来更新不也行嘛?
我觉得是考虑唤醒时调用unparkSuccessor的性能问题,在后面的release分析中可以看到unparkSuccessor的实现是先找到后继节点,判断后继节点是否已经取消,已取消,就从tail遍历到head,所以可以理解当cancelAcquire方法被执行时,当前线程应该要主动维护同步队列的状态,更新同步队列的prev和next指针来防止唤醒性能降低,不然老是唤醒一个已经取消的节点会使得每次都从tail遍历到head。

总结

acquire的实现流程是什么?

每个需要锁的线程都会去竞争锁,只会有一个线程获取成功,其他获取失败的入队同步队列
刚入队的节点的waitStatus状态都为0,并且会惰性初始化head,tail节点,head节点标识当前获得锁的线程,head的下一个有效后继节点标识有资格竞争锁的节点,其他节点会阻塞
对于每个节点来说,如果节点是head的后继节点并且获取锁成功了,就通过设置head指针来让当前节点出队
如果不是以上情况就执行一个方法来判断当前节点是否应该阻塞,如果前驱节点是SIGNAL,说明当前节点应该阻塞,如果前驱节点是CANCELLED,就同时调整当前节点的prev指针以及pred节点的next指针来让位于当前节点和向前遍历找到的第一个有效节点之间的CANCELLED节点出队,最后不是以上情况,就修改pred节点的状态为SIGNAL并再次进入for循环中,如果不是head节点的后继或者获取锁失败了就会再次进入该流程,但要注意此时的pred节点的状态已经被修改了SIGNAL,所以会让当前节点阻塞,可以认为其实这就是个状态机
最后要处理异常情况,遇到这种会取消当前节点,其具体实现流程总结如下:
首先只要进入该异常处理流程就会调整当前节点的prev指针,找到有效前驱节点并指向它
其次针对当前节点在队列中的位置分为三种情况
1.当前节点是tail节点,这种很简单,只需要调整tail指针和pred节点的next指针即可完成当前节点的出队
2.当前节点不是head节点的后继节点 且 它的有效前驱节点未CANCELLED,就会先找到当前节点的后继节点,判断该后继节点未CANCELLED的情况下,调整pred节点的next指针
3.当前节点是head节点的后继节点,这种情况直接唤醒有效后继节点,因为之后这个后继节点会被唤醒执行前面的for循环代码,它会再次调整它自己的prev指针以及修改它的有效前驱节点的next指针从而完成节点出队

你可能感兴趣的:(java,并发)