AQS简介与源码剖析

时间:2022-06-17 18:48:23

AbstractQueuedSynchronizer,简称AQS,是Doug Lea大师创作的用来构建锁或者其他同步组件(信号量、事件等)的基础框架类。


java.util.concurrent并发包中的工具类的内部实现都依赖于AQS,如常用的ReentrantLock, ReentrantWriteLock, CountDownLatch等的核心都是AQS,虽然它们都依赖AQS,但是通过AQS实现的功能却是不同的。


同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,它简化了锁的实现方式,屏蔽了同步状态管理、线程的排队、等待与唤醒等底层操作。


AQS使用了一个int型成员变量state来表示线程的同步状态,通过内置的同步队列(FIFO双向队列)来完成管理线程同步状态的工作,一旦当前线程没有竞争到锁,同步队列会将当前线程以及线程的状态放在一个node节点中维护,并阻塞当前线程,等待被唤醒再次重新尝试获取锁或者被取消等待。


我们来看下节点中存放了什么:

AQS简介与源码剖析

节点是构成同步队列的基础,其中存放了获取同步状态失败的线程引用、线程状态、前驱节点、后继节点、节点属性(共享、独占)等,同步队列的基本结构图如下:

AQS简介与源码剖析


同步器中存放了头节点、尾节点,没有获取到锁的线程会加入到同步队列的尾部,头节点是获取同步状态成功的节点,头节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置为首节点。


本文以JDK8来分析AQS的实现原理,本章将介绍独占锁的获取与释放,废话不多说了直接撸源码,AQS是一个抽象类继承了AbstractQueuedSynchronizer

AQS简介与源码剖析

这个类主要有如下几个功能:

1、提供了一个给子类使用的构造函数

2、设置当前独占同步状态的线程

3、获取当前独占同步状态的线程


AQS成员变量:

AQS简介与源码剖析


独占式获取同步状态

调用AQS的acquire方法获取锁

AQS简介与源码剖析

如果我们看过ReentrantLock这个类会发现一般arg的值等于1,如果tryAcquire(arg)为true这个方法就执行完毕了,说明当前线程获取到了锁

AQS简介与源码剖析


这里我们用ReentrantLock中覆写该方法来解释

AQS简介与源码剖析


AQS简介与源码剖析

1、如果state=0,说明没有线程持有锁,然后调用hasQueuedPredecessors()看有没有其他线程在同步队列中等待,如果该方法返回false。如果没有其他线程在等待,则cas原子操作设置状态,如果设置成功则说明当前线程获取到了锁,然后将该线程设置为独占模式。

2、如果走到else if 语句中,如果是表示当前线程拥有锁,这个时候锁重入了,然后设置state值,这里也就视解释了前面说的state为什么会大于0的时候表示线程占有了锁

3、如果上述都不满足则,返回false,说明当前线程没有获取到锁,则程序走到acquireQueued(addWaiter(Node.EXCLUSIVE), arg),这个时候该线程就要被挂起了,放入到同步队列中,等待前置节点的唤醒。先看addWaiter方法:

AQS简介与源码剖析


1、将当线程用一个node节点来维护,如果尾节点不为空,设置node的前驱节点为尾节点,通过cas将node设置成尾节点,然后将pred的后继节点指向到node,形成了首尾相接。至此线程进入同步队列,返回当前线程的node节点。

2、如果尾节点为空的话或者线程竞争入队导致cas失败,则调用enq(node)

AQS简介与源码剖析

这里使用了自旋的方式进入队列:

1、如果尾节点为空,说明整个队列为空,初始化一个节点,通过cas将该节点设置为头节点,并将尾节点指向头节点


2、再次循环的时候尾节点此时已经不为空了,然后将node的前驱节点为之前的尾节点,然后通过cas将当前线程节点设置为尾节点,这里说明下为什么要使用无限循环呢,因为这个时候可能会有其他线程因为没有获取到同步状态来竞争插入队尾,那么当前线程就重复循环直到插入到队尾为止。然后返回队尾的节点。

这时候再反过头来看acquireQueued(addWaiter(Node.EXCLUSIVE), arg)方法:

AQS简介与源码剖析


AQS简介与源码剖析

当前线程在无限循环中尝试获取同步状态,这里结合下图来解释acquireQueued(addWaiter(Node.EXCLUSIVE), arg)

AQS简介与源码剖析


1、假设当线程获取同步状态失败,插入到了同步队列的队尾,我们假设称为之node2,当前线程执行到acquireQueued,因为node2的前驱节点为node1,node1节点不是头节点,然后执行shouldParkAfterFailedAcquire(p,node)&&parkAndCheckInterrupt())  去挂起线程,这个时候节点node2会一直自旋判断其前驱节点是否为head节点并等待被唤醒


2、此时另外一个线程也在执行acquireQueued,并且节点为node1,从上图中看出node1的前驱节点为head节点,然后尝试获取同步状态,这个时候有人可能有疑问了,head节点持有的同步可能没有释放啊,为什么node1可以尝试获取同步状态,这是因为两点:

1>因为头节点可能是在enq中初始化的,而new node()会延迟初始化,这个时候还没有其他线程持有这个初始化的node,因此作为队头可以尝试去获取。

2>这里调用tryAcquire(arg)获取同步状态是为了等待head节点释放同步状态后唤醒后继节点,node1可以尝试获取同步状态。


3、只有前驱节点是头节点才能够尝试获取锁,因为成功获取到锁的节点一定是头节点,而头节点的线程释放了同步状态之后,将会唤醒其后继节点,后继节点的线程被唤醒后需要检查自己的前驱节点是否是头节点。这个方法其实是线程真正被唤醒和挂起的地方。


4、如果前驱节点不是头节点或者未成功获取锁则根据前驱节点和当前线程节点判断是否要挂起。如果阻塞过程中被中断,则置interrupted标志位为true。acquireQueued的返回值代表的是是否被中断,线程的中断状态为false,如果发生中断则要重新设置中断状态,会通过selfInterrupt设置回去,其实acquireQueued本身是不关心中断状态。真正关心中断的在doAcquireInterruptibly中


5、如果前驱节点不是头节点或者当前线程获取同步状态失败则会走到if(shouldParkAfterFailedAcquire(p,node)&&parkAndCheckInterrupt()) )...中,来看做了什么:

AQS简介与源码剖析

这里会判断前驱节点的等待状态:

1、waitStatus=-1说明前驱节点状态正常,当前节点则需要被挂起

2、waitStatus>0  前驱节点状态为取消(CANCELLED状态),则向前遍历,直到找到前驱节点是非取消的状态,更新当前节点的前驱为往前第一个非取消节点。

3、waitStatus不为上面两种状态,那么只可能为0(new Node()时的waitStatus为0),-2(Condition状态等到其他线程调用signal方法后该节点会从等待队列转移到同步同列中),-3(PROPAGATE:表示下一次共享状态会被无条件的传播开),当为这三种状态的时候将前驱节点设置为SIGNAL状态,当前线程会之后会再次回到循环并尝试获取锁。


如果shouldParkAfterFailedAcquire(p, node)返回ture说明当前线程需要挂起,等待前驱节点的唤醒,在哪里挂起呢,这里调用了LockSupport来唤醒线程

AQS简介与源码剖析


最后我们来看下acquireQueued中final块:if (failed)cancelAcquire(node);这里好像永远都走不到因为:failed似乎永远都不可能为true这里看着有点像是模版代码一样,目的是由于响应中断或者其他的异常情况会导致执行cancelAuquire:主要用于唤醒后继节点和取消某个节点获取同步状态

AQS简介与源码剖析


独占式同步状态的释放

当前线程获取同步状态并执行了相应的业务逻辑之后,就需要释放同步状态,避免长期持有锁造成的资源浪费以及其他线程的长时间阻塞导致系统性能的问题。通过调用AQS的release(int arg)方法可以释放同步状态,会唤醒后继节点尝试获取同步状态。

AQS简介与源码剖析

如果tryRelease(arg)为true则,头节点不为空并且头节点的状态不为0(这里为什么是h.waitStatus!=0,因为头节点的状态肯定不会为-1,然后头节点可能是new node()出来的这个时候waitStatus为0)则唤醒后继线程。


总结

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程

AQS简介与源码剖析


参考:

Doug Lea:《Java并发编程实战》
方腾飞、魏鹏、程晓明:《Java并发编程的艺术》