AQS浅析

AQS的原理浅析

本文是《Java特种兵》的样章,本书即将由工业出版社出版java

AQS的全称为(AbstractQueuedSynchronizer),这个类也是在java.util.concurrent.locks下面。这个相似乎很不容易看懂,由于它仅仅是提供了一系列公共的方法,让子类来调用。那么要理解意思,就得从子类下手,反过来看才容易看懂。以下图所示:
QQ图片20140110194431node

图 5-15 AQS的子类实现安全

 

这么多类,咱们看那一个?刚刚提到过锁(Lock),咱们就从锁开始吧。这里就先以ReentrantLock排它锁为例开始展开讲解如何利用AQS的,而后再简单介绍读写锁的要点(读写锁自己的实现十分复杂,要彻底说清楚须要大量的篇幅来讲明)。
首先来看看ReentrantLock的构造方法,它的构造方法有两个,以下图所示:
QQ图片20140110194534
图 5-16 排它锁的构造方法
很显然,对象中有一个属性叫sync,有两种不一样的实现类,默认是“NonfairSync”来实现,而另外一个“FairSync”它们都是排它锁的内部类,不论用那一个都能实现排它锁,只是内部可能有点原理上的区别。先以“NonfairSync”类为例,它的lock()方法是如何实现的呢?
QQ图片20140110194615
图 5-17 排它锁的lock方法
lock()方法先经过CAS尝试将状态从0修改成1。若直接修改为功,前提条件天然是锁的状态为0,则直接将线程的OWNER修改成当前线程,这是一种理想状况,若是并发粒度设置适当也是一种乐观状况。
若上一个动做未成功,则会间接调用了acquire(1)来继续操做,这个acquire(int)方法就是在AbstractQueuedSynchronizer当中了。这个方法表面上看起来简单,但真实状况比较难以看懂,由于第一次看这段代码可能不知道它要作什么!不急,一步一步来分解。
首先看tryAcquire(arg)这里的调用(固然传入的参数是1),在默认的“NonfairSync”实现类中,会这样来实现:
QQ图片20140110194650数据结构

妈呀,这代码好费劲,胖哥第一回看也是以为这样,细心看看也不是想象当中那么难:多线程

○ 首先获取这个锁的状态,若是状态为0,则尝试设置状态为传入的参数(这里就是1),若设置成功就表明本身获取到了锁,返回true了。状态为0设置1的动做在外部就有作过一次,内部再一次作只是提高几率,并且这样的操做相对锁来说不占开销。
○ 若是状态不是0,则断定当前线程是否为排它锁的Owner,若是是Owner则尝试将状态增长acquires(也就是增长1),若是这个状态值越界,则会抛出异常提示,若没有越界,将状态设置进去后返回true(实现了相似于偏向的功能,可重入,可是无需进一步征用)。
○ 若是状态不是0,且自身不是owner,则返回false。并发

回到图 5-17中对tryAcquire()的调用断定中是经过if(!tryAcquire())做为第1个条件的,若是返回true,则断定就不会成立了,天然后面的acquireQueued动做就不会再执行了,若是发生这样的状况是最理想的。
不管多么乐观,征用是必然存在的,若是征用存在则owner天然不会是本身,tryAcquire()方法会返回false,接着就会再调用方法:acquireQueued(addWaiter(Node.EXCLUSIVE), arg)作相关的操做。
这个方法的调用的代码更很差懂,须要从里往外看,这里的Node.EXCLUSIVE是节点的类型,看名称应该清楚是排它类型的意思。接着调用addWaiter()来增长一个排它锁类型的节点,这个addWaiter()的代码是这样写的:
QQ图片20140110194812
图 5-19 addWaiter的代码
这里建立了一个Node的对象,将当前线程和传入的Node.EXCLUSIVE传入,也就是说Node节点理论上包含了这两项信息。代码中的tail是AQS的一个属性,刚开始的时候确定是为null,也就是不会进入第一层if断定的区域,而直接会进入enq(node)的代码,那么直接来看看enq(node)的代码。性能

看到了tail就应该猜到了AQS是链表吧,没错,并且它还应该有一个head引用来指向链表的头节点,AQS在初始化的时候head、tail都是null,在运行时来回移动。此时,咱们最少至少知道AQS是一个基于状态(state)的链表管理方式。学习

QQ图片20140110194853

图 5-20 enq(Node)的源码
这段代码就是链表的操做,某些同窗可能很牛,一下就看懂了,某些同窗一扫而过以为知道大概就能够了,某些同窗可能会莫不着头脑。胖哥为了给第三类同窗来“开开荤”,简单讲解下这个代码。
首先这个是一个死循环,并且自己没有锁,所以能够有多个线程进来,假如某个线程进入方法,此时head、tail都是null,天然会进入if(t == null)所在的代码区域,这部分代码会建立一个Node出来名字叫h,这个Node没有像开始那样给予类型和线程,很明显是一个空的Node对象,而传入的Node对象首先被它的next引用所指向,此时传入的node和某一个线程建立的h对象以下图所示。
QQ图片20140110194922
图 5-21 临时的h对象建立后的与传入的Node指向关系
刚才咱们很理想的认为只有一个线程会出现这种状况,若是有多个线程并发进入这个if断定区域,可能就会同时存在多个这样的数据结构,在各自造成数据结构后,多个线程都会去作compareAndSetHead(h)的动做,也就是尝试将这个临时h节点设置为head,显然并发时只有一个线程会成功,所以成功的那个线程会执行tail = node的操做,整个AQS的链表就成为:测试

QQ图片20140110194957

图 5-22 AQS被第一个请求成功的线程初始化后
有一个线程会成功修改head和tail的值,其它的线程会继续循环,再次循环就不会进入if (t == null)的逻辑了,而会进入else语句的逻辑中。
在else语句所在的逻辑中,第一步是node.prev = t,这个t就是tail的临时值,也就是首先让尝试写入的node节点的prev指针指向原来的结束节点,而后尝试经过CAS替换掉AQS中的tail的内容为当前线程的Node,不管有多少个线程并发到这里,依然只会有一个能成功,成功者执行t.next = node,也就是让原先的tail节点的next引用指向如今的node,如今的node已经成为了最新的结束节点,不成功者则会继续循环。
简单使用图解的方式来讲明,3个步骤以下所示,以下图所示:ui

QQ图片20140110194957

图 5-23 插入一个节点步骤先后动做
插入多个节点的时候,就以此类推了哦,总之节点都是在链表尾部写入的,并且是线程安全的。
知道了AQS大体的写入是一种双向链表的插入操做,但插入链表节点对锁有何用途呢,咱们还得退回到前面图 5-19的代码中addWaiter方法最终返回了要写入的node节点, 再回退到图5-17中所在的代码中须要将这个返回的node节点做为acquireQueued方法入口参数,并传入另外一个参数(依然是1),看看它里面到底作了些什么?请看下图:
QQ图片20140110195059

图 5-24 acquireQueued的方法内容
这里也是一个死循环,除非进入if(p == head && tryAcquire(arg))这个断定条件,而p为node.predcessor()获得,这个方法返回node节点的前一个节点,也就是说只有当前一个节点是head的时候,进一步尝试经过tryAcquire(arg)来征用才有机会成功。tryAcquire(arg)这个方法咱们前面介绍过,成立的条件为:锁的状态为0,且经过CAS尝试设置状态成功或线程的持有者自己是当前线程才会返回true,咱们如今来详细拆分这部分代码。
○ 若是这个条件成功后,发生的几个动做包含:
(1) 首先调用setHead(Node)的操做,这个操做内部会将传入的node节点做为AQS的head所指向的节点。线程属性设置为空(由于如今已经获取到锁,再也不须要记录下这个节点所对应的线程了),再将这个节点的perv引用赋值为null。
(2) 进一步将的前一个节点的next引用赋值为null。
在进行了这样的修改后,队列的结构就变成了如下这种状况了,经过这样的方式,就可让执行完的节点释放掉内存区域,而不是无限制增加队列,也就真正造成FIFO了:

QQ图片20140110195124
图 5-25 CAS成功获取锁后,队列的变化
○ 若是这个断定条件失败
会首先断定:“shouldParkAfterFailedAcquire(p , node)”,这个方法内部会断定前一个节点的状态是否为:“Node.SIGNAL”,如果则返回true,若不是都会返回false,不过会再作一些操做:断定节点的状态是否大于0,若大于0则认为被“CANCELLED”掉了(咱们没有说明几个状态的值,不过大于0的只可能被CANCELLED的状态),所以会从前一个节点开始逐步循环找到一个没有被“CANCELLED”节点,而后与这个节点的next、prev的引用相互指向;若是前一个节点的状态不是大于0的,则经过CAS尝试将状态修改成“Node.SIGNAL”,天然的若是下一轮循环的时候会返回值应该会返回true。
若是这个方法返回了true,则会执行:“parkAndCheckInterrupt()”方法,它是经过LockSupport.park(this)将当前线程挂起到WATING状态,它须要等待一个中断、unpark方法来唤醒它,经过这样一种FIFO的机制的等待,来实现了Lock的操做。
相应的,能够本身看看FairSync实现类的lock方法,其实区别不大,有些细节上的区别可能会决定某些特定场景的需求,你也能够本身按照这样的思路去实现一个自定义的锁。
接下来简单看看unlock()解除锁的方式,若是获取到了锁不释放,那天然就成了死锁,因此必需要释放,来看看它内部是如何释放的。一样从排它锁(ReentrantLock)中的unlock()方法开始,请先看下面的代码截图:

QQ图片20140110195158

图 5-26 unlock方法间接调用AQS的release(1)来完成
经过tryRelease(int)方法进行了某种断定,若它成立则会将head传入到unparkSuccessor(Node)方法中并返回true,不然返回false。首先来看看tryRelease(int)方法,以下图所示:
QQ图片20140110195239

图 5-27 tryRelease(1)方法
这个动做能够认为就是一个设置锁状态的操做,并且是将状态减掉传入的参数值(参数是1),若是结果状态为0,就将排它锁的Owner设置为null,以使得其它的线程有机会进行执行。
在排它锁中,加锁的时候状态会增长1(固然能够本身修改这个值),在解锁的时候减掉1,同一个锁,在能够重入后,可能会被叠加为二、三、4这些值,只有unlock()的次数与lock()的次数对应才会将Owner线程设置为空,并且也只有这种状况下才会返回true。
这一点你们写代码要注意了哦,若是是在循环体中lock()或故意使用两次以上的lock(),而最终只有一次unlock(),最终可能没法释放锁。在本书的src/chapter05/locks/目录下有相应的代码,你们能够自行测试的哦。
在方法unparkSuccessor(Node)中,就意味着真正要释放锁了,它传入的是head节点(head节点是已经执行完的节点,在后面阐述这个方法的body的时候都叫head节点),内部首先会发生的动做是获取head节点的next节点,若是获取到的节点不为空,则直接经过:“LockSupport.unpark()”方法来释放对应的被挂起的线程,这样一来将会有一个节点唤醒后继续进入图 5-24中的循环进一步尝试tryAcquire()方法来获取锁,可是也未必能彻底获取到哦,由于此时也可能有一些外部的请求正好与之征用,并且还奇迹般的成功了,那这个线程的运气就有点悲剧了,不过一般乐观认为不会每一次都那么悲剧。
再看看共享锁,从前面的排它锁能够看得出来是用一个状态来标志锁的,而共享锁也不例外,可是Java不但愿去定义两个状态,因此它与排它锁的第一个区别就是在锁的状态上,它用int来标志锁的状态,int有4个字节,它用高16位标志读锁(共享锁),低16位标志写锁(排它锁),高16位每次增长1至关于增长65536(经过1 << 16获得),天然的在这种读写锁中,读锁和写锁的个数都不能超过65535个(条件是每次增长1的,若是递增是跳跃的将会更少)。在计算读锁数量的时候将状态左移16位,而计算排它锁会与65535“按位求与”操做,以下图所示。

QQ图片20140110195308

图 5-28 读写锁中的数量计算及限制 写锁的功能与“ReentrantLock”基本一致,区域在于它会在tryAcquire操做的时候,断定状态的时候会更加复杂一些(所以有些时候它的性能未必好)。 读锁也会写入队列,Node的类型被改成:“Node.SHARED”这种类型,lock()时候调用的是AQS的acquireShared(int)方法,进一步调用tryAcquireShared()操做里面只须要检测是否有排它锁,若是没有则能够尝试经过CAS修改锁的状态,若是没有修改为功,则会自旋这个动做(可能会有不少线程在这自旋开销CPU)。若是这个自旋的过程当中检测到排它锁竞争成功,那么tryAcquireShared()会返回-1,从而会走如排它锁的Node相似的流程,可能也会被park住,等待排它锁相应的线程最终调用unpark()动做来唤醒。 这就是Java提供的这种读写锁,不过这并非共享锁的诠释,在共享锁里面也有多种机制 ,或许这种读写锁只是其中一种而已。在这种锁下面,读和写的操做自己是互斥的,可是读能够多个一块儿发生。这样的锁理论上是很是适合应用在“读多写少”的环境下(固然咱们所讲的读多写少是读的比例远远大于写,而不是多一点点),理论上讲这样锁征用的粒度会大大下降,同时系统的瓶颈会减小,效率获得整体提高。 在本节中咱们除了学习到AQS的内在,还应看到Java经过一个AQS队列解决了许多问题,这个是Java层面的队列模型,其实咱们也能够利用许多队列模型来解决本身的问题,甚至于能够改写模型模型来知足本身的需求,在本章的5.6.1节中将会详细介绍。 关于Lock及AQS的一些补充: 一、 Lock的操做不只仅局限于lock()/unlock(),由于这样线程可能进入WAITING状态,这个时候若是没有unpark()就无法唤醒它,可能会一直“睡”下去,能够尝试用tryLock()、tryLock(long , TimeUnit)来作一些尝试加锁或超时来知足某些特定场景的须要。例若有些时候发现尝试加锁没法加上,先释放已经成功对其它对象添加的锁,过一小会再来尝试,这样在某些场合下能够避免“死锁”哦。 二、 lockInterruptibly() 它容许抛出InterruptException异常,也就是当外部发起了中断操做,程序内部有可能会抛出这种异常,可是并非绝对会抛出异常的,你们仔细看看代码便清楚了。 三、 newCondition()操做,是返回一个Condition的对象,Condition只是一个接口,它要求实现await()、awaitUninterruptibly()、awaitNanos(long)、await(long , TimeUnit)、awaitUntil(Date)、signal()、signalAll()方法,AbstractQueuedSynchronizer中有一个内部类叫作ConditionObject实现了这个接口,它也是一个相似于队列的实现,具体能够参考源码。大多数状况下能够直接使用,固然以为本身比较牛逼的话也能够参考源码本身来实现。 四、 在AQS的Node中有每一个Node本身的状态(waitStatus),咱们这里概括一下,分别包含: SIGNAL 从前面的代码状态转换能够看得出是前面有线程在运行,须要前面线程结束后,调用unpark()方法才能激活本身,值为:-1 CANCELLED 当AQS发起取消或fullyRelease()时,会是这个状态。值为1,也是几个状态中惟一一个大于0的状态,因此前面断定状态大于0就基本等价因而CANCELLED的意思。 CONDITION 线程基于Condition对象发生了等待,进入了相应的队列,天然也须要Condition对象来激活,值为-2。 PROPAGATE 读写锁中,当读锁最开始没有获取到操做权限,获得后会发起一个doReleaseShared()动做,内部也是一个循环,当断定后续的节点状态为0时,尝试经过CAS自旋方式将状态修改成这个状态,表示节点能够运行。 状态0 初始化状态,也表明正在尝试去获取临界资源的线程所对应的Node的状态。

相关文章
相关标签/搜索