JUC源码学习笔记6——ReentrantReadWriteLock
系列文章目录和关于我
阅读此文需要有AQS独占和AQS共享的源码功底,推荐阅读:
1.JUC源码学习笔记1——AQS独占模式和ReentrantLock
2.JUC源码学习笔记2——AQS共享和Semaphore,CountDownLatch
一丶类结构和源码注释解读
1.ReadWriteLock
维护了一对关联锁,一个用于只读操作,另一个用于写入。读读可以共享资源,因为不会造成数据的变更,读写,写写互斥,因为读写可能造成脏读,幻读,不可重复读等错误,写写可能造成脏写等错误(ps:有点mysql innodb隔离级别的味道,只是mysql innodb 使用mvcc多版本并发控制让并发能力更高,当然innodb也有S锁,和X锁,类似于读写锁一样对并发事务进行控制)。读写锁在访问共享数据时允许比互斥锁更高级别的并发性。与使用互斥锁相比,读写锁是否会提高性能取决于数据被读取的频率,读写锁适用于读多写少的情况,如果写操作变得频繁,那么数据的大部分时间都被独占锁定,并发性几乎没有增加。
读写锁面临的问题:
- 写锁被释放时,存在多个线程拿读锁,和多个线程拿写锁,是将锁给读线程还是写线程呢,通常情况下都是给写锁的,因为我们认为写操作没用读操作频繁。如果读操作非常频繁,且持有读锁的时间很长,写锁需要等待所有读锁释放才能获取,这样将造成写锁长时间无法获取锁。当然这种情况下可以使用”公平“的策略,先来后到获取锁
- 锁是否可重入,读锁是否可重入,写锁是否可重入
- 读锁是否可以升级为写锁,写锁是否可以降级为读锁
2.ReentrantReadWriteLock
ReadWriteLock的一个实现,它具备以下特性。
2.1支持公平和非公平
-
非公平
非公平情况下,会导致没拿到锁的线程处于”饥饿“状态,但是拥有更高的吞吐率。为什么?我认为是公平情况下需要排队,排队的线程会被
LockSupport.park
挂起,意味着释放锁的时候需要使用LockSupport.unpark
唤醒排队的线程,这时候并不允许其他线程抢占先机,唤醒是需要时间的,公平情况下这一段时间锁是没用被任何线程获取锁的,所以说吞吐率不如非公平锁。 -
公平
当构造为公平时,线程已近似到达顺序策略来竞争进入(CAS入队的顺序,为什么说是近似顺序,入队的瞬间存在消耗完时间片,让老六线程抢先一步,这个顺序是cpu决定的,并不是绝对时间上的先后顺序)。当前持有的锁被释放时,等待时间最长的单个写入线程将被分配写入锁,或者如果有一组读取线程等待时间超过所有等待的写入线程,则该组将被分配读取锁(这里的意思是说,如果写锁排在队列头部,那么写锁被持有,如果队列头部是一堆读锁,那么读锁被持有)。
公平情况下,如果写锁被持有,或者存在等待写锁的线程,那么在此之后获取读锁的线程会被阻塞。在最早等待获取写锁的线程没有释放锁的情况下,其他线程是无法获取读锁的,除非等待获取写锁的线程,放弃获取写锁并在AQS队列中不存在其他等待写锁的线程位于读锁之前。
注意,非阻塞ReentrantReadWriteLock.ReadLock.tryLock()和ReentrandReadWriterLock.WriteLock.tryLock()方法不支持这种公平设置,如果可能的话,不管等待的线程如何,都会立即获取锁
2.2 可重入,写锁降级为读锁,读锁无法升级为写锁
ReentrantReadWriteLock允许读线程和写线程以ReentrantLock类似的方式重新获取读或写锁。“此外,获取写锁的线程可以获取读读锁,仅持有读锁的线程试图获取写锁,它将死锁
。注释里面还提了一嘴这个重入,以及写锁可以拿到读锁有啥用——A方法获取写锁,调用B方法,B方法是读取数据进行校验,这时候B需要获取读锁,B调用C方法,C也需要获取读锁,这些方法可以正常进行,可以看出重入和写锁可降级的用处了吧
可以先持有写锁,然后获取读锁(这时候肯定直接可以拿到)然后释放掉写锁,将写锁降级为读锁,这种使用方式可以保证,持有写锁的线程一定可以成功拿到读锁。
2.3读写锁都支持等待获取锁的途中响应中断
这是synchronized
所不支持的,在ReentrantLock
源码解读中,解读过源码,重点是LockSupport.park
方法挂起线程A,线程A有两种方式可以从LockSupport.park
中返回:1.被unpark,2.被中断。源码的实现是如果不支持等待锁的途中中断,会记录下当前线程被中断过,然后Thread.interrupted()
重置中断标注(因为中断的线程无法再次被park)然后继续park当前线程,让其等待锁,获取到锁之后,发现之前被中断过会自我中断补上中断标志。在获取锁的途中响应中断,则是从LockSupport.park
检查中断标志,如果被中断了说明是由于中断从LockSupport.park
中返回,这时候将抛出中断异常。
2.4 写锁支持基于Condition的等待唤醒
写锁支持Condition,但是读锁不支持Condition等待唤醒,读锁本身是共享的,需要所有读锁释放后才有必要唤醒写锁。
二丶读写锁使用范例
这里使用读写锁实现了一个线程安全,读写分离的TreeMap,doug lea还提醒到想让这个TreeMap并发性能很好,必须实在读多写少的情况下。
三丶属性和构造方法
可以看到读写锁,内部使用final修饰读锁和写锁,然后通过writeLock,readLock
两个方法将锁暴露出去。有意思的是其构造方法,构造读写锁把this传递了进去
可以看到传递this的目的,是让读写锁使用sync = fair ? new FairSync() : new NonfairSync();
生成的AQS子类对象,让读写锁使用同一个Sync对象,这样才能做到读写互斥,下面我们分析FairSync
,NonfairSync
是怎么一回事
三丶源码解析
1.FairSync,NonfairSync类结构
熟悉的套路,把具体锁的实现,放到内部类Sync中,读写锁只是调用对应的Sync的方法,整个Sync内容内容很多,我们先看具体源码,Sync自然就柳暗花明了。
2.读锁加锁-ReadLock#lock
2.1源码解析
问题:
读锁如何实现公平,
读锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的),
写锁被获取如何让其他线程无法获取读锁,写锁如何降级为读锁
获取读锁然后获取写锁为啥会死锁
ReadLock的lock
方法直接调用Sync
的acquireShared(1)
方法,此方法在AQS中进行了实现
最终是调用Sync的tryAcquireShared
方法
protected final int tryAcquireShared(int unused) {
//当前线程
Thread current = Thread.currentThread();
//状态 低16为写锁重入次数 高16位读锁被多少个线程持有
int c = getState();
//写锁被占有 且不是自己占有写锁 返回-1 表示获取失败,这个时候会共享入队
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//写锁共享次数
int r = sharedCount(c);
//readerShouldBlock 公平情况下就是看前面是否有人排队
//非公平情况下就是看头结点的下一个节点是否是共享模式,如果是共享说明,写锁被持有多个读锁都被阻塞了
if (!readerShouldBlock() &&
//读锁被持有小于最大值
r < MAX_COUNT &&
//CAS更改成功读锁数量
compareAndSetState(c, c + SHARED_UNIT)) {
//当前读锁是第一个拿读锁的
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {//读锁线程再次获取读锁
firstReaderHoldCount++;
} else {
//记录当前线程持有读锁数量
//利用ThreadLocal进行记录
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//到这 1.当前线程需要排队,2.读锁被持有数量大于最大值 3.CAS失败,多个线程在拿读锁
return fullTryAcquireShared(current);
}
这段代码可以看做两部分,最后一句return之前的内容,我称为快速获取写锁
,fullTryAcquireShared
我称之为完全尝试获取写锁
-
快速获取写锁
首先如果发现独占锁重入数量不为0,且独占的线程不是自己,也就是说当前写锁被其他线程持有,那么直接返回-1,这里可以看出
写锁被持有,其他线程无法获取读锁
其次
readerShouldBlock
这个方法返回true 代表当前读线程需要阻塞,什么是否需要阻塞?- 公平锁FairSync的逻辑是看是否有排队的线程,前面有人排队为什么说读线程需要阻塞——说明写锁被持有,多个读线程在排队,这时候是看是否有人排队,这是公平的体现
- 非公平锁是看第一个排队的线程是否是独占模式,这是不公平的体现,如果第一个排队的线程是共享模式,那么还是会尝试获取锁,相当于插队
douglea,使用CAS修改state记录写锁被多少线程持有,这里CAS是因为可能存在多个线程获取读锁,修改成功后,会用ThreadLocal记录当前这个线程写锁重入数量
-
完全获取写锁
进入完全获取写锁的条件
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
判断写锁没有被持有,但是readerShouldBlock
发现读线程需要排队,可能是突然写锁被持有,也可能是读锁被很多线程持有,当前线程需要排队- cas更新state失败,意味着多个线程获取写锁,当前线程cas失败,有点类似之前AQS中的快速入队
完全获取写锁的逻辑和快速类似,但是它是自选+CAS保证,要么获取到写锁,要么返回-1进行排队
2.2读锁如何实现公平
上面说到了,是readerShouldBlock
来实现公平,在公平锁的情况下,加入当前写锁被持有,多个读线程在排队,如 A->B->C,然后线程D尝试获取读锁,这时候是看队列中是否有排队的线程,如果有那么线程D会进行排队。
非公平锁是看第一个排队的线程是否是独占模式,如果队列头是独占,那么会进行排队,这样做的目的是避免写线程一直饥饿,如果不是那么会尝试获取锁,这相当于厕所门口多人(读线程)排队,你硬抢,是非公平的体现
2.3读锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的)
对于第一个获取写锁的线程,它会使用firstReader
,firstReaderHoldCount
记录线程和其重入数量,对于后面获取写锁的线程,会使用ThreadLocal
进行记录
2.4写锁被获取如何让其他线程无法获取读锁,写锁如何降级为读锁
首先快速获取读锁的有如下判断
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
如果持有写锁的线程不是当前线程,那么返回-1,进行排队,在完全入队的自旋中也有这一段逻辑
如果当前线程就是持有写锁的线程则不会返回-1,依旧还是自旋+CAS获取读锁,从而获取到读锁
然后此时线程再释放写锁,就实现了写锁降级为读锁。
2.5获取读锁然后获取写锁为啥会死锁
执行上述代码,你会发现发生了死锁,make it
永远不会打印出来,为啥呢?这里需要我们看完写锁获取的源码。
3.读锁释放ReadLock#unLock
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//更新当前线程的重入数量
if (firstReader == current) {
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
//cas改变state
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
源码不难,主要两部分
-
更改ThreadLocal,或者是
firstReaderHoldCount
中记录的当前线程写锁重入数量如果是第一个获取写锁的线程那么
firstReaderHoldCount--
完全释放的时候firstReader
设置为null如果不是第一个或者说第一个线程重入2次,释放3此,都会进入到else分支,减少ThreadLocal中记录的重入数量,如果发现释放次数>重入次数,会抛出异常
-
cas改变state,state使用低16位记录写锁重入数量,使用高16为记录读锁重入数量
低16位 = 写锁重入数
高16位 = 每一个读锁持有线程的重入数之和
这里需要使用CAS修改state,因为存在多个读线程同时释放读锁的情况
读锁的tryLock lockInterruptibly()还是哪些老套路,没啥好看的
4.写锁加锁-WriteLock#Lock
问题
写锁如何实现公平,
写锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的),
获取读锁然后获取写锁为啥会死锁
4.1.源码解析
写锁加锁调用的是AQS的acquire
方法
我们在AQS独占源码分析中,说到过,如果tryAcquire
失败,意味着后续会调用acquireQueued
入队然后获取锁后才能出队,其中selfInterrupt
是为了将获取锁途中受到的中断补上,tryAcquire
被读写锁中的Sync内部类重写,如下
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
//写锁被重入数
int w = exclusiveCount(c);
//c!=0说明写锁或者读锁至少有一个锁被持有
if (c != 0) {
//w == 0说明读锁被持有,那么写锁需要阻塞返回false
//w!=0 但是 current != getExclusiveOwnerThread() 说明写锁被其他线程持有
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//到这说明写锁被当前线程持有,那么线程不需要使用CAS
//确保重入数不能大于 MAX_COUNT
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//重入+1
setState(c + acquires);
return true;
}
//writerShouldBlock——写锁是否需要阻塞
//公平锁的情况下:如果有排队的线程 那么返回true
//非公平的情况下,恒定返回false
if (writerShouldBlock() ||
//如果cas失败 说明写锁被其他线程持有 那么返回false需要进行排队 写写互斥
!compareAndSetState(c, c + acquires))
return false;
//到此说明当前线程 拿到了写锁 记录下独占的线程
setExclusiveOwnerThread(current);
return true;
}
源码相比于共享更简单,主要分为两部分
-
写锁或者读锁被持有
这部分的重点在于处理重入
C!=0
说明state不为0,那么写或者读锁必定有一种锁被持有,然后(w == 0 || current != getExclusiveOwnerThread()
,如果w==0成立,说明读锁被持有了,这时候直接返回false,因为读写互斥,如果w!=0说明此时写锁被持有,继续判断current != getExclusiveOwnerThread()
,当前线程是否是持有写锁的线程,如果不是返回false,说明写锁被其他线程持有。如果当前线程就是持有写锁的线程,接下来就是使用
setState
设置重入数量,这一步不需要CAS,本身便是线程安全的 -
写锁和读锁都未被持有
这里的未被持有,是上面第一个if中的判断结果,也许第一个if执行完就有其他线程获取到了读锁或者写锁
首先
writerShouldBlock
判断写线程是否需要阻塞,在公平情况下是看是否具备排队的线程,非公平情况下恒定返回false,只有第一个if结束有其他线程抢先获取了写锁,并且后续有其他线程获取锁,才可能出现公平情况下判断得到有排队的线程,这是一种公平的体现,其他线程在排队那么当前获取锁的线程也需要加入队列尾部。非公平情况下,writerShouldBlock
恒定返回false,这里可以看出写锁偏好
——即使前面有A已经获取了写锁,BC两个线程排队获取读锁,非公平情况下,只要A释放了写锁的一瞬间,当前线程可不管释放有人排毒,就是一个CAS抢锁,这里即是非公平
也是写锁偏好
的体现compareAndSetState(c, c + acquires)
这便是当前线程CAS抢锁,保证线程安全,后续如果抢锁成功setExclusiveOwnerThread(current)
记录写锁被当前线程获取。
4.2写锁如何实现公平
上面说到,公平情况下,写锁的获取需要看是否具备排队的线程,如果具备其他线程排队,那么直接返回false,这样会让当前线程调用acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
进行排队并等待其他线程释放锁后等待,这便是公平。
4.3写锁如何实现重入(重入需要记录获取次数,那么doug lea 如何实现的)
和ReentrantLock一样,通过state记录重入数量,只是低16位才是写锁的重入数量,每当写锁被重入便setState
进行记录
4.4获取读锁然后获取写锁为啥会死锁
前面读锁加锁解读,我们就抛出了这个问题,这里我们结合写锁加锁源码看下,为什么会死锁
5.写锁释放锁-WriteLock#unLock
写锁的释放就是调用内部类Sync的release方法,此方法会调用tryRelease
如果返回true后续会唤醒其他等待的线程
ReentrantReadWriteLock内部类Sync的tryRelease方法如下
这里首先会判断当前线程释放是独占写锁的线程,如果不是那么就是其他线程想释放持有写锁线程的锁,这是不允许的
然后计算释放后的重入数量,这里一般是重入数量-1,如果减少重入后的数量为0,那么free为true,这意味着完全释放了写锁,会将独占写锁的线程属性置为null,如果free为true这个方法结束后就会调用AQS中的unparkSuccessor
唤醒其他线程
setState
改变重入数量,这里不需要加锁因为还没有唤醒其他线程,所以此时不会存在线程安全问题,这是独占释放和共享释放的一个区别,共享锁的释放需要使用自旋+CAS保证线程安全的更新state