从ReentrantLock的角度思考AQS

大鱼仙人

共 4267字,需浏览 9分钟

 ·

2021-12-13 07:38


我们上一篇简单介绍了AQS这个技术点,这一篇我们从ReentrantLock这个锁的角度来分析AQS,帮助大家理解


       

 

首先,我们先看一下ReentrantLock的内部的抽象类Sync,这个是继承于AQS的,重写了其中的一些方法,我们会在下面源码中解析,继续往下看,记住这个Sync



我们知道这个锁可以实现公平锁和非公平锁,我们来看下是如何实现的

/**    * Sync object for non-fair locks    */   static final class NonfairSync extends Sync {       private static final long serialVersionUID = 7316153563782823691L;
       /**        * Performs lock.  Try immediate barge, backing up to normal        * acquire on failure.        */       final void lock() {           if (compareAndSetState(0, 1))               setExclusiveOwnerThread(Thread.currentThread());           else               acquire(1); }
       protected final boolean tryAcquire(int acquires) {           return nonfairTryAcquire(acquires); }}

   /**    * Sync object for fair locks    */   static final class FairSync extends Sync {       private static final long serialVersionUID = -3000897897090466540L;
       final void lock() { acquire(1);        }}


上面的是非公平锁,下面的是公平锁,默认的是非公平锁,我们看下非公平锁的实现是先通过CAS的方式去加锁,加锁成功之后就将当前线程设置为活跃的持有锁的线程


/**     * The current owner of exclusive mode synchronization.   */   private transient Thread exclusiveOwnerThread;


失败的话会执行acquire方法,OK,这里我们再看下公平锁FairSynclock方法的实现,这个公平锁没有像上面非公平锁那样判断,而是直接调用了acquire方法

 

这里大家应该也懂了非公平锁和公平锁的真正区别了吧,就是非公平锁的时候,线程来的时候会多一次直接尝试加锁,剩下的操作就是一样了

 

OK,让我们进去acquire方法看



看一下tryAcquire方法



可以看出,这里只是AQS的简单实现,具体获取锁的实现方法是由各自的公平锁和非公平锁单独实现的(以ReentrantLock为例)


如果该方法返回了True,则说明当前线程获取锁成功,就不用往后执行了;如果获取失败,就需要加入到等待队列中。下面会详细解释线程是何时以及怎样被加入进等待队列中的。

 

OK,知道了这个我们就得看看ReentrantLock是如何实现tryAcquire方法的

 

老规矩,先看一下非公平锁中的具体实现




 

大家看代码应该也比较好理解,第一步先判断state==0,这个0也就意味着这个共享资源处于空闲状态,于是这里就会先尝试去抢一下锁,假如此时等待队列中有等待线程,则就是等待线程中的第二个节点和这个新加入的这个线程去抢这个锁了

为什么是第二个,因为第一个head节点存储的永远是占用锁的线程节点Node

 

接下来就是判断当前持有锁的线程和当前线程是否是同一个,如果是同一个,则将state+1这里就是ReentrantLock支持重入性的关键,到时候解锁的时候也是通过减去这个state计数的

 

抢到锁或者重入锁,都会返回true,返回true,加锁方法就直接加锁了



如果既没抢到锁,又发现占用锁的线程不是当前线程,则返回false,继续执行

 

上面这是非公平锁的tryAcquire方法,接下来咱再看这个公平锁的tryAcquire方法



这个也是先判断状态是否为0,这个的==0之后的处理逻辑就很明了了,直接通过hasQueuedPredecessors方法判断队列中是否有等待的节点,如果没有等待的节点,则直接通过CAS的方式进行判断,然后就是把当前线程设置为活跃线程

 

如果有等待的节点,就会跳过CAS的判断,紧接着会去判断当前线程和持有锁的线程是否是同一个线程,如果是同一个线程,还是进行计数+1,满足可重入性

 

不是就返回false,此时tryAcquire方法返回false

 

此时,我们再把视角拉回到acquire方法



返回false之后,则会执行addWaiter方法和acquireQueued方法



这段代码首先会创建一个和当前线程绑定的Node节点,Node为双向链表。此时等待对内中的tail指针为空,直接调用enq(node)方法将当前线程加入等待队列尾部:



第一遍循环时tail指针为空,进入if逻辑,使用CAS操作设置head指针,将head指向一个新创建的Node节点。


此时AQS中数据:



执行完成之后,head、tailt都指向第一个Node元素。

 

接着执行第二遍循环,进入else逻辑,此时已经有了head节点,这里要操作的就是将线程二对应的Node节点挂到head节点后面。此时队列中就有了两个Node节点:



addWaiter()方法执行完后,会返回当前线程创建的节点信息。继续往后执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)逻辑,此时传入的参数为线程二对应的Node节点信息




acquireQueued()这个方法会先判断当前传入的Node对应的前置节点是否为head,如果是则尝试加锁。


加锁成功过则将当前节点设置为head节点,然后空置之前的head节点,方便后续被垃圾回收掉。

 

如果加锁失败或者Node的前置节点不是head节点,就会通过shouldParkAfterFailedAcquire方法 将head节点的waitStatus变为了SIGNAL=-1,最后执行parkAndChecknIterrupt方法,调用LockSupport.park()挂起当前线程。


           

 

我们不能发现的一点,就是AQS的设计内部,包括ReentrantLock的设计内部。很多地方都会尝试用CAS的方式去加锁,就是因为在高速的运转下,可能在几行代码的时间一个线程就已经用完锁了,这样可以最高效率的来利用资源

 

parkAndCheckInterrupt主要用于挂起当前线程,阻塞调用栈,返回当前线程的中断状态。

 

给大家看个这里的流程图,图片来源于网络,觉得挺不错

 


从上图可以看出,跳出当前循环的条件是当“前置节点是头结点,且当前线程获取锁成功”。


为了防止因死循环导致CPU资源被浪费,我们会判断前置节点的状态来决定是否要将当前线程挂起,具体挂起流程用流程图表示如下(shouldParkAfterFailedAcquire流程):



acquireQueued中最后的finally中,如果失败,则执行cancelAcquire

 

获取当前节点的前驱节点,如果前驱节点的状态是CANCELLED,那就一直往前遍历,找到第一个waitStatus <= 0的节点,将找到的Pred节点和当前Node关联,将当前Node设置为CANCELLED


          

 

但是为什么所有的变化都是对Next指针进行了操作,而没有对Prev指针进行操作呢?什么情况下会对Prev指针进行操作?

 

执行cancelAcquire的时候,当前节点的前置节点可能已经从队列中出去了(已经执行过Try代码块中的shouldParkAfterFailedAcquire方法了),如果此时修改Prev指针,有可能会导致Prev指向另一个已经移除队列的Node,因此这块变化Prev指针不安全。


shouldParkAfterFailedAcquire方法中,会执行下面的代码,其实就是在处理Prev指针。shouldParkAfterFailedAcquire是获取锁失败的情况下才会执行,进入该方法后,说明共享资源已被获取,当前节点之前的节点都不会出现变化,因此这个时候变更Prev指针比较安全。

 do {   node.prev = pred = pred.prev;  } while (pred.waitStatus > 0);


解锁

 

接下来再对解锁的基本流程进行分析。由于ReentrantLock在解锁的时候,并不区分公平锁和非公平锁,所以我们直接看解锁的源码:



点进来release之后发现实现还是在AQS框架中



在ReentrantLock里面的公平锁和非公平锁的父类Sync定义了可重入锁的释放锁机制。



这个方法先去减少一次可重入次数,然后判断当前线程是否是持有锁的线程,如果不是,则直接抛出异常

 

接着判断c==0,等于0代表当前的资源处于空闲状态,便可以将当前独占资源的线程设置为null,然后更新state

 

如果不等于0,这一步释放独占锁的操作便会滤过,就是普通的重入锁减少一次重入次数,就像是重入加锁三次,执行这里之后只是变成2次而已,但是还是该线程持有该资源

 

总结 

 

我们先是在非公平锁和公平锁的角度分别分析了加锁的过程,得知非公平比公平锁只是多了一个抢先加锁的机会,但是如果抢不到锁还是会执行和公平锁相同的逻辑

 

中间我们分析了公平锁和非公平锁的优缺点,这个是面试热点

 

然后我们还会发现代码中很多地方都会尝试用CAS的方式去抢占锁,我们知道CPU的运行是很快的,这样能够保证资源释放释放能够在第一时间被等待队列中的线程抢到锁

 

最后我们又分析了这个释放锁的过程,这个释放锁并没有公平和非公平的区分,只是其中对于重入锁进行了处理,就是上面最后一张图的==0操作,因为我们上面分析了重入的道理也是对这个state进行累加得来的,所以这里只需要减一,然后判断是否为0即可

 

0的时候就意味着此时资源处于空闲状态,这个statevolatile的,保证了可见性

 

这篇只是一个笼统的分析,其实还有很多细节没有分析到位,只能说AQS的设计很精妙,李老牛皮



结束语


感谢大家能够做我最初的读者和传播者,请大家相信,只要你给我一份爱,我终究会还你们一页情的。


Captain会持续更新技术文章,和生活中的暴躁文章,欢迎大家关注【Java贼船】,成为船长的学习小伙伴,和船长一起乘千里风、破万里浪


哦对了,后续所有的文章都会更新到这里


https://github.com/DayuMM2021/Java



浏览 49
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报