1. 介绍
本文我们继续探究使用AQS的子类ReentrantReadWriteLock(读写锁)。老规矩,先贴一下类图 ReentrantReadWriteLock这个类包含读锁和写锁,这两种锁都存在是否公平的概念,这个后面会细讲。
此类跟ReentrantLock类似,有以下几种性质:
- 可选的公平性政策
- 重入,读锁和写锁同一个线程可以重复获取。写锁可以获取读锁,反之不能
- 锁的降级,重入还可以通过获取写锁,然后获取到读锁,通过释放写锁的方式,从而写锁降级为读锁。 然而,从读锁升级到写锁是不可能的。
- 获取读写锁期间,支持不可中断
2. 源码剖析
先讲几个必要的知识点,然后我们再对写锁的获取与释放,读锁的获取与释放进行讲解,中间穿插着讲公平与非公平的实现。
知识点一: 内部类Sync中,将AQS中的state(private volatile int state;长度是32位)逻辑分成了两份,高16位代表读锁持有的count,低16位代表写锁持有的count
Sync
1 |
|
This lock supports a maximum of 65535 recursive write locks and 65535 read locks. Attempts to exceed these limits result in Error throws from locking methods.(读锁与写锁都最大支持65535个)
知识点二: HoldCounter的作用,一个计数器记录每个线程持有的读锁count。使用ThreadLocal维护。缓存在cachedHoldCounter
1 |
|
使用ThreadLocal维护 👇
1 |
|
维护最后一个使用HoldCounter的线程。简言之就是,假如A线程持有读锁,A线程重入获取读锁,在它之后没有其他线程获取读锁,那么当获取HoldCounter时,可以直接将cachedHoldCounter赋值给该线程,就不用从ThreadLocal中去查询了(ThreadLocal内部维持一个 Map ,想获取当前线程的值就需要去遍历查询),这样做可以节约时间。
1 |
|
当前线程持有的重入读锁count,当某个线程持有的count降至0,将被删除。
1 |
|
初始化在构造函数或readObject中
1 |
|
1 |
|
知识点三: 是否互斥 : | | 读操作 | 写操作 | | ———— | ———— | ———— | | 读操作 | 否 | 是 | | 写操作 | 是 | 是 |
只有读读不互斥,其余都互斥 获取到了写锁,也有资格获取读锁,反之不行.
知识点四 ReentranReadWriteLock,实现了ReadWriteLock接口。
1 |
|
ReadWriteLock维护着写锁和读锁。写锁是排他的,而读锁可以同时由多个线程持有。与互斥锁相比,读写锁的粒度更细
有了上面的知识,等会理解下面的源码就更容易了,故事从下面几个变量开始~
1 |
|
ReentrantReadWriteLock的构造器,默认是非公平模式
1 |
|
2.1 写锁
写锁,排他锁;一个线程获取了写锁,其他线程只能等待
2.1.1 写锁的获取
1 |
|
java.util.concurrent.locks.ReentrantReadWriteLock.WriteLock#lock
1 |
|
又来到了AQS的acquire方法
1 |
|
我们看ReentrantReadWriteLock是如何实现tryAcquire的
1 |
|
上面代码中的writerShouldBlock方法就是tryAcquire控制公平与否的关键,我们分别看看公平与非公平是如何实现的
2.1.1.1 写锁的获取(非公平)
默认情况下是非公平的
1 |
|
2.1.1.2 写锁的获取(公平)
1 |
|
判断是否该线程前面还有其他线程的结点,上一节有讲到过。
这里还贴一下,整个acquire的流程图
2.1.2 写锁的释放
下面的这段代码,记得一定放在finally中
1 |
|
1 |
|
1 |
|
又看到了熟悉的面孔,但我们主要看的还是tryRelease, 👇
1 |
|
很简单,就贴一下release的流程图
2.2 读锁
读锁与读锁并不互斥,可以存在多个持有读锁的线程📕
2.2.1 读锁的获取
1 |
|
1 |
|
1 |
|
之前的文章还没有讲解过tryAcquireShared在子类如何实现的。看看如何实现的 👇
1 |
|
Full version of acquire for reads, that handles CAS misses and reentrant reads not dealt with in tryAcquireShared. ```java
1 |
|
1 |
|
判断sync queue的head的后继结点是否是写锁(独占模式)
1
2
3
4
5
6
7final boolean apparentlyFirstQueuedIsExclusive() { Node h, s; return (h = head) != null && (s = h.next) != null && !s.isShared() && s.thread != null; }
上面的方法是,获取读锁时,避免导致写锁饥饿(indefinite writer starvation)的一个措施,下面我们对它进行详细的解释
结合上面的图片,我们设想有一个这样的情况,写锁没有被获取,线程A获取到了读锁,此时另一个线程X想要获取写锁,但是写锁与读锁互斥,所以此时将线程X代表的node添加到sync queue中,等待读锁被释放,才有资格去获取写锁。
上面的情况 + 不存在判断sync queue的head的后继结点是否是写锁(apparentlyFirstQueuedIsExclusive)的方法时,我们看看会出什么问题
时刻一: 线程B、线程C,线程D是新建线程想要去获取读锁(new reader),此时因为不存在写锁被获取,所以线程B、线程C,线程D都会在fullTryAcquireShared中不断重试,最终都获得读锁
时刻二: 线程A释放,会执行unparkSuccessor,此时线程X被唤醒,但是执行到tryAcquire,又检测到读锁被持有(不管是自己还是是其他线程),线程X又被阻塞。线程B释放,还是会出现这种情况,只有等到最后一个读锁被释放,线程X才能获取到写锁。但是想想如果后面一连串的读锁,线程X不是早就被‘饿死了’
apparentlyFirstQueuedIsExclusive,就可以防止这种‘写锁饥饿’的情况发生。线程B、线程C,线程D只有被阻塞,等待线程X获取到写锁,才有机会获取读锁。
2.2.1.2 读锁的获取(公平)
1 |
|
这里关于读锁的获取(公平与非公平)分析完了,贴一张整个acquireShared的流程图
2.2.2 读锁的释放
1 |
|
1 |
|
AQS
1 |
|
1 |
|
3. 总结
- ReentrantReadWriteLock中的防止’写锁饥饿’的操作,值得一看
- 将AQS中的state(private volatile int state;),逻辑分为高16位(代表读锁的state),低16位(代表写锁的state),是一个值得学习的办法
- 使用ThreadLocal维护每一个现成的读锁的重入数,使用cachedHoldCounter维护最后一个使用HoldCounter的读锁线程,节省在ThreadLocal中的查询
使用读写锁的情况,应该取决于与修改相比,读取数据的频率,读取和写入操作持续的时间。 例如:
- 某个集合不经常修改,但是对其元素搜索很频繁,使用读写锁就是最佳选择。(简言之,就是读多写少)
- 现在也是读多写少,但是读操作时间很短,只有一小段代码,而读写锁比互斥锁更加复杂,开销可能大于互斥锁,这种情况使用读写锁可能不合适。此时就要通过性能分析,判断使用读写锁在系统中是否合适。