浅析AQS抽象队列同步器

        我们都知道AQS是Java JUC包下大多数锁的基石,它本质上是一个抽象类AbstractQueuedSynchronizer,那么它的作用是什么呢?

        举个栗子,现在你去银行办理业务,这个时候跟你一起被分配到同一个窗口办理业务的有5个人,而你们5个人在同一个时间段内只能有一个人占有这个窗口,也就是占有这个共享资源,那么其他4个人,就需要在等候区等待,等待什么呢?等待窗口重新空闲,也就是等待共享资源被释放,那么,如何让其他4个人知道这个窗口被释放了呢?通过“叫号”,也即唤醒,只有“叫号”叫到你了,才表明窗口已释放,就等着你去占有这个窗口了。

        通过上面的栗子,我们可以知道,当一个共享资源被占用,就需要一定的阻塞等待唤醒机制来保证共享资源的分配,而对于AQS来说,它实现这种机制主要是通过CLH队列的变种来保证的,这就是AQS的作用。

        在讲解AQS内部大致逻辑之前,需要先了解几块前置知识,一个是自旋+CAS,一个是LockSupport。自旋+CAS这里就不详细展开了,有兴趣的自行去了解,下面我们来了解下什么是LockSupport。

        LockSupport,官方定义为,它是一种用来创建锁和其他同步类的基本线程阻塞原语。其实它算是一个线程阻塞工具类,它里面所有的方法都是静态的,通过这些方法,它能够让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport调用的是Unsafe类中的native代码。(而Unsafe.class存在于rt.jar包里面,由于Java方法无法直接访问底层系统,需要通过本地native方法来访问,unsafe相当于一个后门,基于该类内部方法的操作可以像C的指针一样直接操作特定内存的数据)

        相信大家在我刚刚说到阻塞唤醒的时候,应该会立马想到wait-notify跟await-signal吧,为什么已经有阻塞唤醒的相关方法了,还要出来一个LockSupport呢?因为无论是wait-notify还是await-signal,都严格按照先阻塞再唤醒的顺序,如果先唤醒再阻塞的话,会报错!!!相反,LockSupport允许先唤醒,后等待,为什么?因为LockSupport和每个使用它的线程都有一个许可证关联,这个凭证就是permit,permit也相当于1,0的开关,默认是0,当调用一次LockSupport.unpark(),permit就加1变成1,当调用一次LockSupport.park()会消费permit,也就是将1变成0,同时park立即返回。如果再次调用park()会变成阻塞,因为permit为零了会阻塞在这里,一直到调用unpark()将permit置为1为止,需要注意的是,permit最多只会有一个,重复调用unpark()也不会积累凭证。

        说完了LockSupport,我们可以一起来探究AQS里面到底有什么,上图:

image.png

        可以看到,AQS它包含了一个head头指针、tail尾指针、一个用来表示持有锁的状态的volatile修饰的int类型的成员变量state,state为0表示锁空闲,为1表示锁被占用,同时还有一个静态的final修饰的类Node,这个Node就是队列中的一个个节点,也就是一个个请求共享资源的线程,这个Node类中又包含了什么呢?上图:

image.png

        Node类中包含了Thread对象也即线程对象本身,还有前驱指针prev跟后继指针next,还有一个waitStatus表示当前线程节点在队列中的等待状态。

        1. 我们可以拿那个ReentrantLock重入锁来说,假如线程A调用了重入锁的lock方法时,由于ReentrantLock默认是非公平锁,所以首先会来到非公平的一个lock()方法,lock方法里面它会通过cas去对state变量做一个判断修改,看state是否为0也即是否空闲,是0的话就改为1,并且把当前占用该锁的线程的值设置为线程A,也即Thread = ThreadA;

image.png

        2. 然后这个时候线程B也调用lock(),通过cas判断发现state值已经变成1了,表示锁已经被占用了,然后走acquire(1)方法;

image.png

        3. 在acquire()方法中,首先会进入到非公平的tryAcquire()方法,这个方法里有三种情况,第一种情况就是线程B去尝试获取锁,刚好这个时候线程A已经释放锁了,然后state变为0,线程B拿到了这个锁资源,把state再次改为1,同时把当前占用线程的值设置为线程B,也即Thread = ThreadB;第二种情况就是当前持有锁的线程是线程A,然后线程A又拿到了重入锁,这个state变量会从1修改为2,表示这次是重入;第三种情况就是线程B尝试获取锁,但是线程A还在占用,因此会直接return false;

image.png

        4. 后续会通过addWaiter()方法,把线程B添加到等待队列中,但是线程B并不是等待队列中的第一个节点,在这之前,会new一个新的节点作为哨兵节点去占位,此时在等待队列中,头指针head、尾指针tail都指向这个哨兵节点,然后再通过自旋+CAS的方式去操作尾指针把线程B放入队列尾部,这个时候,等待队列中,头指针指向的仍然是哨兵节点,而尾指针已经指向了B节点,并且B节点的前驱指针会指向哨兵节点,哨兵节点的后继指针会指向B节点,哨兵节点跟B节点此时的waitStatus都默认为0,且哨兵节点(Node类)的Thread = null,B节点的Thread = ThreadB;

image.png

        5. 后面要是线程B再次去尝试获取锁并且失败的话,它会把哨兵节点的waitStatus改为-1,-1表示线程已经准备好了,就等待资源释放了,然后还会调用LockSupport.park()去阻塞住线程B,也就是让B节点彻底在队列中等待,直到当线程A调用了unlock()方法去释放锁的时候,会判断队列中的头指针指向的节点是不是null,并且节点的waitStatus是不是0,而现在头指针指向的是哨兵节点而不为null,哨兵节点的waitStatus为-1,然后就会调用unpark()方法,在unpark()方法中,会将哨兵节点的waitStatus重新设置为0,然后去调用LockSupport.unpark()方法去唤醒线程,而唤醒的线程就是node.next,也就是哨兵节点的后继指针指向的节点,也即之前被阻塞住的B节点。线程B被唤醒后,尝试获取锁,如果拿到了锁,那么这个时候队列里,会把头指针从指向原来的哨兵节点改为指向B节点,B节点的Thread也会被设置为null,即Thread = null,因为线程B已经拿到了锁,还会把B节点的前驱指针设置为null,不再指向原来的哨兵节点,原来的哨兵节点的后继指针也会设置成null,不再指向B节点,这个时候,在等待队列中,原来的哨兵节点既没有头指针、尾指针指向,也没有前驱指针指向,后继指针也没有指向谁,也就是说,原来的哨兵节点已经没有任何引用了,因此,等到下一次发生GC的时候就会把它回收掉,而等待队列中的头指针、尾指针都已指向了原来的B节点,B节点也就变成了新的哨兵节点。

image.png

        AQS大致的逻辑就是这样,那么有个问题就来了,为什么需要哨兵节点作为首节点?

        我个人感觉是为了方便查询队列中的元素,比较固定,这样就只需要通过改变尾指针来操作线程入队,如果没有哨兵节点的话,需要经常去更新头指针的指向,不然头指针指向为null的话会影响后续线程入队的。

        不知道各位大佬是如何看待这个问题的,希望告知一下,谢谢。

        对了,AQS还能解决双端队列的尾分叉问题:

        尾分叉就是说同一个时刻有多个线程把自己设置成那个队列的尾部导致出现的情况,AQS它并不是从队列头到队列尾的一个遍历,经常性的它是从尾部向前去遍历,就是因为AQS它考虑到多线程的一个并发环境,大家都可能把自己设置成那个尾部,但是实际上只能有一个会真正被设置成尾部,那么这个时候如果从头部往尾部遍历的话,它只是把自己指向了原来的那个尾部节点,即对node.prev做了赋值处理,但是原来尾部节点可能还未指向新的尾节点,即node.next还未进行赋值,仍然为null,这样的话,它只能获取到头部 -> 原来尾部节点的数据,数据并不完整,而要是从尾部向前去遍历的话,就能获得当前尾部节点 -> 头部完整的数据信息。

image.png

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值