ReentrantLock具有完全互斥排他的效果,即同一时间只有一个线程在执行lock()方法后的任务,这就会出现一个弊端,比如在一些环境里多个线程都是读操作,没有涉及到数据的变更,那么多个读并发时效率就非常低;为了解决这种少写多读场景下的性能问题,JDK中设计了一种读写锁ReentrantReadWriteLock(可重入读写锁)。在读写锁中锁的策略有两种:公平策略和非公平策略。
1.ReentrantReadWriteLock类的内部结构
ReentrantReadWriteLock有五个内部类,五个内部类之间也是相互关联的。内部类的关系如下图所示。
如上图Sync继承自AQS、NonfairSync和FairSync继承自Sync类;ReadLock和WriteLock实现了Lock接口。在Sync中具体实现读写锁状态state的定义和分割,也是读写锁巧妙所在;同时Sync也继承了AQS,并重写了模版方法,如:tryAcquire(),tryRelease()等。
2.锁状态实现原理
虽然ReentrantReadWriteLock 和 ReentrantLock锁的状态都是使用AQS中的状态state,但读写锁的锁状态维护完全不同。在读写锁中既要维护读锁的状态,又要维护写锁的状态,那么读写锁是怎么用AQS状态state一个变量来维护两个状态的呢?
读写锁将AQS 中32位(int 类型)的状态state辦成两份,读锁用高16位,表示持有读锁的线程数(sharedCount);写锁低16位,表示写锁的重入次数 (exclusiveCount)。状态值为 0 表示锁空闲,sharedCount不为 0 表示分配了读锁,exclusiveCount 不为 0 表示分配了写锁。如下图同步锁状态state表示一个线程已经获取了写锁,且重进入了两次,同时也连续获取了两次读锁。
-
写锁状态维护:同一个线程重入一次,就在锁状态state的低16位加1操作,释放一次就减1操作;不同线程的写操作是互斥的(写锁也叫独占锁)。
-
读锁状态维护:多个线程可以同时获取读锁,每获取一次就在锁状态state的高16位加1操作,释放一次就减1操作,不同线程读操作不是互斥的(因此读锁也叫共享锁)。比如三个线程同时都获取到了读锁,那么锁状态高16位就是0011。
读写锁的互斥关系如下:只有读锁与读锁之间是兼容的,也就是说可以多个线程同时持有读锁。
读锁 | 写锁 | |
---|---|---|
读锁 | 兼容 | 互斥 |
写锁 | 互斥 | 互斥 |
3.源码分析
(1)ReentrantReadWriteLock构造函数
ReentrantReadWriteLock创建的时候可以指定锁竞争策略:公平竞争和非公平竞争。
-
锁公平竞争:严格按照FIFO的策略来过去锁状态。
-
非公平竞争:新进入的线程,不管AQS同步队列中是否有等待获取锁的线程,都是直接参加锁的竞争,先前AQS等待队列里的线程有可能获取不到锁。
//默认锁的竞争策略是非公平竞争
public ReentrantReadWriteLock() {
this(false);
}
//可以指定锁的竞争策略
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
(2)Sync源码
Sync中主要定义了读锁和写锁运算的规、线程持有的锁计数器、锁获取和释放、读写阻塞等方法。其中使用AQS里锁状态state的高低位来维护读锁、写锁状态的思想值得学习。
abstract static class Sync extends AbstractQueuedSynchronizer {
//读写锁位偏移大小
static final int SHARED_SHIFT = 16;
//由于读锁是高16位,每获取一次读锁高16位就加1,其实是状态值加SHARED_UNIT
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
//读锁和写锁允许获取的最大值次数
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
//独占锁(写锁)的mask位
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
//读锁占有次数获取
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
//写锁占有次数获取(重入次数)
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
//线程持有读锁计数器,每个线程特定的read持有计数。存放在Sync的ThreadLocal中
static final class HoldCounter {
//当前线程读锁获取次数
int count = 0;
//线程tid
final long tid = getThreadId(Thread.currentThread());
}
//当readHolds.get()获取当前读锁计数器时,如果没有则初始化读计数器
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
//重写ThreadLocal的初始化方法initialValue(),每次在get()如果没找到,就返回一个初始化对象HoldCounter
public HoldCounter initialValue() {
return new HoldCounter();
}
}
//保存所有线程的读锁计数器
private transient ThreadLocalHoldCounter readHolds;
//最近一个成功获取读锁的线程的计数。避免了同一个线程多次操作去ThreadLocal查找
private transient HoldCounter cachedHoldCounter;
//读锁状态从0改为1的线程,不会放入readHolds中
private transient Thread firstReader = null;
//firstReader的重入计数
private transient int firstReaderHoldCount;
//构造函数:初始化readHolds和锁状态
Sync() {
readHolds = new ThreadLocalHoldCounter();
setState(getState());//cas操作,加内存屏障,保证readHolds的可见性
}
//定义读、写锁获取时的阻塞策略,根据策略不同分别在NonfairSync和FairSync中实现
abstract boolean readerShouldBlock();
abstract boolean writerShouldBlock();
}
(3)独占锁的实现
1)独占锁加锁过程
独占锁ReentrantReadWriteLock.WriteLock中非公平策略的加锁lock()方法源码调用过程的时序图如下:
//独占锁lock调用的是AQS里的acquire方法
public void lock() {
sync.acquire(1);
}
//AQS中定义了模板方法tryAcquire,具体实现是在ReentrantReadWriteLock中
public final void acquire(int arg) {
//如果获取写锁失败,则把当前线程分装成node添加到AQS同步队列尾部
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire()源码
tryAcquire方法通过锁状态state 和 state的低16位来判断写锁能否获取。该方法除了当前线程重入条件之外,增加了一个读锁是否存在的判断。 只要读锁存在,则当前线程获取写失败;如果读锁没被占有,再判断当前线程是否是写锁重入;如果是重入锁则获取锁重构,否则使用CAS操作更新锁状态。
protected final boolean tryAcquire(int acquires) {
//获取当前线程
Thread current = Thread.currentThread();
int c = getState(); //获取锁状态state
//获取写锁状态,即取state的低16位
int w = exclusiveCount(c);
if (c != 0) {
//state不为0,说明有写锁或读锁被占用;w=0表示读锁被占用,则获取写锁失败(读写互斥);如果w!=0写锁被占用,再判断是否是重入
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//写锁状态不能操作最大值(1<<16 - 1)
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//重入写锁成功,更新state值
setState(c + acquires);
return true;
}
//当不是写锁重入时,通过writerShouldBlock(公平、非公平策略)判断是否要阻塞,不需阻塞时CAS更新锁状态
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//CAS更新锁成功,则获取锁成功,设置独占锁线程
setExclusiveOwnerThread(current);
return true;
}
这儿有个点需要注意,用writerShouldBlock()方法来判断当前线程是否需要被阻塞。那什么在情况下会阻塞当前线程,什么情况下不需要阻塞?先看看NonfairSync和FairSync实现的源码 。
//1.写锁-公平策略FairSync实现
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
//只要AQS同步队列里有等待线程、且等待线程不是当前线程时,获取锁阻塞
return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}
//2.写锁-非公平策略NonFairSync的实现
final boolean writerShouldBlock() {
return false; // writers can always barge
}
所以在判断写锁获取的是否需要阻塞时,公平策略 和 非公平策略实现的方式不一样:
-
公平写锁获取时,需要判断AQS同步队列里是否有等待获取锁的节点;如果没有,当前线程就不需阻塞;如果有,需要判断是否是锁重入情况。
-
非公平写锁获取时,不需要阻塞,直接参与写锁竞争。
两种方式阻塞策略是不一样的,例下图:线程A获取了写锁,AQS同步队列中线程B等待获取读锁、线程C等待获取写锁;当线程A释放写锁的时候,线程D请求一个写锁。如果是FairSync公平锁,线程D阻塞入队列;如果是NonFairSync非公平锁,线程D直接竞争写锁。
当tryAcquire获取锁失败后,跟ReentrantLock一样通过addWaiter()将线程分装成节点添加到AQS同步队列尾部,再通过acquireQueued()方法将线程阻塞,直到被唤起。
【注释:addWaiter()、acquireQueued()方法解析请参考:深入图解AQS实现原理和源码分析】
2)独占锁释放过程
独占锁ReentrantReadWriteLock.WriteLock中非公平策略的释放锁unlock()方法源码调用过程的时序图如下:
//writeLock调用的是AQS中的release()方法
public void unlock() {
sync.release(1);
}
//tryRelease()在AQS里是模板方法,具体实现是在Sync中
public final boolean release(int arg) {
if (tryRelease(arg)) {
//如果获取锁成功,唤醒head的后继节点
Node h = head;
if (h != null && h.waitStatus != 0)
//唤醒head后面处于等待状态的节点(这和ReentrantLock原理一样)
unparkSuccessor(h);
return true;
}
return false;
}
tryRelease()源码
在写锁释放时,会取state-1的低16位判断是否等于0;如果等于零表示写锁释放成功,其它线程可以获取读或写锁;如果不等于零,表示锁重入情况下释放一次写锁,其它线程不能获取读或写锁。
protected final boolean tryRelease(int releases) {
//判断写锁占有线程不是当前线程,就抛异常。
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//释放一次锁,state就减1操作
int nextc = getState() - releases;
//判断写锁是否被释放;取nextc的低16位如果等于0,释放锁成功;不等于0,表示重入锁情况下,释放一次锁
boolean free = exclusiveCount(nextc) == 0;
if (free)
//释放锁成功
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
(4)共享锁的实现过程
相对于写锁,读锁的获取流程更复杂一些;读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数,只能选择保存在ThreadLocal中,由线程自身维护,这使获取读锁的实现变得复杂。读锁的复杂性主要体现在:
-
(1)读读不互斥,多个线程可以同时获取读锁,所以维护读锁状态更复杂。
-
(2)写锁释放后唤醒AQS等待队列中的节点时,需要考虑到可以唤醒多个读锁线程。
1)共享锁加锁过程
共享锁ReentrantReadWriteLock.ReadLock中非公平策略的加锁lock()方法源码调用过程的时序图如下:
//readLock的lock方法调用的是AQS中的acquireShared()
public void lock() {
sync.acquireShared(1);
}
//AQS中定义了共享锁获取模板方法tryAcquireShared,具体实现是在ReentrantReadWriteLock中
public final void acquireShared(int arg) {
//尝试获取共享锁
if (tryAcquireShared(arg) < 0)
//共享锁获取失败,将当前线程封装成node添加到队列AQS尾部,同时阻塞当前线程
doAcquireShared(arg);
}
tryAcquireShared源码
共享锁获取时,会先判断是否有线程持有独占锁,如果有进一步判断独占锁是不是当前线程重入,此时持有写锁的线程,可以再获取读锁。当没有线程持有独占锁时,会进行CAS操作将state锁的高16位加1操作。
//尝试获取共享锁(读锁)
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)) { //c + SHARED_UNIT表示state锁高16位加1操作
// 成功获取读锁,判断是不是firstReader,计数不会放入readHolds
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
//firstReader 重入
firstReaderHoldCount++;
} else {
//非 firstReader 读锁重入计数更新
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
//从ThreadLocal中获取当前线程的计数器
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
//如果获取读锁失败,会进入自旋重试,会根据公平策略判断是否需要阻塞。自旋直到获取读锁或获取失败
return fullTryAcquireShared(current);
}
这儿有个点需要注意一下,readerShouldBlock()会根据不同的公平策略来决定需不需要阻塞当前线程。那什么在情况下会让当前读线程阻塞,什么情况下不需要阻塞?先看看NonfairSync和FairSync读阻塞策略实现的源码
//.FairSync公平策略:新来的线程获取读锁时,会判断AQS同步队列里的是否有其他等待的节点,如果有则添加到AQS尾部。
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
//2.NonFairSync非公平策略:新来的线程获取读锁时,会判断AQS等待队列中第一个等待线程是否是获取写锁,如果是先让写锁先执行,当前线程会添加到等待队列尾部。
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
//当AQS等待队列中第一个等待线程是获取写锁时,返回false。之所以这样处理一方面是为了效率,一方面是为了避免想获取写锁的线程饥饿,老是得不到执行的机会
return (h = head) != null &&
(s = h.next) != null &&
!s.isShared() &&
s.thread != null;
}
主要注意一点是:NonfairSync非公平策略,如果当前处于读锁状态,且等待队列中第一个等待线程是写锁,则当前线程不进行抢占读锁。这样处理一方面是为了效率,一方面是为了避免想获取写锁的线程饥饿,老是得不到执行的机会。
如下图线程A获取了读锁,AQS等待队列中线程B等待获取写锁、线程C等待获取读锁。此时新来了一个线程D获取读锁,线程D会被添加到队列的尾部,因为AQS等待队列中第一节点(线程)是写锁。
doAcquireShared()源码
如果线程获取读锁失败后,会进入doAcquireShared()方法将当前线程封装成node添加到队列AQS尾部,同时阻塞线程,直到线程被唤醒。
private void doAcquireShared(int arg) {
//跟ReentrantLock一样,将当前线程分装成node添加到AQS等待队列尾部
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
//如果node是等待队列中head.next(第二个节点)节点,则尝试获取读锁
if (p == head) {
int r = tryAcquireShared(arg);
//获取读锁成功后,唤醒后继节点
if (r >= 0) {
//这里是重点,获取到锁以后的唤醒操作,这个操作会唤醒多个读锁等待的node
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//线程挂起逻辑跟ReentrantLock一样,会移除一些取消状态的节点
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
} finally {
//当出现异常时,从等待队列中移除添加的节点,这个和独占锁一样
if (failed)
cancelAcquire(node);
}
}
【注释:shouldParkAfterFailedAcquire()方法解析请参考这篇文章:深入图解AQS实现原理和源码分析】
共享锁模式获取成功以后,调用了setHeadAndPropagate()方法,从方法名就可以看出除了设置新的头结点以外还有一个传递动作,一起看下代码:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; //记录当前头节点
//设置新的头节点,即把当前获取到锁的节点设置为头节点
//注:这里是获取到锁之后的操作,不需要并发控制
setHead(node);
//这里意思有两种情况是需要执行唤醒操作
//1.propagate > 0 表示调用方指明了后继节点需要被唤醒
//2.头节点后面的节点需要被唤醒(waitStatus<0),不论是老的头结点还是新的头结点
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
//如果当前节点的后继节点是共享类型获取没有后继节点,则进行唤醒
//这里可以理解为除非明确指明不需要唤醒(后继等待节点是独占类型),否则都要唤醒
if (s == null || s.isShared())
//醒操作,比较复杂
doReleaseShared();
}
}
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
//使用自旋直到唤醒后继节点为止
private void doReleaseShared() {
for (;;) {
//唤醒操作由头结点开始,注意这里的头节点已经是上面新设置的头结点了
//其实就是唤醒上面新获取到共享锁的节点的后继节点
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
//表示后继节点需要被唤醒
if (ws == Node.SIGNAL) {
//这里需要控制并发,因为入口有setHeadAndPropagate跟release两个,避免两次unpark
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
//执行唤醒操作
unparkSuccessor(h);
}
//如果后继节点暂时不需要唤醒,则把当前节点状态设置为PROPAGATE确保以后可以传递下去
else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
//如果头结点没有发生变化,表示设置完成,退出循环
//如果头结点发生变化,比如说其他线程获取到了锁,为了使自己的唤醒动作可以传递,必须进行重试
if (h == head)
break;
}
}
上面的setHeadAndPropagate()方法表示:唤醒同步队列中等待获取共享锁的节点,唤醒操作有一个传递动作。我们举个例子来说明这个传递过程。如图AQS同步队列里依次是线程B、C、D读锁等待、线程E写锁等待,线程A获取了独占锁(写锁)。当线程A释放锁后,唤醒线程B;因为线程B、C、D都是读锁等待,线程B在唤醒后会再唤醒线程C,C唤醒D,直到遇到写锁等待的。等到B、C、D线程读锁都释放后,最后才能唤醒E线程。
2)共享锁释放过程
共享锁ReentrantReadWriteLock.ReadLock中非公平策略的释放锁unLock()方法源码调用过程的时序图如下:
//1.读锁释放:调用AQS的releaseShared()释放共享锁
public void unlock() {
sync.releaseShared(1);
}
//2.尝试释放共享锁tryReleaseShared方法,是AQS里的模板方法具体实现在Sync中
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
//释放读锁成功,即当前线程不持有读锁时,唤醒后面的等待节点(前面已讲到)
doReleaseShared();
return true;
}
return false;
}
tryReleaseShared()源码
tryReleaseShared()释放锁时,会使用自旋 + CAS操作将读锁(锁状态state的高16位)减1,同时会维护当前线程的读锁计数器。当同一个线程同时占有写锁和读锁,tryReleaseShared释放读锁后返回false,不会唤醒队列里等待的节点。
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//当前线程是firstReader时,清理firstReader和重入计数次数
if (firstReader == current) {
if (firstReaderHoldCount == 1)
//清除firstReader
firstReader = null;
else
//firstReader读锁重入计算次数减1
firstReaderHoldCount--;
} else {
//非firstReader读计数器,从readHolds取出操作
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高16位)减1操作,直到CAS成功为止
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT; //锁状态state高16位减1操作
if (compareAndSetState(c, nextc))
return nextc == 0; //CAS成功,如果next不为0则是读锁重入或同一个线程获取了写和读锁
}
}
(5)锁降级
是指线程A获取了(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。从现象上看好像是线程A从写锁降级成为读锁,但实际上读、写锁的状态记录是相互独立的,不存在所谓的降级转换。
总结
-
1.当有线程占有了写锁,其它线程获取读锁或写锁会被阻塞,占有写锁的线程可以再获取读锁。
-
2.线程在已经获取了写锁情况下,同一个线程还可以重入读锁;反过来就不成立了。
-
3.公平及非公平这两种策略下,一般而言,非公平锁吞吐会比较大,所以默认情况下都是使用的非公平策略。
-
4.公平性锁保证了锁的获取按照FIFO原则,而代价是进行大量的线程切换。非公平性锁虽然可能造成线程“饥饿”,但极少的线程切换,保证了其更大的吞吐量。
2020年05月24日 于北京记