【图解】一篇搞定ReentrantLock的加锁和解锁过程

您所在的位置:网站首页 杭州的典型建筑 【图解】一篇搞定ReentrantLock的加锁和解锁过程

【图解】一篇搞定ReentrantLock的加锁和解锁过程

2024-06-01 11:44| 来源: 网络整理| 查看: 265

文章目录 1. 概述2. AbstractQueuedSynchronizer(AQS)3. 加锁4. 解锁5. 公平锁和非公平锁的区别

1. 概述

本文主要结合图片分析ReentrantLock加锁和解锁过程的源码,加锁和解锁的原理不清楚的读者可以好好看看。

2. AbstractQueuedSynchronizer(AQS)

在分析ReentrantLock加锁和解锁的过程之前,先来了解一下AQS,ReentrantLock就是基于AQS实现的。AQS其实就是维护了一个双向链表,主要的属性如下所示:

Thread exclusiveOwnerThread(继承父类AbstractOwnableSynchronizer)持有锁的线程 Node head 指向阻塞队列的队头 Node tail 指向阻塞队列的队尾 int state 当前锁的状态

在这里插入图片描述

Node对象是对Thread进行封装,主要的属性如下所示:

Thread thread 记录当前结点关联的线程 Node prev 指向在前驱结点 Node next 指向后继结点 int waitStatus 结点状态标记 在之后的博客中再来详细的介绍这个属性

下面先来分析ReentrantLock的加锁过程

3. 加锁

加锁对外提供的API有lock(),lockInterruptibly(),tryLock(),tryLick(long,TimeUnit),四种方法,本文主要分析lock()方法的过程,其他的大同小异,在之后的博客当中再更新这4者的使用区别和原理细节的不同。

lock方法如下,内部调用了AQS当中的lock方法

在这里插入图片描述

ReentrantLock当中AQS有两种实现,一种是公平锁FairSync,另一种是非公平锁NonFairSync。而默认的实现是非公平锁,下面来主要分析非公平锁的lock()方法。

在这里插入图片描述

这里比较简单,if判断 cas修改锁标记是否成功,若修改成功,那么就修改exclusiveOwnerThread属性为当前线程,否则调用acquire方法 继续抢锁。

例如此时t1线程当中调用了lock方法抢到了锁,此时的ReentrantLock 对象如下所示: 在这里插入图片描述

接着第二个线程 t2 调用了lock方法,那么if得到的结果肯定是false,修改状态标记失败,会调用acquire方法,下面来看看acquire方法的逻辑

在这里插入图片描述

这里涉及到了4个方法,一个一个按照逻辑调用的顺序来说明:

tryAcquire 如果返回false —> 2. addWait —> 3. acquireQueued 返回true —> 4. selfInterrupt

先来分析tryAcquire

在这里插入图片描述

内部调用nonfairTryAcquire

在这里插入图片描述

该方法有两种情况可以拿到锁

锁没有人占用持有锁的线程和当前线程相同,表示可重入

但是现在假设的当前线程是t2线程,此时锁是被t1持有的,所有这时候t2拿不到锁,那么tryAcquire的结果就是false

接下来会调用addWait方法

在这里插入图片描述

将当前t2线程,封装成node结点,pred指针指向tail

当前tail为空,所以不会进入if

调用enq方法,enq方法代码如下:

在这里插入图片描述

这里是一个死循环,第一轮循环新的指针t指向tail,此时tail为空,所以会进行cas 修改head结点(cas是原子性的就算此时多个线程都进入了enq方法,那么有且仅有一个设置头结点成功,其余的线程进入下一次for循环),然后将tail指向head,此时的ReentrantLock如下图所示:

在这里插入图片描述

第一层循环结束,该方法并没有结束,进行第二次的for循环,此时t不等于null,所以会进入else的逻辑

node结点的前驱指向t,cas修改tail指向node,修改t的next等于node,结果如下:在这里插入图片描述

所以addWaiter方法就是将当前线程封装成Node结点,然后放入阻塞队列的尾部,也就是入队操作。

接下来就来到第三个方法acquireQueued

在这里插入图片描述

这里主要是一个死循环,首先拿到当前结点的前驱结点p,对于现在的例子来说,当前结点是t2的结点,它的前驱就是head。此时p等于head 可以再一次的调用tryAcquire去获取锁。

为什么这里还要再来一次呢?是因为再上一次获取锁失败 到t2 形成Node结点入队的这个期间,t1可能会释放锁。这样t2就可以在这里获取到锁了。可以说这样做是为了减少线程阻塞唤醒的次数。

如果t1 释放了,那么t2就有可能可以获得到锁。此时就调用setHead方法修改head指针,并把当前结点的thread 设置为null,原先head结点的后继设置为空。这样原先head结点 就没有和GCroot关联,在垃圾回收的时候就会处理掉它。ReentrantLock如下图所示:

在这里插入图片描述

如果t1此时没有释放锁,那么t2 再次获取锁失败。则会调用shouldParkAfterFailedAcquire方法判断是否需要park,代码如下:

在这里插入图片描述

获取前驱结点的ws,如果ws等于-1直接返回true,表示需要park。

否则走下面的逻辑,如果ws大于0,说明该线程取消了,那么就进行循环,将取消的线程跳过,什么是取消的线程之后再讨论,一般情况下是不会进入这个if的。如果ws不大于0 走else的逻辑,cas设置前驱结点的ws为-1。设置为-1的意义表示,当前ws为-1的结点有义务唤醒它的后继结点。在之后的锁的释放当中,可以看到ws的作用。

此时ReentrantLock的结果如下:

在这里插入图片描述

上面的shouldParkAfterFailedAcquire逻辑执行完 返回false。那么它就不会调用parkAndCheckInterrupt。继续执行下一个for循环。

同样的此时t2结点的前驱还是head,所以可以再一次的尝试获取锁。看看此时t1有没有释放锁,如果释放锁了,那么就可以拿到,否则继续调用shouldParkAfterFailedAcquire。但是此时前驱结点的ws已经是-1了 所以直接返回true。

接着就会执行parkAndCheckInterrupt方法,这才阻塞了当前线程。代码如下:

在这里插入图片描述

可以看到ReentrantLock的加锁过程,尽可能的让当前线程更多机会的去尝试获取锁,避免线程阻塞,发生系统调用。

假设此时t3线程调用了lock方法,相信读者可以分析出此时ReentrantLock的结果了吧,如下所示:在这里插入图片描述

4. 解锁

解锁对外提供的API是unlock方法,内部调用AQS的release方法,代码如下:

在这里插入图片描述

release方法如下:

在这里插入图片描述

调用tryRelease 当前线程释放锁。

如果释放成功 则获取头结点,如果头结点的ws !=0 也就是等于-1的话,会调用unparkSuccessor来唤醒下一个结点。

下面就来分析tryRelease和unparkSuccessor方法

tryRelease的代码如下: 在这里插入图片描述

计算当前锁的新的state状态 =》c

判断当前线程是否是持有锁的线程,如果不是的话直接抛出异常,没有持有锁就想释放锁 不是做梦吗。。。

设置锁是否完全释放的标记free

如果c等于0 说明锁被完全释放了,那么将完全释放标记free设为true,再将AQS中的exclusiveOwnerThread设置为null,表示没有线程持有这把锁。

更新state,返回free。true 表示完全释放了锁,false 表示还没有完全释放锁。

执行完tryRelease,ReentrantLock对象如下图所示: 在这里插入图片描述

如果该方法返回true 接着会调用unparkSuccessor方法去唤醒阻塞队列当中的下一个结点。

在这里插入图片描述

此处的node是头结点

首先将node的ws修改为0,然后拿到头结点的后继结点。如果后继结点为空,或者后继结点关联的线程被取消了,那么就从tail开始往前找到ws小于0的最靠前的结点赋值给s。

然后将s结点的线程唤醒。一般情况下就是唤醒阻塞队列当中的头结点的后继结点。

unparkSuccessor方法结束后,ReentrantLock对象如下图所示:

在这里插入图片描述

t2被唤醒,回想一下刚才t2 是在哪里被阻塞的,是在parkAndCheckInterrupt 当中调用了LockSupport.park(this)阻塞的。那么t2被唤醒后,会继续从这里开始执行后面的代码

在这里插入图片描述

之后的代码 调用了Thread.interrupted()。清除打断标记,至于为什么要这样做之后再说明。

再调用Thread.interrupted()方法之前,如果当前线程被打断过,清除打断标记 返回true,如果没有被打断过则返回false。

此时t2没有被打断过,所以返回false。函数返回上一层就来到了acquireQueued方法当中,继续执行

在这里插入图片描述

因为parkAndCheckInterrupt()返回false,所以此处是否被中断的标记并没有被修改,继续执行下一次for循环。

拿到t2结点的前一个结点p,此时p就是head 所以会调用tryAcquire尝试获得锁。tryAcquire方法前面已经分析过了,此时如果没有其他线程来抢锁,那么t2肯定是可以拿到锁的,返回true。

然后更新AQS的队头,修改p结点的后继 方便垃圾回收,返回中断标记。acquireQueued方法结束,ReentrantLock对象如下所示:

在这里插入图片描述

至于此处的failed标记有什么用?

是为了在JVM运行抛出异常或者程序员调用API错误导致了一些异常的情况下,在finally块中执行cancelAcquire方法取消当前线程获取锁的操作。被取消的线程对应的Node结点当中的ws被设置为1.所以之前唤醒后继结点的时候,会将ws大于0的结点跳过,因为它已经取消获取锁的操作了。

还记得acquire方法当中有4个方法吗,再来回顾一下

在这里插入图片描述

tryAcquire 如果返回false —> 2. addWait —> 3. acquireQueued 返回true —> selfInterrupt

前面3个方法已经分析过了,还剩下最后一个。这里也就会涉及到为什么被唤醒的线程要调用Thread.interrupted()清除中断标记。当parkAndCheckInterrupt()方法返回true的时候,会将打断标记设置为true。也就是说,在这个线程被打断后程序需要对它进行一定的处理,但是这里又无法像sleep一样响应中断,所以Doug Lea的响应中断的逻辑就是 让它成为没有被打断过一样,继续的执行代码,拿到锁之后返回中断标记。如果此时的中断标记为true,那么就会执行上面的selfInterrupt方法,进行补偿中断,中断当前线程,设置中断标记为true,改回来它曾经被中断过。之后程序员如果需要这个中断标记做一些其他的业务处理的话,就不会受到影响。

在这里插入图片描述

selfInterrupt代码比较简单,就是进行了一次打断,设置中断标记为true

在这里插入图片描述

以上就是非公平锁的加锁和释放的过程。

5. 公平锁和非公平锁的区别

先来看看两个lock方法代码,区别还是挺明显的,非公平锁,会先进行cas尝试拿锁,如果拿锁失败了才会调用acquire,而公平锁是直接调用acquire方法

在这里插入图片描述

acquire方法有什么区别呢?看下面的代码 在这里插入图片描述

这里是一样的,区别在于tryAcquire当中的实现不一样,下图左边是非公平锁的实现,右图是公平锁的实现,主要区别就一行代码,是否调用hasQueuedPredecessors方法,当hasQueuedPredecessors的返回结果是false的时候,公平锁才会调用cas尝试获取锁

在这里插入图片描述

下面来分析hasQueuedPredecessors方法什么情况下会返回false

在这里插入图片描述

两种情况会返回false

队列为空。h==t 这时候说明没有其他线程在等待拿锁,此时可以去尝试拿锁队列不为空,但是当前头结点的后继结点关联的线程等于当前线程,此时表示要进行重入拿锁。

总结区别:

使用非公平锁,线程来尝试拿锁的时候,不用先看阻塞队列当中是否有线程在等待拿锁,都可以先进行尝试拿锁,没拿到才进入队列当中。使用公平锁,线程需要看阻塞队列当中是否有线程在等待,如果有线程在等待的话,如果头结点的后继结点关联的线程不是当前线程,那么就没法加锁,直接进入队列


【本文地址】


今日新闻


推荐新闻


CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3