AQS(AbstractQueuedSynchronizer)是各种锁实现的基础,提供了对资源(state字段)的获取与阻塞等待,阻塞的线程会被放进一个先进先出(FIFO)的同步队列里。各种锁是AQS的子类,子类必须实现一套用来改变state变量(volatile 修饰的变量)的方法,包括锁资源的获取方法与锁资源的释放方法。始终记得:volitile和cas操作铸就了AQS的辉煌。
众所周知,锁分排他锁和共享锁, AQS对锁的获取与释放也是分两种情况的,即SHARED与EXCLUSIVE两种模式。即如下图代码:
EXCLUSIVE模式的锁必须实现tryAcquire和tryRelease两个抽象方法,同理,SHARED模式的锁必须实现tryAcquireShared和tryReleaseShared两个抽象方法。至于为什么AQS是个抽象类而不是接口的原因就在于次,比如你只需要排他锁就只用实现你需要的那两个方法,而不需要像接口那样需要实现全部抽象方法。当然也有同时实现两套方法的锁,如ReadWriteLock.
state资源
state字段是AQS锁的核心,即是锁资源,该字段是volatile修饰的。volatile主要对所修饰的变量提供两个功能:①可见性②防止指令重排序。AQS框架都是对state变量的CAS增减操作,不通的增减方式从而实现了不同性质的锁,例如重入锁在同对象再次重入该锁锁住的资源时候,会对state字段进行加一操作。操作state字段有三个方法:
getState()
setState()
compareAndSetState()
这三个均是原子操作,compareAndSetState的实现依赖于Unsafe的compareAndSwapInt()方法,即
自定义锁方法
如刚才介绍,有两种锁,有两套方法。每次自定义锁实现的时候需要自己去实现自己需要的方法。默认情况下不重写的方法返回抛出UnsupportedOperationException异常。以下是需要重写的方法:
isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
源码及原理
一、排他锁
1、acquire(int arg)
该方法调用后,首先回去尝试调用tryAcquire获取资源,如果获取到资源则直接返回。获取不到则调用addWaiter方法向队列尾部插入一个新节点,再调用acquireQueued方法使节点进入一个安全点休息,等待后续唤起
1.1、addWaiter(Node mode)
该方法将刚刚构造好的node节点用cas操作设置成尾结点,并修改指针成功加入队尾,如果队列为空则使用enq方法新构造一个队列放入。
1.1.1、enq(final Node node)
使用for无限循环,取到tail节点指针指的尾节点t,如果t为空则使用cas操作head指向一个新构造的没有任何任务线程的空节点当做头指针,如果cas设置成功则将tail节点也指向该空节点。如果t不为空,意味着并发情况下他人已经构建了一个队列,那么将node的prev指针指向节点t,并通过cas将队尾设置成要加入的节点node,cas成功后再将之前的尾结点指向入队的新节点,到此入队成功。
重要须知:head和tail两个字段均为volitile修饰的,意味着cas操作的时候会使字段值失效并重新从主存读取,也就保证了并发情况下,不会构建多个队列,也不会同时有多个节点入队指向同一个前驱节点的问题。
1.2、acquireQueued(final Node node, int arg)
首先提一个非常重要的点,该方法代码中interrupted字段的作用是用来补标记位的,当方法内部最后调用parkAndCheckInterrupt后因为Thread.interrupted()会清除当前线程的中断标记位。所以要在方法最后将中断标记位补上。parkAndCheckInterrupt方法代码后面讲。
该方法意思是在一个无穷for循环中,每一次执行都去拿到该节点的前驱节点p,当前驱节点p是头结点且能获得到资源,那么就认为这个节点可以执行了,就将头结点指针指向当前节点,并将前驱节点的next指针指向空,使得gc可达性算法能将其识别为垃圾回收,并直接返回成功。但是当前驱节点不是头结点或者尝试获取资源失败后,就要执行shouldParkAfterFailedAcquire来判断是否需要将该节点线程挂起。因为是短路与(&&),所以前者如果前者未达到挂起的要求,则返回false,将不执行parkAndCheckInterrupt方法。而当满足需要挂起的条件时则返回true,并调用parkAndCheckInterrupt来挂起线程。在挂起后如果被打断parkAndCheckInterrupt方法则会返回true,并通过刚才说说的补标记,将标记位补全。
acquireQueued的整体流程:
1、结点进入队尾
2、检查状态,找到安全休息点;
3、调用park()进入waiting状态,等待unpark()或interrupt()唤醒自己;
4、唤醒后,看自己是不是有资格能拿到资源。如果拿到,head指向当前结点,并返回从入队到拿到号的整个过程中是否被中断过;如果没拿到,继续流程2。
1.2.1、shouldParkAfterFailedAcquire(Node pred, Node node)
该方法是用来判断是否能够将当前节点挂起的方法。首先我们要清楚Node的各种status含义:
status初始化都是int默认值0,被取消:1,等待唤醒:-1,condition等待:-2,可传递状态:-3.
因为我们目前看的是排他锁,所以忽略PROPAGATE状态。
通过代码我们可以知道,这个方法设置前驱节点的状态值并返回true或false。
①当前驱节点状态是SIGNAL,意味着前驱节点已经挂起,这时候自身节点则到达可以挂起的安全点,返回true
②当前驱pred节点的状态是被取消了的,那么将不断向前找节点,直到找到一个前面的pred节点的状态<=0,并设置当前node节点的prev指针指向该节点,意味着忽略被取消的节点。返回false
③如果状态是0或者-3,那将cas设置pred节点的状态为SIGNAL。返回false
单独看这个方法感觉并没有很厉害,但是不要忘了这个方法是嵌套在外层方法的for循环里的,综合外层的方法可以明白:不停的去检验是否能获取到资源及如果获取不到资源再通过该方法寻找到一个安全点,找到一个安全点以后才能挂起
到此我们需要梳理一下一个新任务来抢夺资源后的过程:
到此我们可以大体清晰的看到入队及挂起的流程,这只是最简单的样子,现实情况会比这个复杂很多
1.2.2、parkAndCheckInterrupt()
调用LockSupport.park(Object blocker) 方法,线程挂起,注意挂起后不会return,只有在①被unpark或②被interrupt后才可以执行。唤醒后继节点的方法只有这两种,最后需要注意的是Thread.interrupted()会清除当前线程的中断标记位(重复一下,很重要,刚才已经提过)
1.2.3、cancelAcquire(Node node)
该方法在acquireQueued方法发生在acquireQueued方法的for循环排队获取资源之后的finally里。当获取资源失败且发生了异常时,用来取消仍在进行尝试获取资源的该节点。
可以从代码里看到先找到一个状态不是被取消的前面的节点(pred),并将自身的prev指针指向这个pred节点。
之后判断是不是尾结点,如果是尾结点,则用cas将tail指针指向pred,并将pred的next指针指向null,来帮助触发gc。
如果是头结点,则直接调用unparkSuccessor来唤醒后继节点,将传递性延续下去。如果既不是头也不是尾结点的时候,①pred的状态不为SIGNAL,且再次判断状态不是CANCELLED,且cas设置状态为SIGNAL失败,则也直接唤醒后继节点 。②当pred节点的thread为空,也就是刚enq队列时使用的空任务节点,则也直接唤醒后继节点。③前驱节点为SIGNAL或者cas设置为SIGNAL成功,且前驱节点的thread任务不为null,则用cas设置pred节点的next指针指向要取消节点的后继节点。并且将要取消节点的next指针指向自己,用来触发gc回收自己。
疑点:有人可能会问,问啥用node.next = node;就可以触发gc了,刚才遍历的node节点前状态为被取消的那些节点的prev和next指针都还没改啊?而且要取消的节点的prev指针也还在啊?以下是个人理解:
对于第一个问题,那些遍历过为被取消状态的节点他的next也是指向自身的。而他们的prev指针也会想下一个问题的处理方式一样被处理。
对于第二个问题,被取消状态的节点肯定也是执行过cancelAcquire方法的,所以prev也是指向一个当时SIGNAL状态的节点,当前驱执行完用tryAcquire将指向执行完的节点的prev置为null,就完成了prev指针的回收(这块属于个人理解,如果有异议,请尽情纠正我,让我学习一下)
1.3、selfInterrupt()
该方法很简单,只是调用了Thread的方法,当acquireQueued返回true的时候以为这被打断过,就是上述提到的要补标记。
2、release(int arg)
尝试调用tryRelease释放资源,成功唤醒后继节点。唤醒前要判断队列头节点是不是null且是否不为状态0,都满足则调用unparkSuccessor尝试唤醒队列里的下一个结点
2.1、unparkSuccessor(Node node)
获取要当前node节点状态,如果状态小于0,则用cas设置状态为0,以表示执行成功。
获取node的后继节点s。如果s不为null则用LockSupport.unpark(s.thread);唤醒s节点任务。如果s为null或者s节点的状态为被取消,则意味着该节点无效,需要找一个唤醒,那么Doug Lea是怎么做的呢?我们看到一段非常牛逼的代码,从队尾往前倒推,找到一个状态小于等于0的节点来唤醒。
疑点:为什么是从队尾往前倒推找节点唤醒呢?
从tail开始倒推查找,原因在于enq方法插入是,新节点prev指向tail,tail再指向新节点。这里后继节点指向前驱的指针是由cas操作保证线程安全的。而cas操作之后t.next=node之前,可能会有其他线程进来。所以出现了问题。所以从尾部倒推查找是一定能遍历到所有节点的。
排他锁获取流程总结:
1、调用自定义同步器的tryAcquire()尝试直接获取资源,如果成功则直接返回;
2、没成功,则addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
3、acquireQueued()使线程在等待队列中休息,有机会时(轮到自己,会被unpark())会去尝试获取资源。获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
4、如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
二、共享锁
1、acquireShared(int arg)
共享锁的获取在于资源可以不支持多人共享获取。在尝试获取资源,tryAcquireShared返回结果小于0表示获取失败,会调用doAcquireShared尝试重新获取,进入队列并挂起等待后续唤醒。如果获取到资源且资源还没有使用完的情况下,可以将资源延续下去,去唤醒后继节点。例如资源数为10,一号任务需要5资源,二号任务需要4资源,三号任务需要1资源,那么一号获取到资源执行后还会尝试唤醒二号,二号唤醒获取到资源后还会尝试唤醒3号。但是不能跳过顺序使后边节点越级获取。例如资源数为10,一号任务需要6资源,二号任务需要5资源,三号任务需要4资源,那么一号获取到资源执行后还会尝试唤醒二号,二号在判断时候发现自己不满足资源,那么他将挂起自己并不会有唤醒后继节点的操作。
1.1、doAcquireShared(int arg)
将当前线程加入等待队列尾部休息,直到其他线程释放资源唤醒自己,自己成功拿到相应量的资源后才返回。这里会发现获取到资源后调用setHeadAndPropagate设置头结点并在还有资源的情况下调用doReleaseShared只唤醒下一个节点。下一个节点如果没被唤醒就等着,这样不会造成越级唤醒导致顺序混乱。源码如下:
2、releaseShared(int arg)
该方法是共享模式下线程释放共享资源的顶层入口。它会释放指定量的资源,如果成功释放且允许唤醒等待线程,它会唤醒等待队列里的其他线程来获取资源。
此方法的流程也比较简单,释放掉资源后,唤醒后继。跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。
2.1、doReleaseShared()
for循环,大体同独占锁逻辑,如果队列头节点不为空,且head不等于tail,即还有后继节点,则继续执行难,否则直接没有任务直接返回。如果头结点的状态为SIGNAL,cas将head节点状态设置为0,以表示执行完成。之后调用unparkSuccessor唤醒后继节点。如果不为SIGNAL,但为0且能够cas设置为PROPAGATE并进行下一次循环。如果头结点没有改变则跳出了循环。PROPAGATE状态是在共享模式下头结点有可能处于的状态,表示锁的下一次获取可以无条件传播