AQS独占模式

264 阅读5分钟

AQS的数据结构

AQS(AbstractQueueSynchronizer),抽象的队列同步器。数据结构上由一个双向队列和一个同步状态组成。

同步状态

image-20220115102259855.png

注意这里的state是一个volatile变量,意味着直接对其赋值,是线程安全的,如果使用复合操作,比如state++,那就不是线程安全的。

另外,AQS也提供了一个CAS更新状态的方法。通过这个方法,可以实现当state等于某个值的时候才更新的功能,这与直接赋值是不同的。

同步队列

同步队列是一个使用链表实现的双向队列,对每个结点,其结构如下:

 class Node {
   volatile Node prev;
   volatile Node next;
   Node nextWaiter;
   volatile int waitStatus;
   volatile Thread thread;
 }

其中,等待状态包含5种,

1:CANCELLED,顾名思义,结点被取消入队时就会被赋予CANCELLED状态

0:初始状态,结点创建时会被赋予这个状态,出队时端点状态也会被置为初始状态

-1:SIGNAL,当结点是SIGNAL状态时,表示该结点的下一个结点可被唤醒

-2: CONDITION,表示结点处于条件队列中

-3: PROPGATE,这个状态只存在于共享模式,是为了解决共享模式下的并发releaseShared问题而设计出来的。JDK注释上说的是,指示下一个 acquireShared 应该无条件传播。

AQS对状态的设计很巧妙,通过用整型来对状态做标记,可以利用数值类型的大小关系来判断状态,避免了多次状态是否相等的判断。比如,判断状态是否取消,那么就用 waitStatus > 0 表示已取消,用waitStatus <= 0表示未取消,而不用写成 waitStatus == SIGNAL || waitStatus == CONDITION || waitStatus == PROPGATE 这样的形式来判断结点的状态。

在AQS中,持有双向队列的头尾结点

image-20220115105340324.png

其整体结构如下:

image-20220115104400940.png 既然是队列,那么就拥有队列的基本操作:入队和出队。

我们把将要入队和出队的结点称为目标结点,

入队时,需要修改目标结点的prev指向,原tail结点的next指向,以及tail结点的指向,才能让一个结点加入到队列中。注意由于在同一时间可能存在多个线程都要入队等待获取状态,因此入队时,需要使用CAS设置tail结点,来保证线程安全。

出队时,需要变更头结点,那么需要将目标结点的prev和next的指向都置为null,然后让目标结点的后继结点变成同步队列的头结点即可。由于每次只可能是在头部的结点出队,不会存在并发出队的场景,因此在出队时,不需要使用CAS来保证线程安全。这是入队和出队操作主要区别。

AQS的独占模式

使用AQS时,按同步状态的获取方式来分类,分为独占式和共享式。顾名思义,独占式是指,同一时间只允许有一个线程持有同步状态(即持有锁),共享式,是指同一时间允许有多个线程持有同步状态。

对于同步状态的使用,主要分为获取和释放两种。线程要先获取到同步状态,在不需要使用时释放自己持有的同步状态。

下面以独占模式为例,理解其流程。

获取锁

AQS中,独占模式的获取代码如下

image-20220115111248987.png

其大致流程如图:

image-20220117223549215.png

其中,addWaiter的代码如下:

image-20220115143322602.png

addWaiter方法的效果是将当前线程包装成一个Node,然后加入到同步队列中。这个方法的特别之处在于,在执行这个方法时,会先尝试快速插入队尾,如果CAS插入队尾失败,才会进入统一的入队流程enq(node)。注意,addWaiter中的快速插入队尾的代码,和enq方法中的代码的区别在于,

  • 快速插入队尾默认队列中已经有一个头结点,新结点只需要直接插入到队尾而无需初始化头结点,
  • enq方法,则会去检查头结点是否被初始化,如果头结点未被初始化,则会先进行头结点的初始化。

新结点node入队后,会用循环+CAS的方式尝试获取锁(因为CAS的内部实现并不是循环尝试的,只会尝试一次,如果失败了就会返回false,所以如果要保证这个操作一定成功,那么就要通过循环+CAS的方式来重试,以确保目标值能更新成功)。

acquireQueued的方法如下,acquiredQueued方法的效果就是让多个线程在队列中循环获取同步状态,获取到同步状态之后循环结束,原线程继续执行后续逻辑。

image-20220115143252932.png

独占模式下,同步队列的结点分为以下几种情况:

  • 结点是头结点

头结点是获取到锁的结点,获取到锁的线程可以继续执行后续逻辑。在独占模式下,锁的释放,也只能由头结点去完成,执行获取和释放都是头结点执行的

  • 结点是头结点的下一个结点(此处称为头结点后继)

一个结点能否获取到锁,需要满足两个条件:

  1. 结点的前驱是否头结点,因为作为等待队列,只有头结点才能获取到锁。如果自己的前驱不是头结点,那就说明前面仍有结点在等待获取锁,还轮不到自己。
  2. 是否有可用的同步资源,即是否能获取到锁。结点与结点之间是互不关心的,因此作为头结点的后继,不会通过指针获取头结点使用锁的情况,也获取不到。所以对头结点的后继结点来说,只能尝试去获取锁,如果获取到了则说明头结点已经释放了资源。

此外,一个结点入队等待时,需要将它的前驱结点(非取消状态的结点)置为SIGNAL状态,SIGNAL状态表示,该结点的后继结点需要被唤醒。这样处于头结点的后继结点,才会在头结点释放锁时被唤醒,从而通过循环再次判断自身是否符合上述两个获取锁的条件 。将前驱结点的状态置为SIGNAL这一步骤,是在shouldParkAfterFailedAcquire中完成的。

image-20220118205058488.png

举个例子:

  1. 队列中存在头结点A,现在加入了一个结点B,此时队列中的结点为A -> B
  2. B进入acquireQueued方法,发现自己的前驱是头结点,但是获取不到锁(A还没释放),不满足获取到锁的条件,就进入到shouldParkAfterFailedAcquire方法
  3. shouldParkAfterFailedAcquire方法中,如果发现A的状态已经是SIGNAL,那么将会直接返回true,B接下来将会进入等待;若A的状态不是SIGNAL,那B就会将A设置为SIGNAL,然后再一次判断上述两个条件,如果不满足,最终都会进入等待状态,线程会在调用LockSupport.park方法时进入等待状态。
  4. 当A释放了锁,并唤醒后继结点时,B线程从LockSupport.park方法的调用处醒来,再次判断上述两个条件,如果满足,则将自己设置为头结点,原头结点A就会在这个时候出队。
  • 除上述结点外,在队列中排队的结点

在队列中排队的结点,由于不满足获取锁的条件,所以都在LockSupport.park方法的调用处进入等待状态,等待被唤醒。

释放锁

release方法的效果是,先尝试释放同步状态,若成功,则从同步队列中查出头结点并唤醒后继结点。

image-20220115145435301.png

在unparkSuccessor方法中,会将头结点的后继结点唤醒,并将未被取消的头结点的状态改成初始状态0。

注意到上面提到的shouldParkAfterFailedAcquire方法,当新结点入队时,会将它的前驱结点置为SIGNAL,而在unparkSuccessor方法中,也会将头结点的状态置为0。在独占模式下,头结点的状态只有头结点本身,以及它的后继结点会修改。所以这里需要用CAS的方式去更新。

注意在唤醒下一个结点时,后继结点有可能已经被取消。这里AQS做了一个降级策略,当发现后继结点被取消了的时候,会从队尾开始找到最接近head.next的未被取消的结点,然后唤醒这个结点。

image-20220118084854880.png

可能看到这里你会有个疑问,最接近head.next的未被取消的结点,那它还是不满足上述获取到锁的两个条件啊,线程又将进入等待状态,那这样不就卡死了吗?实际上并不会出现卡死的现象,这里AQS的设计非常巧妙,它并不单是从队头或队尾这两个方向去考虑的。

上述获取锁的时候提到,线程不满足条件时,都在park方法调用的地方卡住。当线程被唤醒时,会继续执行,发现不满足条件,如果就又会回到shouldParkAfterFailedAcquire中,在这个方法中,当发现它的前驱结点是取消状态时(waitStatus > 0),会从自己开始向前遍历,直到找到一个未被取消的结点,相当于接上了unparkSuccessor的循环,继续寻找未取消的结点 。这样就肯定能唤醒未被取消的后继结点了。

至此,独占模式对锁的获取和释放流程,已经走完了。大概可以总结如下:

  1. 只有头结点能获取到锁,锁的获取和释放,都在acquire流程中完成,release流程只是归还同步资源,并唤醒下一个等待的结点;
  2. 入队等待的结点,需要将自己的前置结点的状态置为SIGNAL;
  3. 在释放锁,唤醒下一个结点的时候,需要acquire和release流程相互配合,才能找到下一个未被取消的结点。

共享模式和独占模式的流程大致相同,但是共享模式又有共享模式面临的问题,这就留到下一部分再详细叙述了。