您的位置:首页 > 电视 >

面试官:不会AQS?那你简历上写什么并发编程

关于AQS(AbstractQueuedSynchronizer),其实平时我们使用的最多的就是ReentrantLock,我们先看看ReentrantLock到底是个什么东东

原来ReentrantLock 是Lock的实现类之一,而且它还有一个非常重要的属性Sync,这个类可是关键哦。当我们调用ReentrantLock的Lock()方法加锁的时候发生什么?进入源码一看,原来它调用了sync的lock()方法


(资料图片仅供参考)

那这个sync又是什么东东呢?我们再点进源码一看,原来这个就是我们大名鼎鼎的AQS类的子类之一啊!!里面也有lock()方法

好啦,这里我们就看到了我们经常说的ReentrantLock原来是借助AQS实现的锁啊,那么接下来我们就要进入AQS的源码去看看啦,冲冲冲

这里的lock是一个抽象方法,它有不同的实现类

盲猜,也是就我们所说的公平锁和非公平锁了,毕竟面试官问你它的优势,你肯定会说它既能实现公平锁又能实现非公平锁

果不其然

我们先来看看公平锁的实现原理,公平锁的实现里面调用acquire()方法,并且传入了参数1

继续刨根,这里的acquire()的大概逻辑就出来了。我们先说一下大概的逻辑,再往里面深扒。首先这里tryAcquire方法是尝试获取锁,如果失败了addWaiter方法是进入一个队列,这个队列是双向链表维护的;进入队列之后,这个线程还是不得死心,它还要再挣扎一下,也就是我们通常所说的自旋(2次),于是它继续调用acquireQueued方法,最后实在拿不到锁了,它就睡眠自己了。

这就是AQS去加锁的一个大致流程。看到这里你可能还不觉得Doug Lea有多牛逼,但是等会你就叹服

我们先去看一下tryAcquire方法,尝试获取一下锁。

下面我们就一起畅游吧,首先拿到当前运行的线程current ,再通过getState方法获取锁的状态,这里的state属性表示锁的状态,默认为0表示无锁。同时为了保证线程之间的可见性,使用volatile关键字修饰private volatile int state;假如此时状态为0,进入第一个if判断,这里通过hasQueuedPredecessors方法判断自己是否需要入队。我们进入这个方法里面看看

这个方法里面有两个Node节点,分别是头尾节点,初始值都为null,此时我们是第一个线程进来拿锁,所以队列并没有被初始化,因此h != tfalse,后面的&&就自然短路了。整个方法返回false。好,那就继续回到咱们上面的尝试获取锁的第一个if代码块

由于hasQueuedPredecessors方法返回false,前面加了!,所以整个为true。继续判断&&后面的代码,这里就有了CAS原子操作了。compareAndSetState(0, acquires)是以CAS的方式将锁的状态从0修改为1。假如线程拿到了锁,返回true,进入下面的代码块setExclusiveOwnerThread(current),这个方法就是为了把当前线程设置为拿到锁的线程,为啥呢?后面在判断可重入锁的时候用。最后整个tryAcquire方法返回了true。

该线程拿到了锁,进入了我们的业务逻辑代码开始执行…

这里只是模拟第一个线程来拿锁的情况,怎么样,挺得住吗

接下来咱们开始看看面试官都喜欢问的可重入锁,其实很简单,也在咱们的tryAcquire方法中。假如拿到锁的线程在业务逻辑代码中需要再次拿到锁,依旧会走上面的流程来到咱们的tryAcquire中,只不过此时的state已经被它自己修改为了1,所以进入这个代码块

首先判断当前线程是不是持有锁的线程啊?毋庸置疑,肯定是的,那就把锁的状态+1就完了,然后返回true,业务逻辑代码正常执行。怎么样?是不是很简单。原来可重入的意思就是线程可以再次拿到锁,只不过把锁的状态值增加1而已。

上面的过程你是不是感觉很简单嘛?确实,假如面试管问你AQS,你首先告诉他,多线程在交替执行的时候,AQS的队列并没有初始化,也没什么卵用。 他可能会觉得,这小子可能真看过源码

接下来咱们讲点有趣的。多线程不再交替执行,也就是说存在竞争了,那咋办?Doug Lea都给你安排的明明白白的,不慌。

第一个线程在执行,第二个线程来了,它也会进入tryAcquire方法,然后它发现锁的状态不为0,同时自己也不是持有锁的线程,那它只能可怜的拿到最后一行代码,返回false。接下来主场戏就要开始了,真的精彩,有尿你都憋住!!!

还记得acquire方法吗

此时tryAcquire返回false,前面加了,所以为true,于是代码继续往后判断,成功进入addWaiter方法,大胆的点进去

首先把当前线程封装为一个Node节点,然后判断tail节点是否为null,这里首尾节点都没有初始化,肯定为null啊,所以直接走enq(node),再点进去

这里面是一个自旋的方法,第一次自旋的时候,先通过CAS的方式创建一个空节点,并让head指向空节点,之所以叫空节点,是因为里面的thread为null。然后尾节点也指向这个空节点。然后再自旋一次,通过CAS的方式让当先线程所在的节点挂到空节点后面。

到此为止,第二个线程做了什么事情呢?首先去拿锁,拿不到;然后入队列,发现队列未初始化,自己去做了初始化的工作,并且把自己挂到了虚拟头节点的后面。接下来他该干嘛了呢?他要准备睡眠自己了,不过,他可不得这么轻易睡觉,你上床睡觉不得在被窝里挣扎一下

话不多说,来人,上源码进入acquireQueued方法啦,其实就是询问自己是不是真的要睡眠了

第一感觉就是又在自旋对不对,是的AQS就是自旋+CAS+双向队列+park。第一次自旋,先拿到当前线程所在Node节点的前一个节点final Node p = (),然后判断前一个节点是不是虚拟头节点,如果是的话,他就去尝试获取锁(第一次挣扎),这里我们假设他获取锁失败。于是进入下面的方法shouldParkAfterFailedAcquire(p, node),这个方法是在干嘛呢?大胆点进去

不要慌,代码很长,逻辑很短,全是注释,咱们一步一步来分析

首先,会拿到前一个节点的waitStatus,咱们简称ws。然后就是判断ws的值,ws默认为0,所以第一次自旋进来,当前线程会把他前面的节点的ws状态使用CAS的方式修改为-1,就是这句代码compareAndSetWaitStatus(pred, ws, ),然后返回一个大大的false。由于返回false,所以

不会继续往下执行;代码进入第二次自旋,第二次自旋我们又假设没有获取到锁,(不是线程二不给力,只是后面再专门讲在竞争情况下拿到了锁该怎么维护这个双向队列,忍耐一下)所以再次进入shouldParkAfterFailedAcquire方法,由于第一次自旋的时候ws已经被修改为-1了,所以这次直接返回true。然后就执行后面的if语句后面的代码块了

也就是说park当前线程,是线程睡眠。到这里我们发现,第二个线程在睡眠之前通过两次自旋又做了两件事情,第一件:把自己前一个节点ws修改为-1,第二件就是自己睡眠。

其实为啥也这样设计呢?我自己感觉哈,给head虚拟节点两次挣扎的机会,就是为了进来不让线程park,这会设计用户态和内核态的切换,十分消耗性能。我们一直抱有侥幸心里,总觉得可能我刚入队,前面一个线程就释放锁了呢。那我是不是可以不用睡眠,直接执行呢?这种情况确实可能存在。但是我们是公平锁,只有head节点的后面第一个节点可以去尝试获取锁,其它后面进来的节点你就早点洗洗睡吧,你前面的都还是排队呢,你猴急啥。选择两次自旋也是出于对性能的考虑,如果自旋太多,会十分影响CPU的性能。这个设计真的非常巧妙。

后面再来第三个、第四个线程等等都是这样自旋两次,唯一不一样的就是在acquireQueued方法中,因为自己的上一个节点不是head虚拟节点,所以不会执行tryAcquire方法,但是依旧会修改前面节点的ws,然后睡眠。这里面我们模拟了线程交替执行获取锁,模拟了第一个线程占用锁,后面所有的线程拿不到锁入队列的过程,就问你Doug Lea强不强???

听到这里,可能有小伙伴要为线程二正名了,为什么他就那么苦逼,每次拿不到锁。好的,那这次咱们就让他硬气一回,让他拿到锁,当队列中的Node拿到锁了又该如何去维护这个链表呢?愣着干嘛??上菜了假如线程二在自旋的过程中拿到锁了。开心,终于轮到我执行了

进入到if代码块中,首先是setHead方法,咱们去看看他干了什么事情

这个方法就是把head节点指向获取到锁的节点,然后把节点中的thread置为null,然后把他的前面阶段断开(这个以前的head虚拟节点就没有了引用,就会被GC回收了)。

看懂了吗?巧妙吗?折服了吗?

拿到锁的线程去执行了,然后他所处的Node变为了head虚拟节点,简直太优秀了!!!

到此,咱们公平锁的基本上就讲完了,这才讲了一半,哈哈哈,不过相信你已经可以自己去分析非公平锁的源码了,一步一步进去看,肯定可以看明白的

其实非公平锁也不难,我们大概看一下

这是他的lock方法,每个线程一上来,不管队列中有没有人派对,自己先去抢占锁,抢不到再说

后面有时间再阅读解锁和唤醒的源码。这是自己看了源码的一些心得体会,自己记录看一下,同时也希望帮助到正在啃源码的你。有错的地方可以在评论区留言交流

标签:

相关阅读

精彩放送