Java并发实现原理—JDK源码剖析Lock与Condition:读写锁

摘要:和互斥锁相比,读写锁(ReentrantReadWritelock)就是读线程和读线程之间可以不用互斥了。在正式介绍原理之前,先看一下相关类的继承体系。

和互斥锁相比,读写锁(ReentrantReadWritelock)就是读线程和读线程之间可以不用互斥了。在正式介绍原理之前,先看一下相关类的继承体系。

3.2.1 类继承层次

如图3-3所示,ReadWriteLock是一个接口,内部由两个Lock接口组成。

图3-3 ReentrantReadWriteLock类继承层次

ReentrantReadWriteLock实现了该接口,使用方式如下:

也就是说,当使用 ReadWriteLock 的时候,并不是直接使用,而是获得其内部的读锁和写锁,然后分别调用lock/unlock。

3.2.2 读写锁实现的基本原理

从表面来看,ReadLock和WriteLock是两把锁,实际上它只是同一把锁的两个视图而已。什么叫两个视图呢?可以理解为是一把锁,线程分成两类:读线程和写线程。读线程和读线程之间不互斥(可以同时拿到这把锁),读线程和写线程互斥,写线程和写线程也互斥。

从下面的构造函数也可以看出,readerLock和writerLock实际共用同一个sync对象。sync对象同互斥锁一样,分为非公平和公平两种策略,并继承自AQS。

同互斥锁一样,读写锁也是用state变量来表示锁状态的。只是state变量在这里的含义和互斥锁完全不同。在内部类Sync中,对state变量进行了重新定义,如下所示。

也就是把 state 变量拆成两半,低16位,用来记录写锁。但同一时间既然只能有一个线程写,为什么还需要16位呢?这是因为一个写线程可能多次重入。例如,低16位的值等于5,表示一个写线程重入了5次。

高16位,用来“读”锁。例如,高16位的值等于5,可以表示5个读线程都拿到了该锁;也可以表示一个读线程重入了5次。

这个地方的设计很巧妙,为什么要把一个int类型变量拆成两半,而不是用两个int型变量分别表示读锁和写锁的状态呢?这是因为无法用一次CAS 同时操作两个int变量,所以用了一个int型的高16位和低16位分别表示读锁和写锁的状态。

当state=0时,说明既没有线程持有读锁,也没有线程持有写锁;

当state!=0时,要么有线程持有读锁,要么有线程持有写锁,两者不能同时成立,因为读和写互斥。这时再进一步通过sharedCount(state)和exclusiveCount(state)判断到底是读线程还是写线程持有了该锁。

3.2.3 AQS的两对模板方法

下面介绍在ReentrantReadWriteLock的两个内部类ReadLock和WriteLock中,是如何使用state变量的。

acquire/release、acquireShared/releaseShared 是AQS里面的两对模板方法。互斥锁和读写锁的写锁都是基于acquire/release模板方法来实现的。读写锁的读锁是基于acquireShared/releaseShared这对模板方法来实现的。这两对模板方法的代码如下:

将读/写、公平/非公平进行排列组合,就有4种组合。如图3-4所示,上面的两个函数都是在Sync中实现的。Sync中的两个函数又是模板方法,在NonfairSync和FairSync中分别有实现。最终的对应关系如下:

(1)读锁的公平实现:Sync.tryAccquireShared+FairSync中的两个覆写的子函数。

(2)读锁的非公平实现:Sync.tryAccquireShared+NonfairSync中的两个覆写的子函数。

(3)写锁的公平实现:Sync.tryAccquire+FairSync中的两个覆写的子函数。

(4)写锁的非公平实现:Sync.tryAccquire+NonfairSync中的两个覆写的子函数。

上面的代码介绍了ReentrantReadWriteLock里面的NonfairSync和FairSync的实现过程,对应了上面的四种实现策略,下面分别解释。

对于公平,比较容易理解,不论是读锁,还是写锁,只要队列中有其他线程在排队(排队等读锁,或者排队等写锁),就不能直接去抢锁,要排在队列尾部。

对于非公平,读锁和写锁的实现策略略有差异。先说写锁,写线程能抢锁,前提是state=0,只有在没有其他线程持有读锁或写锁的情况下,它才有机会去抢锁。或者state!=0,但那个持有写锁的线程是它自己,再次重入。写线程是非公平的,就是不管三七二十一就去抢,即一直返回false。

但对于读线程,能否也不管三七二十一,上来就去抢呢?不行!

因为读线程和读线程是不互斥的,假设当前线程被读线程持有,然后其他读线程还非公平地一直去抢,可能导致写线程永远拿不到锁,所以对于读线程的非公平,要做一些“约束”。当发现队列的第1个元素是写线程的时候,读线程也要阻塞一下,不能“肆无忌惮”地直接去抢。

明白策略后,下面具体介绍四种实现方面的差异。

3.2.4 WriteLock公平与非公平实现

写锁是排他锁,实现策略类似于互斥锁,重写了tryAcquire/tryRelease方法。

1.tryAcquire实现分析

把上面的代码拆开进行分析,如下:

(1)if (c!=0) and w==0,说明当前一定是读线程拿着锁,写锁一定拿不到,返回false。

(2)if (c!=0) and w!=0,说明当前一定是写线程拿着锁,执行current!=getExclusive-OwnerThread的判断,发现ownerThread不是自己,返回false。

( 3 ) c ! =0 , w ! =0 , 且 current=getExclusiveOwnerThread,才会走到if (w+exclusive-Count(acquires)>;MAX_COUNT)。判断重入次数,重入次数超过最大值,抛出异常。

因为是用state的低16位保存写锁重入次数的,所以MAX_COUNT是216。如果超出这个值,会写到读锁的高16位上。为了避免这种情形,这里做了一个检测。当然,一般不可能重入这么多次。

(4)if(c=0),说明当前既没有读线程,也没有写线程持有该锁。可以通过CAS操作开抢了。

抢成功后,调用setExclusiveOwnerThread(current),把ownerThread设成自己。

公平实现和非公平实现几乎一模一样,只是 writerShouldBlock分别被 FairSync 和NonfairSync实现,在上一节已讲。

2.tryRelease(..)实现分析

3.2.5 ReadLock公平与非公平实现

读锁是共享锁,重写了 tryAcquireShared/tryReleaseShared 方法,其实现策略和排他锁有很大的差异。

1.tryAcquireShared(..)实现分析

下面是关于此代码的解释:

(1)

低16位不等于0,说明有写线程持有锁,并且只有当ownerThread!=自己时,才返回-1。这里面有一个潜台词:如果current=ownerThread,则这段代码不会返回。这是因为一个写线程可以再次去拿读锁!也就是说,一个线程在持有了WriteLock后,再去调用ReadLock.lock也是可以的。

(2)上面的compareAndSetState(c,c+SHARED_UNIT),其实是把state的高16位加1(读锁的状态),但因为是在高16位,必须把1左移16位再加1。

(3)firstReader,cachedHoldConunter 之类的变量,只是一些统计变量,在 ReentrantRead-WriteLock对外的一些查询函数中会用到,例如,查询持有读锁的线程列表,但对整个读写互斥机制没有影响,此处不再展开解释。

2.tryReleaseShared(..)实现分析

因为读锁是共享锁,多个线程会同时持有读锁,所以对读锁的释放不能直接减1,而是需要通过一个for循环+CAS操作不断重试。这是tryReleaseShared和tryRelease的根本差异所在。

来源:小安科技论

相关推荐