关于锁的底层框架AQS
谈到多线程就不得不说锁,锁是保证多线程并发中保证数据安全的重要工具,当然锁的底层实现也是稍微有些复杂的,关于这方面的知识我也是花了很久时间才弄明白一点,写下这篇文章也算是记录一下心得体会。
平时使用锁时可能常用的就是synchronized或者是ReentrantLock,但是锁的底层AQS浑然不知,借助这个机会也一次性弄懂AQS。 AQS全称为AbstractQueuedSynchronizer(真长),这个类是JAVA官方提供的,想实现特定功能的锁只需要继承这个抽象类然后实现相关的方法就可以了,这个类本身并没有具体实现具体怎么锁住代码块的功能,实际上只是维护了一个共享资源变量(volatile int state)和一个FIFO线程等待队列(线程阻塞时会将线程存放到这个等待队列中)。具体访问state的方式有三种

  1. getState()
  2. setState()
  3. compareAndState()
    AQS定义了两种资源共享方式,Exclusive(独占模式,每次只有一个线程能够访问资源),Share(共享模式,多个线程都可以访问资源)。
    不同的自定义同步器(锁,后面将用同步器代替锁这种说法,更加书面化)共享资源的方式也不同,自定义同步器在实现时只需要实现共享资源state的获取与释放即可,至于线程等待队列的维护,我在前面就说过了AQS已经完成了这些工作。
    • isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
    • tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
    • tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
    • tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
    • tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
      以ReentrantLock为例,state初始化为0,表示未锁定状态。A线程lock()时,会调用tryAcquire()独占该锁并将state+1。此后,其他线程再tryAcquire()时就会失败,直到A线程unlock()到state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A线程自己是可以重复获取此锁的(state会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证state是能回到零态的。
      再以CountDownLatch以例,任务分为N个子线程去执行,state也初始化为N(注意N要与线程个数一致)。这N个子线程是并行执行的,每个子线程执行完后countDown()一次,state会CAS减1。等到所有子线程都执行完后(即state=0),会unpark()主调用线程,然后主调用线程就会从await()函数返回,继续后余动作。
    一般来说,自定义同步器要么是独占方法,要么是共享方式,他们也只需实现tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一种即可。但AQS也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock。

前面的基础知识铺垫完了,那么现在对源码进行分析。
1) 结点状态waitStatus
这里我们说下Node。Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。变量waitStatus则表示当前Node结点的等待状态,共有5种取值CANCELLED、SIGNAL、CONDITION、PROPAGATE、0。
• CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
• SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
• CONDITION(-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
• PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
• 0:新结点入队时的默认状态。
注意,负值表示结点处于有效等待状态,而正值表示结点已被取消。所以源码中很多地方用>0、<0来判断结点的状态是否正常。
2) acquire(int)方法
此方法是独占模式下获取共享资源的顶层入口,如果获取到资源线程就直接返回,否则线程进入等待队列,直到获取资源为止,整个过程中忽略中断。下面是acquire(int)的源代码
image.png

 函数流程如下:

  1. tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
  2. addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
    现在看这些方法还有点朦胧,但是不要紧,看完接下来的分析你就会明白了。

2)方法的源码解读
1.tryAcquire(int)方法,源代码如下:
image.png

居然只抛出了一个异常,很显然不对劲,但是不要忘记,我在前面就一直说AQS是一个框架,具体的获取和释放资源是由具体的同步器去实现的,其实这里采用的是模版方法设计模式(一种设计模式,有兴趣的朋友可以具体百度了解一下,后续我也会出一些设计模式的记录文章)。当然,这个方法之所以没有设计成abstract,是因为独占模式下只需要实现tryAcquire-tryRelease,而共享模式下只用实现tryAcquireShared-tryReleaseShared。如果都定义成abstract,那么每个模式也要去实现另一模式下的接口。很显然,写底层代码的人还是为了我们着想,让开发者尽量减少不必要的的工作。

  1. addWaiter(Node)
    这个方法用于将当前线程加入到等待队列的队尾,并且返回当前线程所在的节点,源码如下,有注解,应该是看得比较清楚的。
    image.png

这里补充一下Node这个类,这是AQS中的一个静态内部类,实际上是一个先进先出的队列,用来维护等待线程。
Enq(Node)方法,不多说了,直接上源代码
image.png

懂行的人一眼就看出来这段代码的精华,CAS自旋volatile变量,很经典的用法,如果还不熟悉用法,建议百度一下。

  1. acquireQueued(Node,int)
    通过tryAuquire和addWaiter()方法,该线程已经获取资源失败,被放入等待队列尾部了,下一步要做的就是进入等待状态休息,知道其它线程释放资源后唤醒自己,自己再拿到资源,然后再做想做的事情,是不是和医院拿号有点相似,拿到号之后等待前面的病人问诊完成,轮到自己时再看病,acquireQueued(Node,int)干的就是这件事:在等待队列中排队拿号,直到拿到号了再返回。这个方法非常的关键,上源代码
    image.png

先不着急看acquireQueued()的流程,先看看shouldParkAfterFailedAcquire()和parkAndCheckInterrupt()具体干些什么。

shouldParkAfterFailedAcquire(Node, Node)
这个方法用于检查状态,看看自己是不是真的能够休息了。
image.png

整个流程查询前驱的状态是不是SIGNAL,不是的话就不能安心休息,而是找个安心的休息点,同时试一下看看自己有没有机会拿到号。

parkAndCheckInterrupt()
image.png

如果线程找好安全休息点后,那就可以安心去休息了。此方法就是让线程去休息,真正进入等待状态。

park会让线程进入wating状态,这个状态有两个途径唤醒,1.unpark() ;2.interrupt()。
OK,看了shouldParkAfterFailedAcquire()和parkAndCheckInterrupt(),现在让我们再回到acquireQueued(),总结下该函数的具体流程:

  1. 结点进入队尾后,检查状态,找到安全休息点;
  2. 调用park()进入waiting状态,等待unpark()或interrupt()唤醒自己;
  3. 被唤醒后,看自己是不是有资格能拿到号。如果拿到,head指向当前结点,并返回从入队到拿到号的整个过程中是否被中断过;如果没拿到,继续流程1。
  4. 小结
    OK,acquireQueued()分析完之后,我们接下来再回到acquire()!再贴上它的源码吧:
    image.png

再来总结下它的流程吧:

  1. 调用自定义同步器的tryAcquire()尝试直接去获取资源,如果成功则直接返回;
  2. 没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
  3. acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
  4. 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。

至此,acquire()的流程终于算是告一段落了。这也就是ReentrantLock.lock()的流程,不信你去看其lock()源码吧,整个函数就是一条acquire(1)!!!

3)释放资源过程
上一小节已经把acquire()说完了,这一小节就来讲讲它的反操作release()吧。此方法是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。这也正是unlock()的语义,当然不仅仅只限于unlock()。下面是release()的源码:
image.png

逻辑并不复杂。它调用tryRelease()来释放资源。有一点需要注意的是,它是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自定义同步器在设计tryRelease()的时候要明确这一点!!

1.tryRelease(int)
此方法尝试去释放指定量的资源。下面是tryRelease()的源码:
image.png

跟tryAcquire()一样,这个方法是需要独占模式的自定义同步器去实现的。正常来说,tryRelease()都会成功的,因为这是独占模式,该线程来释放资源,那么它肯定已经拿到独占资源了,直接减掉相应量的资源即可(state-=arg),也不需要考虑线程安全的问题。但要注意它的返回值,上面已经提到了,release()是根据tryRelease()的返回值来判断该线程是否已经完成释放掉资源了!所以自义定同步器在实现时,如果已经彻底释放资源(state=0),要返回true,否则返回false。

  1. unparkSuccessor(Node)
    此方法用于唤醒等待队列中下一个线程。下面是源码:
    image.png

这个函数并不复杂。一句话概括:用unpark()唤醒等待队列中最前边的那个未放弃线程,这里我们也用s来表示吧。此时,再和acquireQueued()联系起来,s被唤醒后,进入if (p == head && tryAcquire(arg))的判断(即使p!=head也没关系,它会再进入shouldParkAfterFailedAcquire()寻找一个安全点。这里既然s已经是等待队列中最前边的那个未放弃线程了,那么通过shouldParkAfterFailedAcquire()的调整,s也必然会跑到head的next结点,下一次自旋p==head就成立啦),然后s把自己设置成head标杆结点,表示自己已经获取到资源了,acquire()也返回了!

最后的小结:
release()是独占模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果彻底释放了(即state=0),它会唤醒等待队列里的其他线程来获取资源。其实AQS做的就是这些,当前这只是一个简单的概述,还有共享锁没有写出来,篇幅实在是太长了,有兴趣的朋友可以自己去搜索源代码或者博客查看相关信息。这篇博客也参考了另一位博主的文章,链接如下:https://www.cnblogs.com/water...


cheungZ
1 声望0 粉丝