基础
所谓读写锁,是对访问资源共享锁和排斥锁,一般的重入性语义为:如果对资源加了写锁,其他线程无法再获得写锁与读锁,但是持有写锁的线程,可以对资源加读锁(锁降级);如果一个线程对资源加了读锁,其他线程可以继续加读锁。本文主要分析JDK(1.8+)JCU包中读写锁接口(ReadWriteLock)的重要实现类ReentrantReadWriteLock的细节,来阐述AQS的应用。
样例
我们来看一段例子
1 | public class ReentrantReadWriteLockExample { |
运行结果如下:
1 | 1 -->读数据:0.0 |
结论,我们可以看到,读写锁互斥的,当有写锁锁住资源的时候,无法进行读取,同理,当有数据加了读锁,无法进行写入。
源码分析
类图
-
看一下
ReentrantReadWriteLock
的继承类图
我们可以发现 ReentrantReadWriteLock
类继承了读写锁 ReadWriteLock
的接口:
1 | public interface ReadWriteLock { |
- 我们来看整体的类图
ReentrantReadWriteLock 构造函数
1 | public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable { |
ReentrantReadWriteLock
中提供了两把锁分别是 ReadLock
(读锁)和 WriterLock
(写锁),这两个锁都是以内部类的形式存在的。这两个内部类当中有都有一个 Sync
的对象,这个对象正是AbstractQueuedSynchronizer
(AQS)的实现类。锁中的 Sync
的对象和 ReentrantReadWriteLock
对象中的Sync
是同一个对象。 在ReentrantReadWriteLock
的构造函数中,这个对象对根据我们锁填参数分为 FairSync
(公平锁)和 NonfairSync
(非公平锁)。其中 FairSync
和 NonfairSync
都有各自的实现,下文会分析。
锁(ReadLock 和 WriteLock)之间的关系
读锁和写锁有以下特点:
-
1)
writeLock
是排他的exclusive
,readLock
是共享的sahred
。 -
- 同一个线程可以拥有
writeLock
与readLock
(但必须先获取writeLock
再获取readLock
, 反过来进行获取会导致死锁)。writeLock
与readLock
是互斥的(就像Mysql
的X S
锁)。(死锁的问题后面会具体分析)
- 同一个线程可以拥有
-
- 在获取
writeLock
时监测到有线程获取了readLock
, 则获取writeLock
是线程会一直在AQS
的sync queue
里面等待readLock
被完全释放, 而若获取readLock
的时候,若这个线程以前获取过readLock
, 则还能继续重入 (reentrant
), 而没有获取readLock
的线程因为AQS Sync Queue
里面有想要获取writeLock
的 线程创建的Node
节点存在, 会存放在AQS Sync Queue
队列里面 一直block
。
- 在获取
如果线程想要获取readLock
,并且成功获取,那么前提是aqs队列中没有writeLock
对应的Node
, 如果线程想要获取writeLock
,并且成功获取,那么前提是aqs队列中没有readLock
对应的Node
,
如果你不知道什么是
Node
和AQS Sync Queue
队列,可以参考我的另外两篇博文:JAVA多线程之AQS分析(1),JAVA多线程之AQS分析(2)
Sync 类详解
ReentrantReadWriterLock
同样使用自己的内部类 Sync
(继承 AbstractQueuedSynchronizer
)实现CLH
算法。为了方便对读写锁获取机制的了解,先介绍一下Sync
内部类中几个属性。
对于CLH 锁,这里就不在展开,有兴趣的小伙伴可以自行查阅。
CLH lock is Craig, Landin, and Hagersten (CLH) locks, CLH lock is a spin lock, can ensure no hunger, provide fairness first come first service.
The CLH lock is a scalable, high performance, fairness and spin lock based on the list, the application thread spin only on a local variable, it constantly polling > the precursor state, if it is found that the pre release lock end spin.
Sync 属性
1 |
|
- 首先
ReentrantReadWriterLock
使用一个32位的int类型来表示锁被占用的线程数(state
这个字段在ReentrantLock
中代表同一个线程的加锁次数,采取的办法是,高16位用来表示读锁占有的线程数量,用低16位表示写锁被同一个线程申请的次数。这里留一个疑问,就是为什么要保存同一个线程的申请次数
Sync 状态(静态变量的含义)
-
state
: 这个是在AQS
的类中定义的,并没有在ReentrantReadWriteLock
类的Sync
单独实现,表示了当前锁的状态。 -
SHARED_SHIFT
: 对32位的int进行分割 (对半 16)SHARED_SHIFT
,表示读锁占用的位数,常量16, 也就是对上文的status字段表示分割。 -
SHARED_UNIT
: 如果增加一个读锁,按照上述设计,就相当于增加SHARED_UNIT
,其中的值为000000000 00000001 00000000 00000000
。我们可以看到他的含义就是 将int的高16位作为一个单元,然后在这个单元上加上这个单元的1。 -
MAX_COUNT
: 表示申请读锁最大的线程数量,为65535。例:000000000 00000000 11111111 11111111
。 -
EXCLUSIVE_MASK
:表示计算写锁的值使用的掩码,该值为15个1,用getState()
&EXCLUSIVE_MASK
算出写锁的线程数。 -
举例说明
现在当前,申请读锁的线程数为13个,写锁一个,那
state
怎么表示?
用一个32位的int类型的高16位表示读锁线程数,13的二进制为1101
,那state
的二进制表示为00000000 00001101 00000000 00000001
十进制数为851969
, 接下来要得到读锁和写锁的数量时,需要根据这个851968
这个值得出上文中的 13 与 1。要算成13,只需要将state
无符号向左移位16位置,得出00000000 00001101,就出13,根据851969要算成低16位置,只需要用该00000000 00001101 00000000 00000001
&111111111111111
(15位),就可以得出00000001
,就是利用了1&1得1,1&0得0这个技巧。
ReadLock
我们来看一下读锁的源码
核心代码
1 | public static class ReadLock implements Lock, java.io.Serializable { |
加锁的方法为获取
1 | // sync 的获取共享锁的方法 |
看完这段代码有几个问题:
- 第一:为什么要记录第一把读锁
firstReader
? - 第二:
cachedHoldCounter
是干什么用的? - 第三:排队的阻塞策略是什么?
- 第四:
fullTryAcquireShared
这个方法是干什么用的,为什么保底要执行这个方法? - 第五:加锁之前为什么要
tryAcquireShared
为什么不能直接加锁么,之后doAcquireShared
方法是做什么,为什么要有这一步?
带着这几个问题,我们往下看
readHolds 类(cachedHoldCounter 作用)
- 首先来解决第二个问题
cachedHoldCounter
是干什么用的?
在Sync类当中有这么几个参数
1 | static final class HoldCounter { |
读写锁是要给多个线程调用的,也就是说,多个线程会同事操作同一个对象,每个线程如果读锁重入,就要记住没个线程对应重入了多少读锁。HoldCounter
相当于一个计数器。一次共享锁的操作就相当于在该计数器的操作。获取共享锁,则该计数器 + 1,释放共享锁,该计数器 - 1。只有当线程获取共享锁后才能对共享锁进行释放、重入操作。所以 HoldCounter
的作用就是当前线程持有共享锁的数量,这个数量必须要与线程绑定在一起,否则操作其他线程锁就会抛出异常。
HoldCounter
定义非常简单,就是一个计数器 count
和线程 tid
两个变量。按照这个意思我们看到 HoldCounter
是需要和某给线程进行绑定了,我们知道如果要将一个对象和线程绑定仅仅有 tid
是不够的,而且从上面的代码我们可以看到 HoldCounter
仅仅只是记录了tid,根本起不到绑定线程的作用。那么怎么实现呢?答案是 ThreadLocal
, 定义如下:
- ThradLocalHoldCounter 计数器
ThradLocalHoldCounter
继承了ThreadLocal
,将 HoldCounter
绑定到当前线程上,同时 HoldCounter
也持有线程Id,这样在释放锁的时候才能知道 ReadWriteLock
里面缓存的上一个读取线程(cachedHoldCounter
) 是否是当前线程。这样做的好处是可以减少 ThreadLocal.get()
的次数,因为这也是一个耗时操作。需要说明的是这样 HoldCounter
绑定线程id而不绑定线程对象的原因是避免HoldCounter
和 ThreadLocal
互相绑定而GC难以释放它们(尽管GC能够智能的发现这种引用而回收它们,但是这需要一定的代价),所以其实这样做只是为了帮助GC快速回收对象而已。
- 为什么要记录第一把读锁
firstReader
?
其实这个很好理解,有了上面 cachedHoldCounter
的解释,这里的 firstReader
第一个获取锁的线程也就好理解,一个是最后一个获取锁的线程,一个是第一个获取读锁的线程,这样做的目的就是为了性能考虑,实际上就是缓存。因为第一个和最后一个读锁在整个互斥链上有着比较重要的作用,在后边的代码中,我们可以看到他们的实际作用。
齐次,fullTryAcquireShared
这个方法是干什么用的,为什么保底要执行这个方法??
我们来看一下代码
1 | final int fullTryAcquireShared(Thread current) { |
看代码发现,这段代码和 tryAcquireShared
特别相似,相对于其,多加了一些额外的判断和一个for循环。从tryAcquireShared
中可以看到,调用到该方法的前提是:
1 | if (!readerShouldBlock() &&r < MAX_COUNT && |
- 1、
readerShouldBlock
失败,证明当前有写锁,失败。 - 2、
compareAndSetState
失败,证明当前可能有读锁被抢占。
有读写锁的特性可以得到,读锁之间是可以重入的。那么如果这两个有任意一个调用失败,我们都可以进行再次的尝试.如果再次尝试,写锁释放,我们既可以得到锁。compareAndSetState
失败,我们也可以再次尝试。当有上面两个条件任意一个失败的时候,我们让这个方法进入自旋状态,确保读锁可以有效的获取锁。
锁降级
在这段代码之前 我们还可以看到
1 | // 这段代码的含义是,如果当前线程拥有写锁,但是又要去申请写锁,是允许的当写锁被持有时, |
-
锁降级定义:重入还允许从写入锁降级为读取锁,其实现方式是:先获取写入锁,然后获取读取锁,最后释放写入锁。但是,从读取锁升级到写入锁是不可能的。
-
锁降级的必要性
我们来看一下这样的情景, 如果读锁不在写锁之后 进行降级 那么情况1 中的 线程获取和修改的值就会出现类脏读的问题
线程1 命名修改了 data = 1 但是由于 写锁后没有降级成读锁,导致data 被线程2 改成了 2
时间序列 | 线程1 | 线程2 |
---|---|---|
获取写锁 | ||
更改数据 data = 1 | ||
释放写锁 | 获取写锁 | |
data =2 | ||
处理数据 | ||
释放写锁 | ||
处理完毕 | ||
打印data = 2 | ||
打印data = 2 |
读锁降级为写锁
时间序列 | 线程1 | 线程2 |
---|---|---|
获取写锁 | ||
获得读锁 | ||
更改数据 data = 1 | ||
释放写锁 | ||
获取写锁失败 阻塞 | ||
处理数据 | ||
处理完毕 | ||
打印data = 2 | ||
释放读锁 | 获取写锁 成功 |
若发现当前的线程应该排队的时候,那么会正式进入以下代码,用于加锁。代码 和 acquireQueued
比较相似,可以参考 JAVA多线程之AQS分析(2)-Sync doAcquireSharedInterruptibly
这里只贴一下代码
1 | private void doAcquireShared(int arg) { |
ReadLockUnlock 解锁流程
核心代码
1 | // ReentrantReadWriteLock 代码 |
由代码我们知道,其实就只是做了状态的更改,更改statue 和每个线程的holder的数量,具体的解锁流程代码如下:
1 | private void doReleaseShared() { |
doReleaseShared
的方法 在JAVA多线程之AQS分析(2)-sync-doReleaseshared 有详细的介绍
这里再次强调一下
-
这里为什么要 拿到head?:因为共享锁是可以传播的,意思是如果某一个共享锁阻塞的线程被唤醒了,那么意味着排队链上的所有被共享节点阻塞的线程都应该被唤醒。
-
如果当前线程是排队链的第n个,那么当被唤醒的时候,我们要找到头部节点,如果头结点是阻塞状态,那么自旋的去获取 ,知道解锁成功,将head链唤醒,当head被唤醒的时候,会执行
setHeadAndPropagate
这个方法,然后唤醒下一个被共享锁阻塞的线程。 -
如果头结点是当前线程,那就意味着头结点已经被唤醒了或者已经持有锁了,那么意味着已经做过唤醒其他的(
setHeadAndPropagate
)操作了。如果头结点是阻塞状态,那么自旋的去获取 ,知道解锁成功
至此,我们已经完整了解了读写锁的加锁流程。 那么我们剩下一个问题,就是:排队的策略是什么样子的? 这就涉及到我们的写锁流程
WriteLock
按照我们的传统,来看一下代码
1 | public void lock() { |
从代码看到,加锁的流程会降级为普通排它锁,之所以可以这么做,完全可以无视上面的读写锁的互斥规则的原因就是,读锁的重入基本上是不会对阻塞链造成什么改变的,原因如下 在读锁tryAcquireShared
的方法有个
readerShouldBlock
方法,这个方法是由公平锁和非公平锁的类实现的
1 | static final class FairSync extends Sync { |
排队的策略
- 公平锁的
readerShouldBlock
/writerShouldBlock
首先来看公平锁的 block 判定都是 hasQueuedPredecessors
这个方法。 这个方法的判断标准我们以前讨论过,就是看排队链有没有节点,也就是说,只有写锁tryAcquire
失败了(注意,tryAcquire != 0 证明 有读锁占据高位置) 才会加入到队列当中,那么意味着,一旦加入排队,往后的读锁就要执行 hasQueuedPredecessors
方法的时候,就要排队了。
writerShouldBlock
也是一样,写锁要排到队列后边,两个写锁有先后关系。如下
1 | head(read) -> wl1(写锁1) -> rl1(读锁1) ... -> wl2(写锁2) ---> other |
-
非公平锁的
readerShouldBlock
/writerShouldBlock
: 非公平锁的排队策略 在注释生已经说的很清楚了,我们来看一下这两段注释As a heuristic to avoid indefinite writer starvation,
block if the thread that momentarily appears to be head
of queue, if one exists, is a waiting writer. This is
only a probabilistic effect since a new reader will not
block if there is a waiting writer behind other enabled
readers that have not yet drained from the queue.
Returns {@code true} if the apparent first queued thread, if one
exists, is waiting in exclusive mode. If this method returns
{@code true}, and the current thread is attempting to acquire in
shared mode (that is, this method is invoked from {@link
tryAcquireShared}) then it is guaranteed that the current thread
is not the first queued thread. Used only as a heuristic in
ReentrantReadWriteLock.
简单的来说就是,当代队里中的第一个节点(head 后的第一个节点 )不是共享锁的时候,需要排序。换句话说,当队列中(head 后第一个元素)是写锁的时候,且写锁被阻塞了,那么这个时候,读锁就要排队,因为读锁必须要排在写锁侯彪,看代码已经很清晰了。
1 | final boolean apparentlyFirstQueuedIsExclusive() { |
总结
致辞,AQS的核心已经全部分享完毕了。总结如下
- AQS的排最节点氛围 共享和排他两种模式,共享锁的唤醒是由前一个节点来唤醒。
- 排它锁的阻塞由阻塞线程完成,但是前一个排他锁的阻塞状态是由阻塞线程完成的。
- statue 不仅保存了读锁的数量,还保存了写锁的数量。
- 读锁可重入,一旦有写锁加入等待队列,意味着后面想要获取锁的操作(未获取过锁的)操作都需要排队。一旦写锁释放,后边的写锁就都会被唤醒。
参考
- ReentrantReadWriteLock 源码分析(基于Java 8)
- 【死磕Java并发】-----J.U.C之读写锁:ReentrantReadWriteLock
- 并发编程之——读锁源码分析(解释关于锁降级的争议)
赞赏一下