本文基于corretto-17.0.9源码,参考本文时请打开相应的源码对照,否则你会不知道我在说什么
ReentrantReadWriteLock是可重入读写锁,所谓可重入锁指的是占有锁的线程继续在这个锁上调用lock直接加锁成功,当然,lock与unlock的调用次数最终数量要相等,否则不会释放锁。而不可重入锁则是lock成功后再lock就会被阻塞。而读写锁分为读锁和写锁,读锁是共享的,写锁是互斥的,可以用一张表来表示:
第二个线程此时要获取读锁 | 第二个线程此时要获取写锁 | |
---|---|---|
第一个线程先占有读锁 | 成功 | 失败 |
第一个线程先占有写锁 | 失败 | 失败 |
在读多写少的场景下,相ReentrantReadWriteLock比普通的ReentrantLock具有更高的并发量。
ReentrantLock基于AQS(AbstractQueuedSynchronizer),强烈建议先学习AQS,详情请见AbstractQueuedSynchronizer源码阅读
还是用Counter做例子,懒得开线程了。
class Counter {
private final ReadWriteLock lock = new ReentrantReadWriteLock();
private int count;
public void addCount(int n) {
lock.writeLock().lock();
try {
count += n;
} finally {
lock.writeLock().unlock();
}
}
public int readCount() {
lock.readLock().lock();
try {
return count;
} finally {
lock.readLock().unlock();
}
}
}
写共享变量前开写锁,读共享变量前开读锁。
其次,ReentrantReadWriteLock作为读写锁,还支持锁降级机制:首先写锁肯定比读锁高级,加写锁后既能写也能读,加读锁只能读(如果你代码规范的话,应该就是这么设计的),因此锁降级机制听起来是写锁变成读锁,是的,不过这个解释不够准确,应该解释为:当前线程在持有写锁的情况下,可以成功获取读锁。来看另一个例子:
public void processData() {
readLock.lock();
if (!update) {
readLock.unlock();
writeLock.lock();
try {
if (!update) {
// 在这里准备数据,即写数据
update = true;
}
readLock.lock();
} finally {
// 准备数据完成,释放写锁,相当于降级为读锁
writeLock.unlock();
}
}
try {
// 这里读数据
} finally {
readLock.unlock();
}
}
在这个例子中,processData负责更新数据,也负责读更新后的数据,当其检测到需要更新数据时(此时已经加上读锁),先释放读锁然后加上写锁去更新数据(因为不支持锁升级,所以要先释放读锁再加写锁),更新完之后只需要读数据,因此加读锁然后释放写锁(注意要先加读锁再释放写锁),让其他的线程也能读数据。
关于锁降级/升级需要注意两点:
疑问点在于,为什么需要有这个锁降级的特性?首先抛开什么升级降级这种高级词汇来看待,这个过程其实是很自然的:加写锁后既能写也能读,加读锁只能读,因此对于同一个线程来说持有写锁的情况下(能写能读)自然也能成功加上读锁(能读)。其次,先释放写锁再加读锁的不也一样吗?这个就看业务逻辑了,就上面的代码来说,当前线程在更新完数据后去读数据的过程中,希望看到的数据是自己刚刚更新完的数据,如果先释放写锁再加读锁的话,可能会有其他线程加上写锁并修改数据,然后当前线程再去读的数据就相当于被修改了。因此锁降级主要是为了保证数据的可见性(you will read what you write just before)。
与ReentrantLock类似,ReentrantReadWriteLock也是基于AQS实现的,里面很多方法都是直接写一行sync.XXX
,源码主要集中在他的内部类,因此我们直接分析它的内部类即可。
首先ReentrantReadWriteLock继承了ReadWriteLock接口,这个接口只有两个方法:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
这个接口返回读锁和写锁,结合上面的例子可以知道,ReentrantReadWriteLock本身并不是一个Lock,而是负责将两种Lock封装起来协同工作。然后最重要的就是AQS了,作为实现锁的同步器工具,在这里是继承于AQS的Sync类。两个锁依赖于同步器进行协同工作:
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
其实想想AQS的原理,就知道这两个Lock必定是依赖于同一个Sync,相当于依赖同一个等待队列。基于这点,我们接下来就先分析私有静态内部类Sync,以及分别对应公平同步器和非公平同步器的两个子类NonFairSync和FairSync。
Sync同步器类继承于AQS,并且最核心的是state状态变量的含义,state的含义是由子类Sync来定义的,类似Mutex将state=0定义成锁空闲,state=1定义成锁占有;ReentrantLock将state定义成重入次数,state=0代表锁空闲(重入次数为0)。同样的,根据ReentrantReadWriteLock所要实现的功能,也为state定义了一套规则:
解释一下为什么这样定义state:state是int类型,在Java中int类型已经明确是32位的。由于读锁和写锁是互斥的,所以两者不能共用一个计数器。综上可知,分别用state的高16和低16位作为两个计数器。另外,读锁之间并不互斥,因此高16位统计的是 所有读锁 的重入次数总和。综上,读写重入数的时候需要用到位运算,相关的位运算如下:
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // state += SHARED_UNIT相当于读锁重入数+1
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 读/写最大重入数
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // state & EXCLUSIVE_MASK得到低16位,即写重入数
static int sharedCount(int c) { return c >>> SHARED_SHIFT; } // 返回读重入数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; } // 返回写重入数
说个题外话,我在看源码之前,在脑海里尝试过根据可重入读写锁的功能,将state定义成:负数为写重入数,正数为读重入数。而且这样一来,最大重入数将比16位可表示的范围大。但由于锁降级的机制,这样是不可行的,因为锁降级规定当前线程在持有写锁的情况下,可以成功获取读锁…这样一来读和写的重入数都不为0,因此我的弱智方案直接over。
lock count指的是所有线程重入次数总和,hold count指的是当前线程的重入次数。由于写锁是独占锁,因此其lock count等于hold count。而读锁之间是共享锁,因此其lock count等于所有读锁的hold count总和。综上:
HoldCounter是HoldCounter的静态内部类,用于记录单个线程对应的重入数:
static final class HoldCounter {
int count; // initially 0
// Use id, not reference, to avoid garbage retention
final long tid = LockSupport.getThreadId(Thread.currentThread());
}
这个类很简单,只是用来记录单个线程的重入次数,保存在count成员变量中,用于在释放读锁时检查该线程是否拥有读锁。tid代表对应的线程id。至于为什么不直接引用线程而是记录tid,在注释里已经解释了。
因为可能有多个线程获得了读锁,而state只能记录所有线程的总重入数,因此额外设置这个HoldCounter用来记录单个线程对应的重入数。当谈及“每个线程对应的xxx计数”时,自然就会想到用ThreadLocal来实现这个功能。ThreadLocal用于为每个线程维护独立的对象,实现线程之间的资源隔离,这里所谓的对象就是HoldCount。(不了解的先看一下「博客园」ThreadLocal源码阅读)。
static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
private transient ThreadLocalHoldCounter readHolds;
Sync.readHolds保存了该ThreadLocal。
// 保存最后一个获取到读锁的线程的HoldCounter。因为从ThreadLocal获取有一定开销,而释放锁的线程往往是最近获取到锁的那个线程(命中cachedHoldCounter的可能性大),因此缓存下这个HoldCounter以提高性能
private transient HoldCounter cachedHoldCounter;
// 保存自从锁空闲后第一个获得读锁的线程。用于快速判断当前线程是否已经获得锁,或者快速获取当前线程重入数(如果当前线程就是第一个获得读锁的线程)
private transient Thread firstReader;
// 保存第一个获得读锁的线程对应的重入数
private transient int firstReaderHoldCount;
需要注意这是唯一存了firstReader的重入数的地方,firstReader没有对应的HoldCounter保存在ThreadLocal中。
成员变量都已经说完了,接下来就开始分析Sync类的方法。由于要同时支持读共享锁以及写独占锁,因此Sync要实现AQS的全部五个方法:tryAcquire
, tryRelease
, trySharedAcquire
, trySharedRelease
, isHeldExclusively
。另外还提供了其他的一些方法比如tryWriteLock
, tryReadLock
等,提供给外部实现相应功能。
除了以上方法,还有两个抽象方法:
abstract boolean readerShouldBlock();
abstract boolean writerShouldBlock();
由于ReentrantReadWriteLock支持公平和非公平锁,在尝试加锁之前,公平与否会使得加锁的行为不一样,比如在公平锁的情况下,如果当前等待队列存在等待的线程,那么新来的线程就不能直接尝试加锁,而是让他进入等待队列排队。因此这两个方法返回的结果就是:是否应该进入等待队列等候,而不是直接尝试加读/写锁。
ok,下面开始逐个分析Sync的核心方法。
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
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");
// 增加写锁重入数
setState(c + acquires);
return true;
}
// 检查是否可以直接加锁,然后尝试加锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
// 加锁成功
setExclusiveOwnerThread(current);
return true;
}
tryAcquire功能是尝试获取独占锁(写锁)。有了前面的说明铺垫,这里可以发现整个流程其实十分自然:
protected final boolean tryRelease(int releases) {
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
tryRelease功能是尝试释放独占锁(写锁)。流程如下:
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);
// 检查是否能尝试加锁、读重入数是否达到限制、是否CAS加锁成功
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// 到这里已经加锁成功
if (r == 0) { // 如果是首次加读锁的线程,那么单独维护更新其重入数
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current)) // 单独维护最近加读锁线程的可重入数
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0) // 复用HoldCounter,避免每次刚加上锁都用readHolds.get()来创建新的HoldCounter
readHolds.set(rh);
rh.count++; // 更新该读线程的可重入数
}
return 1;
}
// 加锁不成功,进入fullTryAcquireShared
return fullTryAcquireShared(current);
}
tryAcquireShared的功能是尝试获取共享锁(读锁)。可以看到共享相关的操作比独占的稍微复杂了些,因为涉及到之前说的HolderCount、ThreadLocal等,还调用了一个名为fullTryAcquireShared
的函数,其实也没多复杂。先看一下流程:
接下来再来看fullTryAcquireShared做了什么:
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current) // 如果其他线程占有共享锁,则失败
return -1;
// 如果运行到这里,表示当前线程占有写锁,由于锁降级机制的存在,是可以获取读锁的,因此不该返回失败
} else if (readerShouldBlock()) { // 检查是否能直接尝试加读锁
if (firstReader == current) {
// 这个分支用firstReader快速判断是否重入读:如果记录的第一个获得锁的线程就是当前线程,重入读肯定会成功,继续自旋获取锁
} else {
// 检查当前线程的重入数
// 如果重入数是0,说明不是重入读,而且此时readerShouldBlock是返回true的,因此返回失败
// 如果重入数不是0,那就是重入读,肯定要成功的,继续自旋获取锁
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 自旋获取锁,与tryAcquireShared中CAS后的流程基本差不多,就是常规的设置firstReader、ThreadLocal、cacheHolderCounter
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 != LockSupport.getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
所以为什么需要这个函数呢,看着跟tryAcquire很多重合的地方啊。首先在以下两种情况下都应该能成功获得读锁:
而在tryAcquire函数中,当readerShouldBlock返回true或者CAS失败时不会进入尝试获取锁的步骤,因此tryAcquire是不完整的,只能代表多数的情况(即并发量小的情况下,readerShouldBlock大多情况会返回false,并且CAS几乎不会失败),因此tryAcquire属于fast-path,而fullTryAcquireShared则属于slow-path(比如它还得查询HoldCount),实现了在上面两种规则下一定能获得锁的功能。
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) { // 用firstReader快速判断当前线程是否已经持有锁
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else { // 获取重入数,判断是否重复释放锁。最后更新重入数
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count;
}
// 更新AQS.state,因为是共享锁,由于并发CAS可能失败,因此for循环重试
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
tryReleaseShared的功能是释放共享锁。这部分比较简单,直接看注释就行。
重复提一嘴,firstReader是自从锁空闲以来第一个获取读锁的线程,用于快速判断是否当前线程(避免访问ThreadLocal的开销),如果当前线程完全释放了锁后,firstReader要置空。以及cachedHoldCounter在之前变量使用说明中也提到过,也是用于避免访问ThreadLocal的开销。
还有一个有意思的点就是tryAcquireShared和tryReleaseShared的参数都是unused的,代码中确实也没有使用它,而独占模式的tryAcquire和tryRelease却使用了。其实加读锁和加写锁传入的参数都只会是1,什么时候会传入不是1呢?使用条件变量的时候。比如当前重入数为n,并且在条件变量上await的话,release要传入n,表明线程完全释放锁,重入数直接减到0,并且在唤醒尝试获得锁时要恢复重入数,acquire也需要传入n。而共享锁不支持条件变量(其原因我在AQS源码阅读一文中有提到),因此共享锁的acquire和release传入的一定就是1,可以不用这个参数。
final boolean tryWriteLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c != 0) {
int w = exclusiveCount(c);
// 检查其他线程是否占有写锁
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 检查是否超出最大重入数
if (w == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
}
// CAS尝试加锁
if (!compareAndSetState(c, c + 1))
return false;
// 加锁成功
setExclusiveOwnerThread(current);
return true;
}
tryWriteLock的功能是尝试加写锁,功能上来说与tryAcquire一样,但不考虑writerShouldBlock,即直接是非公平的。整体比较简单,看注释就行。
final boolean tryReadLock() {
Thread current = Thread.currentThread();
for (;;) {
int c = getState();
// 检查其他线程是否占有写锁
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return false;
int r = sharedCount(c);
// 检查是否超出最大重入数
if (r == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// CAS尝试加锁
if (compareAndSetState(c, c + SHARED_UNIT)) {
// 加锁成功
// 下面就是常规的设置firstReader、cachedHoldCounter、ThreadLocal
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return true;
}
}
}
tryReadLock的功能是尝试加读锁,功能上来说与tryAcquireShared一样,但不考虑readerShouldBlock,即直接是非公平的。类似fullTryAcquireShared,由于CAS可能失败,因此需要for循环自旋。
为了实现读写锁的功能,Sync定义了state的高低16位分别作为读锁和写锁重入数,并且整个加锁和解锁过程都是围绕state来进行的。复杂点在于共享锁的实现,因为涉及到CAS可能加锁失败、ThreadLocal分别保存每个线程的重入数、firstReader和cachedHoldCounter的维护以加速重入数的获取。不过复杂之处也就止步于此,原理上还是比较简单的。至于锁降级机制,实现起来也没什么特别的技巧,只需要判断如果是当前线程获取了写锁,继续去执行正常的获取读锁流程就行了。
公平与否的区别是尝试加锁之前是否检查等待队列中有比当前线程等待时间更久的线程,并且Sync类通过模板模式(先在抽象类定义抽象方法,以供抽象类的其他方法调用,抽象方法的实现下放给子类来做)定义了抽象方法xxxShouldBlock
交给子类实现,以区分公平与非公平。
static final class NonfairSync extends Sync {
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
非公平同步器很简单,无论如何都直接尝试获得锁,,因此两个shouldBlock都应该返回false表示可以直接去尝试获得锁。但可以发现readerShouldBlock
没有直接返回false,而是返回apparentlyFirstQueuedIsExclusive(),这个函数是AQS提供的,来看看这个函数做了什么:
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null && (s = h.next) != null &&
!(s instanceof SharedNode) && s.waiter != null;
}
apparentlyFirstQueuedIsExclusive功能是返回当前的AQS等待队列队头是不是writer(非共享节点)。虽然是非公平的锁,但由于读锁是共享锁,源源不断地有新来的读者的概率还是比较高的,如果此时有写者等待,那么它很大概率永远得不到锁(饿死)。当然了,并不是说有了这个方法后,写者就不会被饿死,因为还得看业务设计者是否使用得当。
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
公平锁就不用说了,hasQueuedPredecessors是AQS提供的方法,在ReentrantLock源码阅读的一文已经介绍过,这里再介绍一遍:这个函数在以下两种情况下返回true:
这两种情况下都说明有比当前线程等待时间更久的线程,因此获取锁失败,以实现强公平。
public static class ReadLock implements Lock, java.io.Serializable {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquireShared(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean tryLock() {
return sync.tryReadLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.releaseShared(1);
}
public Condition newCondition() {
throw new UnsupportedOperationException();
}
}
ReadLock顾名思义读锁,通过将Sync同步器以构造函数的方式传入,然后借助Sync实现Lock接口的方法,看完Sync类的分析甚至是看完AQS的分析之后,这个类其实已经没有必要再看了,让你自己写也能写出来。需要注意点只有,读锁不支持共享变量,具体原因我在AQS一文的末尾有提到,因此直接抛UnsupportedOperationException异常。
public static class WriteLock implements Lock, java.io.Serializable {
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquire(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock() {
return sync.tryWriteLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.release(1);
}
public Condition newCondition() {
return sync.newCondition();
}
public boolean isHeldByCurrentThread() {
return sync.isHeldExclusively();
}
public int getHoldCount() {
return sync.getWriteHoldCount();
}
}
WriteLock写锁也是如出一辙,借助Sync实现Lock接口的方法,没什么可分析的。
核心方法只有ReadWriteLock接口的那两个方法:
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
其他的方法从实现上来说都是借助Sync,用两三行就能搞定,这些其他方法从功能上来说更多是用于debug或监控,比如打印一下锁目前的某些状态之类的,没什么实际效果,因此就不讲了。
AQS是个强大的类,学好AQS,没有你看不懂的锁。
「博客园」AbstractQueuedSynchronizer源码阅读
「博客园」ThreadLocal源码阅读
「博客园」全网最详细的ReentrantReadWriteLock源码剖析(万字长文)
「Java全栈知识体系」JUC锁: ReentrantReadWriteLock详解