一. 为什么要有ReentrantReadWriteLock?
通过前面的学习,我们知道ReentrantLock已经实现了互斥锁的功能,并且ReentrantLock已经有了非常丰富的API可使用,完全可以保障我们并发编程时对于线程安全的高要求。既然如此,为什么又出现了一个ReentrantReadWriteLock呢?原来啊,在一个读多写少的场景下,如果我们采用synchronized或者说ReentrantLock,其效率是非常低的,就像前面说的,它们都是互斥锁。在这种情况下,咱们就可以使用ReentrantReadWriteLock读写锁去实现。读读之间是不互斥的,可以读和读操作并发执行,但是如果涉及到了写操作,那么还得是互斥的操作。
二. ReentrantReadWriteLock的实现原理
ReentrantReadWriteLock依然是基于AQS实现,也还是对state值进行操作,拿到了锁资源就可以往下执行任务,拿不到锁资源也同样是进入AQS队列中排队并挂起线程。但是,ReentrantReadWriteLock不同的是它实现了读写锁的功能,也正是这个读写锁的实现,才使得它与ReentrantLock不一样。读锁,意味着多个线程可以共享,同时访问某个资源而不需要等待和抢占资源。写锁,也就是我们常说的互斥锁,这一层面就是ReentrantLock和synchronized实现的锁功能,多个线程不可共享,同一时间也只有一个线程可以占有锁资源,其他线程只能等待和抢占这把锁。那么,现在就有个问题来了,我们都知道java层面都是可重入锁,如果ReentrantReadWriteLock同时实现了读锁和写锁功能,我们又该怎么做到写锁重入和读锁重入,请往下看。
写锁重入:读写锁中的写锁的重入方式,基本和ReentrantLock一致,没有什么区别,依然是对state进行+1操作即可,只是注意此处只对state的低16位进行+1操作,只要确认持有锁资源的线程,是当前写锁线程即可。只不过之前ReentrantLock的重入次数是state的正数取值范围,但是读写锁中写锁范围就变小了。
读锁重入:因为读锁是共享锁。读锁在获取锁资源操作时,是要对state的高16位进行 + 1操作。因为读锁是共享锁,所以同一时间会有多个读线程持有读锁资源。这样一来,多个读操作在持有读锁时,无法确认每个线程读锁重入的次数。为了去记录读锁重入的次数,每个读操作的线程,都会有一个ThreadLocal记录锁重入的次数。
仅从上述来看,其实还会产生一个很严重的问题,也叫写锁饥饿问题,如下。
写锁的饥饿问题:读锁是共享锁,当有线程持有读锁资源时,再来一个线程想要获取读锁,直接对state修改即可。在读锁资源先被占用后,来了一个写锁资源,此时,大量的需要获取读锁的线程来请求锁资源,如果可以绕过写锁,直接拿资源,会造成写锁长时间无法获取到写锁资源。
那么,ReentrantReadWriteLock又怎么解决我们的写锁饥饿问题呢?
其实原理很简单,不管是来获取读锁还是写锁的任务节点,我们都放入AQS队列中进行排队,保证只有写锁前面的读锁线程节点能够获得资源并执行,而在写锁后面的任务节点必须得等到该写锁任务节点执行并释放锁资源后才能被唤醒即可。
三. 写锁加锁流程分析
依然是老套路,我们通过画图的方式来理解一下ReentrantReadWriteLock的写锁加锁流程,其实除了state的操作不一样,其余的和ReentrantLock是完全一致的。
接下来,在理解了写锁的加锁流程后,我们再来看一下代码又是如何实现的呢?
// 写锁加锁的入口
public void lock() {
sync.acquire(1);
}
// 与ReentrantLock是一模一样的调用流程。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// 读写锁的写锁实现tryAcquire
protected final boolean tryAcquire(int acquires) {
// 拿到当前线程
Thread current = Thread.currentThread();
// 拿到state的值
int c = getState();
// 得到state低16位的值
int w = exclusiveCount(c);
// 判断是否有线程持有着锁资源
if (c != 0) {
// 当前没有线程持有写锁,读写互斥,告辞。
// 有线程持有写锁,持有写锁的线程不是当前线程,不是锁重入,告辞。
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 当前线程持有写锁。 锁重入。
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 没有超过锁重入的次数,正常 + 1
setState(c + acquires);
return true;
}
// 尝试获取锁资源
if (writerShouldBlock() ||
// CAS拿锁
!compareAndSetState(c, c + acquires))
return false;
// 拿锁成功,设置占有互斥锁的线程
setExclusiveOwnerThread(current);
// 返回true
return true;
}
// ================================================================
// 这个方法是将state的低16位的值拿到
int w = exclusiveCount(c);
state & ((1 << 16) - 1)
00000000 00000000 00000000 00000001 == 1
00000000 00000001 00000000 00000000 == 1 << 16
00000000 00000000 11111111 11111111 == (1 << 16) - 1
&运算,一个为0,必然为0,都为1,才为1
// ================================================================
// writerShouldBlock方法查看公平锁和非公平锁的效果
// 非公平锁直接返回false执行CAS尝试获取锁资源
// 公平锁需要查看是否有排队的,如果有排队的,我是否是head的next,即我是不是第一个排队的
四. 写锁释放锁流程分析
释放的流程和ReentrantLock一致,只是在判断释放是否干净时,判断低16位的值。此处,我们只展示部分关键代码,其余代码和ReentrantLock一致,本节课不做赘述。
// 写锁释放锁的tryRelease方法
protected final boolean tryRelease(int releases) {
// 判断当前持有写锁的线程是否是当前线程
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 获取state - 1
int nextc = getState() - releases;
// 判断低16位结果是否为0,如果为0,free设置为true
boolean free = exclusiveCount(nextc) == 0;
if (free)
// 将持有锁的线程设置为null
setExclusiveOwnerThread(null);
// 设置给state
setState(nextc);
// 释放干净,返回true。 写锁有重入,这里需要返回false,不去释放排队的Node
return free;
}
五. 读锁加锁流程分析
读锁的实现相比起写锁会稍微复杂一些,因为写锁的加锁和释放锁流程几乎与ReentrantLock一致,但读锁的加解锁流程会有些关键的变化点。开始之前,我们依然是通过画图的方式去理解和描述咱们的读锁加锁的一个流程。
接下来,我们对读锁加锁流程,再做一个详细的源码分析,如下所示,此处省略了读锁重入的部分逻辑没讲,希望大家先理解当下的源码,理解后,我们再来进行读锁重入部分的分析。
// 读锁加锁的方法入口
public final void acquireShared(int arg) {
// 竞争到锁资源的,则返回值为1,意味着不用排队,线程直接进行操作。
if (tryAcquireShared(arg) < 0)
// 返回值小于0,则是没拿到锁资源,需要去AQS队列排队
doAcquireShared(arg);
}
// 读锁竞争锁资源的操作
protected final int tryAcquireShared(int unused) {
// 拿到当前线程
Thread current = Thread.currentThread();
// 拿到state
int c = getState();
// 拿到state的低16位,判断 != 0,有写锁占用着锁资源
// 并且,当前占用锁资源的线程不是当前线程
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
// 写锁被其他线程占用,无法获取读锁,直接返回 -1,去排队
return -1;
// 没有线程持有写锁、当前线程持有写锁
// 获取读锁的信息,state的高16位。
int r = sharedCount(c);
// 公平锁:就查看队列是由有排队的,有排队的,直接退出,进不去if,后面也不用判断(没人排队继续走)
// 非公平锁:没有排队的,直接抢。 有排队的,但是读锁其实不需要排队,如果出现这个情况,大部分是写锁资源刚刚释放,
// 后续Node还没有来记得拿到读锁资源,当前竞争的读线程,可以直接获取
if (!readerShouldBlock() &&
// 判断持有读锁的临界值是否达到
r < MAX_COUNT &&
// CAS修改state,对高16位进行 + 1
compareAndSetState(c, c + SHARED_UNIT)) {
// 省略部分代码!!!!(不用急,先理解现有的内容)
return 1;
}
return fullTryAcquireShared(current);
}
// 非公平锁的判断
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null && // head为null,可以直接抢占锁资源
(s = h.next) != null && // head的next为null,可以直接抢占锁资源
!s.isShared() && // 如果排在head后面的Node,是共享锁,可以直接抢占锁资源。
s.thread != null; // 后面排队的thread为null,可以直接抢占锁资源
}
上面源码,我们忽略了读锁重入部分的分析,是因为这块会稍微复杂一点,我们先理解了上面的加锁流程,再往下接着去看读锁重入部分的逻辑,就会更加轻松。接下来,我们仔细分析一些读锁重入部分的逻辑。
六. ReentrantReadWriteLock对读锁重入优化
咱们知道读锁是同一时间允许多个线程同时对锁资源进行访问的,那么现在有个问题来了,如果多个读线程都同时访问,我们怎么记录各自线程的锁重入次数呢?我想这个答案很多人会想到ThreadLocal,没错,ReentrantReadWriteLock也正是这么做的。
ReentrantReadWriteLock在内部对ThreadLocal做了封装,基于HoldCount的对象存储重入次数,在内部有个count属性记录,而且每个线程都是自己的ThreadLocalHoldCounter,所以可以直接对内部的count进行++操作。
重入次数的流程执行方式:
1、判断当前线程是否是第一个拿到读锁资源的:如果是,直接将firstReader以及firstReaderHoldCount设置为当前线程的信息
2、判断当前线程是否是firstReader:如果是,直接对firstReaderHoldCount++即可。
3、跟firstReader没关系了,先获取cachedHoldCounter,判断是否是当前线程。
3.1、如果不是,获取当前线程的重入次数,将cachedHoldCounter设置为当前线程。
3.2、如果是,判断当前重入次数是否为0,重新设置当前线程的锁从入信息到readHolds(ThreadLocal)中,算是初始化操作,重入次数是0
3.3、前面两者最后都做count++。
接下来,我们首先看看如下结构的代码,务必要理解,读透下列每一个变量,方法和类的作用是什么,读懂了才能理解接下来的读锁加锁源码。
// 存储线程读锁重入次数和线程ID的对象
static final class HoldCounter {
int count = 0;
// Use id, not reference, to avoid garbage retention
final long tid = getThreadId(Thread.currentThread());
}
// 这个类继承了ThreadLocal,采用ThreadLocal去存储各自读线程的锁重入信息。
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
// 这个类存储了各个读线程的锁重入信息,很重要!!!!。
private transient ThreadLocalHoldCounter readHolds;
// 存储了最后一个持有读锁线程的锁重入信息
private transient HoldCounter cachedHoldCounter;
// 记录第一个持有读锁的线程
private transient Thread firstReader = null;
// 记录第一个持有读锁线程的锁重入信息
private transient int firstReaderHoldCount;
// 该构造方法会初始化ReentrantReadLock的读线程锁重入信息池以及state的初始值。
Sync() {
readHolds = new ThreadLocalHoldCounter();
setState(getState()); // ensures visibility of readHolds
}
读完上面的代码后,我们直接切入正题,全面的分析读锁的锁重入代码,如下。
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// ===============================================================
// 判断r == 0,当前是第一个拿到读锁资源的线程
if (r == 0) {
// 将firstReader设置为当前线程
firstReader = current;
// 将count设置为1
firstReaderHoldCount = 1;
}
// 判断当前线程是否是第一个获取读锁资源的线程
else if (firstReader == current) {
// 如果是第一个获取了读锁资源的线程,直接++。
firstReaderHoldCount++;
}
// 到这,就说明不是第一个获取读锁资源的线程
else {
// 那获取最后一个拿到读锁资源的线程
HoldCounter rh = cachedHoldCounter;
// 判断当前线程是否是最后一个拿到读锁资源的线程
if (rh == null || rh.tid != getThreadId(current))
// 如果不是,设置当前线程为cachedHoldCounter
// 注意:只要执行了readHolds.get()方法,rh就已经不可能为空了,它本身的初始化
方法会给它构造一个初始值出来。
cachedHoldCounter = rh = readHolds.get();
// 当前线程是之前的cacheHoldCounter
else if (rh.count == 0)
// 将当前的重入信息设置到ThreadLocal中
readHolds.set(rh);
// 重入的++
rh.count++;
}
// ===============================================================
return 1;
}
return fullTryAcquireShared(current);
}
读到这里,我们还差一个方法没有分析,就是读锁加锁的后续逻辑fullTryAcquireShared方法,这里我之所以单独拿出来强调,是因为这个代码的作者Douge Lee存在一个非常明显的代码习惯,如果看明白了,有利于我们读他写的java.util.concurrent并发线程源码。不管是ReentrantLock还是ReentrantReadWriteLock的加锁,他都是先写一个尝试加锁的方法,加锁失败,他都会再继续写一个逻辑几乎一模一样的方法,区别就是会死循环,保证在线程不中断的情况下,最终一定会获取到锁的逻辑。话不多说,我们接着读下面的代码。
// tryAcquireShard方法中,如果没有拿到锁资源,走这个方法,尝试再次获取,逻辑跟上面基本一致。
final int fullTryAcquireShared(Thread current) {
// 声明当前线程的锁重入次数
HoldCounter rh = null;
// 死循环
for (;;) {
// 再次拿到state
int c = getState();
// 当前如果有写锁在占用锁资源,并且不是当前线程,返回-1,走排队策略
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current)
return -1;
}
// 查看当前是否可以尝试竞争锁资源(公平锁和非公平锁的逻辑)
else if (readerShouldBlock()) {
// 无论公平还是非公平,只要进来,就代表要放到AQS队列中了,先做一波准备
// 在处理ThreadLocal的内存泄漏问题
if (firstReader == current) {
// 如果当前当前线程是之前的firstReader,什么都不用做
} else {
// 第一次进来是null。
if (rh == null) {
// 拿到最后一个获取读锁的线程
rh = cachedHoldCounter;
// 当前线程并不是cachedHoldCounter,没到拿到
if (rh == null || rh.tid != getThreadId(current)) {
// 从自己的ThreadLocal中拿到重入计数器
rh = readHolds.get();
// 如果计数器为0,说明之前没拿到过读锁资源
if (rh.count == 0)
// remove,避免内存泄漏
readHolds.remove();
}
}
// 前面处理完之后,直接返回-1
if (rh.count == 0)
return -1;
}
}
// 判断重入次数,是否超出阈值
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// CAS尝试获取锁资源
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
七. 读锁获取锁资源后的后续操作
这一块我也必须要单独拿出来讲,我前面说ReentrantReadWriteLock用于读多写少的场景,意味着大多数读线程都是用不到AQS队列,直接采用CAS即可。但我前面也讲过写线程的饥饿问题,只要存在一个写线程进入,那么意味着AQS依然会被启用,写线程后面的读线程会在AQS队列中进行排队。
因此,我们需要注意的是,当AQS队列中的写线程获取到资源并释放后,需要同时释放后面一连串的读线程任务的,而不是像之前ReentrantLock,每次唤醒后面一个有效的节点。接下来,我们来对这块的源码再进行一个详解。
// 读锁需要排队的操作
private void doAcquireShared(int arg) {
// 声明Node,类型是共享锁,并且扔到AQS中排队
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 拿到上一个节点
final Node p = node.predecessor();
// 如果prev节点是head,直接可以执行tryAcquireShared
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
// 拿到读锁资源后,需要做的后续处理
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
// 找到prev有效节点,将状态设置为-1,挂起当前线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
private void setHeadAndPropagate(Node node, int propagate) {
// 拿到head节点
Node h = head;
// 将当前节点设置为head节点
setHead(node);
// 第一个判断更多的是在信号量有处理JDK1.5 BUG的操作,此处学习的话,我们不需要关心这一点。
if (propagate > 0 || h == null || h.waitStatus < 0 || (h = head) == null || h.waitStatus < 0) {
// 拿到当前Node的next节点
Node s = node.next;
// 如果next节点是共享锁,直接唤醒next节点
if (s == null || s.isShared())
doReleaseShared();
}
}
八. 读锁的释放锁流程
读锁的释放锁流程相对而言就很简单了,我们只需要记住两点:1、处理重入以及state的值; 2、唤醒后续排队的Node,接下来,让我们再来看看它是怎么实现的;
// 读锁释放锁流程
public final boolean releaseShared(int arg) {
// tryReleaseShared:处理state的值,以及可重入的内容
if (tryReleaseShared(arg)) {
// AQS队列的事!
doReleaseShared();
return true;
}
return false;
}
// 1、 处理重入问题 2、 处理state
protected final boolean tryReleaseShared(int unused) {
// 拿到当前线程
Thread current = Thread.currentThread();
// 如果是firstReader,直接干活,不需要ThreadLocal
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
}
// 不是firstReader,从cachedHoldCounter以及ThreadLocal处理
else {
// 如果是cachedHoldCounter,正常--
HoldCounter rh = cachedHoldCounter;
// 如果不是cachedHoldCounter,从自己的ThreadLocal中拿
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
// 如果为1或者更小,当前线程就释放干净了,直接remove,避免value内存泄漏
if (count <= 1) {
readHolds.remove();
// 如果已经是0,没必要再unlock,扔个异常
if (count <= 0)
throw unmatchedUnlockException();
}
// -- 减一。
--rh.count;
}
for (;;) {
// 拿到state,高16位,-1,成功后,返回state是否为0
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
// 唤醒AQS中排队的线程
private void doReleaseShared() {
// 死循环
for (;;) {
// 拿到头
Node h = head;
// 说明有排队的
if (h != null && h != tail) {
// 拿到head的状态
int ws = h.waitStatus;
// 判断是否为 -1
if (ws == Node.SIGNAL) {
// 到这,说明后面有挂起的线程,先基于CAS将head的状态从-1,改为0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
// 唤醒后续节点
unparkSuccessor(h);
}
// 这里不是给读写锁准备的,在信号量里说。。。
else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
// 这里是出口
if (h == head)
break;
}
}
九. 结论
实际上对于ReentrantReadWriteLock的处理和ReentrantLock是十分类似的,不同的是,我们需要重点理解ReentrantReadWriteLock对state值的处理方式不同,以及要重点理解ReentrantReadWriteLock的读锁加锁流程,尤其是读锁锁重入这一块内容。下一节课,我会给大家详解java中常用阻塞队列的使用以及源码解析。