
并发编程锁之ReentrantLock总结
锁
之前我讲过,在并发编程中一个比较难解决的就是共享资源并发访问控制问题。如果同步做的不好,很容易出现不一致问题,从而导致业务逻辑的错误;但是如果对共享资源控制的过于严格,又很容易对性能造成很大的影响。在并发编程中,一方面多从大牛的源码中学习精巧的思想和结构设计;另一方面要对并发基础知识掌握的足够牢固,你才能游刃有余的结合些设计模式、架构思想做出些高质量的高并发、高性能的系统。并发编程对设计模式、架构设计是非常依赖的,因此,并发编程对经验的积累、知识的积累方面要求是比较高的。
刚才说过,共享资源在并发访问中很容易造成不一致问题,解决方案就是我们所熟知的悲观锁和乐观锁。悲观锁就如其名字一样:悲观锁认为并发访问一定会导致状态不一致问题,所以在并发操作前一定要锁住资源,让并发线程一个接一个串行化去访问。而乐观锁就不一样了,乐观锁认为并发访问在大多数情况下是不会导致状态不一致问题,所以可以放心的去访问,一旦出现问题再说,本质上乐观锁是不会对共享资源添加锁限制的。这是我个人的理解,可能直白不是那么的精准,有关悲观锁、乐观锁的定义可以搜索更准确的官方解释。
我们来看看在Java中是如何实现悲观锁和乐观锁的。悲观锁在Java中就是我们所熟知的锁,实现方式主要分为两种:synchronized和Lock,而乐观锁的实现主要通过CAS操作实现。这里我们来比较下synchronized和Lock方式的大致差别:
1、synchronized主要依赖JVM底层实现,而Lock是通过编码方式实现,其实现方式差别还是比较大
2、synchronized由于其简单方便,只需要声明在方法、代码块上即可,主要是不需要关心锁释放问题,在一般的编程中使用量还是比较大的,但是在真正的并发编程系统中,Lock方式明显优于synchronized:
a.在高版本JDK中,已经对synchronized进行了优化,synchronized和Lock方式在性能方面差别已不太明显
b.synchronized最致命的缺陷是:synchronized不支持中断和超时,也就是说通过synchronized一旦被阻塞住,如果一直无法获取到所资源就会一直被阻塞,即使中断也没用,这对并发系统的性能影响太大了;Lock支持中断和超时、还支持尝试机制获取锁,对synchronized进行了很好的扩展,所以从灵活性上Lock是明显优于synchronized的
在java.util.concurrent.locks包中有很多Lock的实现类,常用的有ReentrantLock、ReadWriteLock(实现类ReentrantReadWriteLock),ReentrantLock在锁的使用上算是非常普遍的,这一节我们就以ReentrantLock为例,分析下Lock实现方式。
ReentrantLock
ReentrantLock是一个可重入的互斥锁,所谓可重入是线程可以重复获取已经持有的锁。锁基本上都是要支持可重入性,否则很容易出现死锁问题。比如:假如锁B不支持可重入性,线程A在持有锁B的情况下再次获取锁B,由于不支持可重入性导致线程A被阻塞,知道锁B资源被释放,但是锁B资源是被线程A持有的,所以线程A永远无法因获取到锁B而被唤醒,这就导致了死锁问题。
ReentrantLock内部实现主要通过AbstractQueuedSynchronizer类实现的,AbstractQueuedSynchronizer是抽象类,在ReentrantLock类中有两个实现类:NonfairSync和FairSync,分别对应非公平锁和公平锁的实现。类结构关系如下:
1 | +-------------------------------+ |
ReentrantLock类内部持有一个Sync类型的变量,主要实现基本上都是调用Sync的实现机制,默认构建的是NonfairSync,即非公平锁,也可以通过带Boolean类型的构造函数构建公平锁,源码如下:
1 | /** |
获取锁原理
使用ReentrantLock时,一般流程大致为:1、调用lock()申请锁资源,申请成功则立即返回,如果申请不到则会阻塞直到申请成功;2、申请锁成功后,即可进行共享资源操作;3、共享资源操作完成,最后调用unlock()释放锁资源。
1 | /** |
ReentrantLock.lock()源码非常简单,调用sync.lock(),这就体现了ReentrantLock核心机制都是在Sync中实现的,上面已说过ReentrantLock中的Sync中有两个子类分别对应公平锁和非公平锁,这里我们就来先看非公平锁NonfairSync的实现:
1 | /** |
这里的逻辑也很简单,通过一个CAS操作将state由0设置成1,成功则获取锁成功,并让Sync的exclusiveOwnerThread变量持有当前线程,供后续当前线程重入使用;如果CAS操作失败,则表示存在竞争,已有线程获取到锁,当前线程获取锁失败,需要进入acquire(1)分支。可以看到ReentrantLock的核心就是通过state字段的值判断是否被占用。
1 | /** |
AbstractQueuedSynchronizer中acquire()逻辑大致如下:
1、首先调用tryAcquire(),该方法的目的主要是:a.重新自旋一次获取下锁看看是否成功,成功则返回true;b.判断持有锁的线程是否就是当前线程,如果是的话,直接在state累加1,并返回true表示获取锁成功;c.如果上述两个目的都没有实现,则返回false,表示获取锁失败。注意:tryAcquire()在AbstractQueuedSynchronizer中是直接抛出异常,具体调用的是子类NonfairSync中的实现逻辑,源码如下:
1 | /** |
2、如果tryAcquire()获取锁失败则执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)语句,首先我们来看下addWaiter(Node.EXCLUSIVE),它的作用就是将无法获取锁的线程追加到一个双向链表中,然后让线程休眠,当锁资源可用时会从该双向链表头部唤醒一个线程去竞争锁资源,其源码如下:
1 | /** |
3、addWaiter(Node.EXCLUSIVE)将当前线程封装的Node节点添加到等待锁资源的Queue上后,接下来要执行acquireQueued(),该方法是获取锁逻辑比较核心的一个方法,关键点有如下几个:
a.该方法被设计成了一个无限for循环,只有满足通过tryAcquire()获取到锁时才会退出该循环,当然如果没有获取到锁也不会一直在for循环中进行空循环,而是通过parkAndCheckInterrupt()让线程休眠,当线程被唤醒后才会执行一次for循环看是否可以获取锁,获取成功则会将Queue的head指针指向当前thread,并将之前head废弃
b.在tryAcquire()竞争锁资源时,会存在p == head判断,判断当前线程的前驱节点是否是head节点,只有前驱是head节点的线程才有资格调用tryAcquire()去竞争锁资源,这个设计思想逻辑主要是:申请锁资源失败的线程会依次加入到Queue中,head指向头部,tail指向尾部,如果head不为空,则该节点代表的线程为锁的占有者,当该线程释放锁时,它会唤醒它的后驱节点,而不是Queue中所有线程,因此,每次释放锁时只会唤醒一个线程,唤醒顺序也是从head到tail依次唤醒,而不是存在锁资源时一起唤醒然后竞争锁资源,因为这样如果存在几百几千个线程,同时竞争锁资源对系统性能损耗很大,有效的避免性能风暴
c.该方法在让线程真正休眠前会让线程再次自旋一次获取锁,如果还是失败则立即进入休眠状态,作者这么设计就体现了:让线程休眠还是比较耗费性能资源的,涉及到上下文切换,另外当线程唤醒时可能会被分配到其它CPU上执行,由于高速缓存L1、L2是CPU独有的,就会降低高速缓存命中率,对性能影响还是比较大的,因此能尽量不休眠就不会让线程休眠
d.当有线程释放锁时,会唤醒Queue头部线程的后驱节点,唤醒后依然要竞争锁,竞争的对象是刚申请锁资源还没有进入到Queue等待队列的线程们,如果竞争失败则再次进入休眠状态,这就体现了非公平锁的特性,这么设计的目的:主要从性能考虑,如果新申请锁的线程可以立即获取到锁,避免了后续一系列创建Node、添加Node到队列等一些列操作,而从Queue中唤醒的线程没有申请到锁只是重新进入休眠,代价要小很多,同时让它们一起竞争锁资源避免Queue等待队列中的线程一直无法获取锁而被饿死情况
1 | /** |
看到这里,基本上对ReentrantLock非公平锁获取锁资源的流程有一个比较清晰的认识了。公平锁和非公平锁流程基本一致,区别只是在tryAcquire()获取锁逻辑的差别,具体如下:
1 | FailSync类中的tryAcquire()获取锁逻辑: |
可以看到,当锁资源可用(state=0)并且当Queue队列中不存在等待锁资源的线程时,才会通过cas操作将state由0设置成1,表示申请锁资源成功,否则都将加入到Queue队列的尾部。对比非公平锁获取锁资源逻辑nonfairTryAcquire(),差别主要是:非公平锁只要判断锁资源可用就会立即通过cas操作获取锁资源,而公平锁则会在锁资源可用的情况下,还要满足Queue队列中无等待锁资源线程才能立即申请锁资源,否则会被追加到Queue队列的尾部,这就体现了公平特性。
上面已经将ReentrantLock.lock()获取锁的流程基本都 分析完成,当然ReentrantLock还提供lockInterruptibly()、tryLock()、tryLock(long timeout, TimeUnit unit)等,如果对lock()逻辑比较清楚,这些方式获取锁的原理就比较简单了,下面大致说下。
ReentrantLock.lockInterruptibly():可中断方式获取锁,通过之前源码分析,线程如果没有获取到锁,会通过LockSupport.park()方式休眠,当锁资源释放时,其它线程调用Lock.unpark()唤醒休眠线程去竞争锁资源,但是LockSupport.park()休眠的线程也可以通过中断方式进行唤醒,可中断锁就是在唤醒时候判断如是中断唤醒,则直接抛出异常,而lock()方式获取的锁使用中断唤醒后直接去竞争锁资源了,竞争不到直接休眠,这就是它们的差别,具体实现看源码:
1 | private void doAcquireInterruptibly(int arg) |
ReentrantLock.tryLock():可尝试性获取锁,获取到返回true,获取不到直接返回fasle,而不会阻塞,实现方式就更简单了,直接nonfairTryAcquire()获取锁,获取到立即返回true,获取不到立即返回false,而不是添加到Sync Queue阻塞队列中去等待。
ReentrantLock.tryLock(long timeout, TimeUnit unit):会尝试一段时间,这段时间都无法获取锁就返回false
1 | private boolean doAcquireNanos(int arg, long nanosTimeout) |
总结
通过上面对ReentrantLock源码分析,Lock机制的核心就是通过cas原子操作state属性,state=0表示锁资源可用,获取锁就是通过cas原子操作将state从0设置成1,成功就表示获取锁成功,如果state>0,cas操作将会失败,即表示锁已被占用,当前获取锁失败。获取锁失败,根据是否是可中断、可超时等特性,处理的逻辑不太一致,但大致为:
1、将获取锁失败的线程封装成Node,封装成Node一方面是要构建双向队列,另一方面是Node中额外添加状态信息对节点进行控制
2、在一个for无线循环中通过Lock.park()让线程休眠,当有锁资源被释放发生时,会从队列头到尾的顺序依次唤醒线程(会跳过CANCELLED标记的节点,因为这些节点代表的线程已经无效了),注意这里只会唤醒一个线程,唤醒的线程只表示该线程具有竞争锁资源的资格,还需要和新申请但还没有放入到Queue中的线程进行竞争该锁资源,这就是非公平锁的特性,这样设计主要是从性能方面考虑,如果竞争成功则退出for循环返回,否则继续进入休眠状态
最后再通过一个大致流程图,对整体的执行流程有个更清晰认识。
释放锁原理
接下来我们来分析下ReentrantLock释放锁资源的流程。释放锁没有区分公平和非公平的,主要的工作就是减小state的值,当state等0的时候,释放锁并唤醒Queue中其他线程来获取锁。
1 | public void unlock() { |
release()是在AbstractQueuedSynchronizer中实现的,
1 | /** |
深入分析
上面通过源码已经对ReentrantLock获取锁和释放锁的大致流程有了比较清晰的认识,当你越深入分析时你会对Doug Lea这位大牛构思和多线程并发处理的游刃有余感到惊叹,以及后面我们会讲到的IO模型,依然会有Doug Lea大牛的精彩大作,如果你对他还不了解,可以多搜索关注下。
了解了流程并不一定就表示你已经完全熟悉ReentrantLock,你知道他是这么做的,但是你不一定清楚他这么做背后的考量是什么,毕竟并发编程比单线程编程复杂性高出太多,你很难顾及到所有线程分支运行的流程,这就是很容易导致bug的根源。上面我们已经分析过addWaiter()这个方法的作用,下面通过该方法进行更深入的分析,希望对并发编程的认识更加深刻。
addWaiter()方法主要完成工作:将未获取锁的线程封装成Node,然后追加到等待队列Queue尾部,等待队列Queue并不存在一个定义好的数据结构,而是通过head、tail、next和prev模拟出的具有出队、入队操作的双向链表。追加当前节点到双向链表尾部关键源码如下:
1 | node.prev = pred; |
将上面代码梳理一下,大致分为三个步骤:
1、将当前node的prev指向tail节点;
2、通过cas原子操作将tail指针指向当前节点;
3、将之前tail节点的next指向当前节点。
示意图如下:
假如将追加节点的三个步骤顺序调换下,先将tail节点的next指向当前节点,然后cas原子修改tail指向,最后再来修改当前节点的prev指向,即将上面的1和3对调一下,会出现上面情况呢?
将tail节点的next指向当前节点操作后,紧接着会执行cas操作修改tail指向当前节点,由于存在多线程并发问题,即可能会存在多个线程同时申请锁资源,假如现在t1、t2两个线程都同时做上面两个步骤:
1、t1线程修改next后,紧接着t2线程也修改next指向,导致会把t1修改的指向覆盖;
2、这时t1线程做cas替换tail指向成功后,t2也来做cas操作就会失败;
3、t1由于cas操作成功,最后修改prev指向
可以发现,由于并发导致追加的t1节点是存在问题的,正常情况下Node1的next应该指向t1节点,但是却被t2节点覆盖了。所以,1和3对调是在并发下是存在问题的。
假如1和2对调,先进行cas操作,然后修改prev,最后再来修改next又会怎么样呢?首先通过cas原子操作将tail指向当前节点,示意图如下:
tail节点这时还是孤立的节点,prev和next都还没有指向,tail节点和其它节点之间没有关联了,这时如果其它线程需要遍历这个双向链表就比较危险了,比如释放锁时会调用unparkSuccessor(),其源码如下:
1 | private void unparkSuccessor(Node node) { |
会发现其可能会存在一个从tail向前查找的流程,假如刚好这时执行这个流程,从tail向head查找节点显然就会存在问题,所以1和2对调的流程在并发下也是存在问题的。unparkSuccessor()在查找head的下一个有效节点的时候,没有从head到tail方向查找,而是反方向从tail向head查找,正常逻辑肯定是从head向tail方向查找速度更快,但是为啥反其道而行呢?如果你只看这段代码是永远看不出问题的,具体原因可以参加下面正常流程分析情况。
错误的顺序我就不一一举例了,大致都是差不多,现在我们来分析下为什么源码中这个顺序执行在并发下就不会存在问题。现在假设两个线程同一时间都没有获取到锁,都需要追加到Sync Queue队列尾部,大致流程如下:
1、线程t1的节点设置prev指向tail,线程t2节点同时也设置prev指向tail,这时就不会出现上面如果先设置next就会导致后设置把之前设置覆盖情况,因为如果先设置next是对Node1进行操作,存在多个线程对Node1同时操作导致状态不一致问题,而如果这里先设置prev,操作对象时线程本身的节点,是不存在多线程并发问题,示意图如下:
2、这时t1和t2都进行cas原子操作,反正会有一个线程会操作成功,假如是t1线程操作成功,然后就可以顺利的设置Node1节点的next指向t1,因为只会存在一个线程操作成功,所以对Node1的操作此时也不会存在并发问题,由于t1的cas操作成功导致t2线程进行cas操作必然失败,此刻示意图如下:
3、由于t2线程cas操作失败,因此不再继续操作Node1的next指向自己,而是进入enq()方法中,其源码如下,enq方法中通过cas+无限循环方式保证t2节点一定会被追加到Sync Queue尾部的,每次循环都是重新获取最新的tail,然后将t2的prev指向这个最新的tail,然后通过cas操作将tail指向自己,最后在将之前tail节点的next指向t2节点,这个案例中获取的最新tail就是t1节点了,所以t2节点会被追加到t1节点后,这样就能保证即使在高并发下依然可以实现节点正常添加,而不会像之前出现状态不一致情况,示意图如下:
1 | /** |
4、上面分析unparkSuccessor()在查找head的下一个有效节点的时候,没有从head到tail方向查找,而是反方向从tail向head查找,如果你对我刚才分析得到逻辑理解透彻的话,就比较好解释了。比如:t1设置prev指向Node1,然后cas操作将tail指向了t1,这时Queue的结构如下,假如这时候执行unparkSuccessor(),Node0查找它的后驱节点为Node1,假如Node1是无效节点,Node1需要继续查找它的后驱节点,但是这时Node1的next并没有设置,是无法查找到的,所以必须从tail向head方向查找才行。
通过对addWaiter的深入分析,你会对并发编程的难度有一个更加深刻的认识,真是处处要小心,搞不好就掉坑里面去了,但是Doug Lea巧妙的构思处理的游刃有余。
至此,对ReentrantLock也有了一个比较完整的流程分析,这一节也就结束了,后面会对Lock的其它实现类及synchronized的底层实现机制进行些分析。
- 本文作者: zhang
- 本文链接: http://blog.reactor.top/2018/01/31/并发编程锁之ReentrantLock总结/
- 版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!