读写锁是一种特殊的锁,它把对共享资源的访问分为读访问和写访问,多个线程可以同时对共享资源进行读访问,但是同一时间只能有一个线程对共享资源进行写访问,使用读写锁可以极大地提高并发量。读写锁实际维护了一对锁,一个读锁,一个写锁,通过分离读锁和写锁,使得其并发性比独占式锁(排他锁)有了很大的提升。
为什么需要读写锁?
通过前面文章的学习,我们知道了ReentrantLock(下文简称:RLock)对象了。Rlock比起synchronized(下文简称Sync)来说有三个优点:RLock可以被中断;RLock可以有公平锁;RLock可以绑定多个条件。那么既然RLock比Sync有这么多优点,为什么还需要读写锁呢?那是因为RLock是独占式(排他) 锁,即当线程1获取到资源的时候,其他线程不能再来操作共享资源了。就算是RLock的操作是读取的时候,其他线程也不能读取共享资源的操作。这在现实生活中是不符合逻辑的,而且性能也比较慢。所以就有了读写锁的出现。
案例:我们在玩王者荣耀的时候,有时候会遇到停服更新的。在不更新前,所有玩家都可以玩,当停服更新的时候,所有玩家就不能玩了。这个操作在并发角度来说:千千万万的玩家是读共享资源的;游戏维护者是写操作的。当停服更新的时候,读操作就被阻塞了,只能等写操作,也就是更新完成后,才可以接着玩。
1、继承关系
ReentrantReadWriteLock中的可以分成三个部分:
- ReentrantReadWriteLock本身实现了ReadWriteLock接口
- 同步器,包含一个继承了AQS的Sync内部类,以及其两个子类FairSync和NonfairSync;
- ReadLock和WriteLock两个内部类实现了Lock接口,它们具有锁的一些特性。
2、属性介绍
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
维护了读锁、写锁和同步器。
3、主要构造方法
// 默认构造方法
public ReentrantReadWriteLock() {
this(false);
}
// 是否使用公平锁的构造方法
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
它提供了两个构造方法,默认构造方法使用的是非公平锁模式,在构造方法中初始化了读锁和写锁。
4、获取读锁和写锁的方法
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
属性中的读锁和写锁是私有属性,通过这两个方法暴露出去。
下面我们主要分析读锁和写锁的加锁、解锁方法,且都是基于非公平模式的。
5、 操作方法
5.1 ReadLock.lock()
public void lock() {
sync.acquireShared(1);
}
获取读取锁定。如果另一个线程未持有写锁,则获取读锁,并立即返回。如果写锁由另一个线程持有,则当前线程出于线程调度目的而被禁用,并且在获取读锁之前一直处于休眠状态。
public final void acquireShared(int arg) {
//尝试获取共享锁(返回1表示成功,返回-1表示失败)
if (tryAcquireShared(arg) < 0)
// 失败了就可能要排队
doAcquireShared(arg);
}
以共享模式获取,忽略中断。首先通过至少调用一次{tryAcquireShared}来实现,然后成功返回。否则,线程将排队,有可能反复阻止和解除阻止,调用{tryAcquireShared}直到成功。
// ReentrantReadWriteLock.Sync.tryAcquireShared()
protected final int tryAcquireShared(int unused) {
// 状态变量的值
// 在读写锁模式下,高16位存储的是共享锁(读锁)被获取的次数,低16位存储的是互斥锁(写锁)被获取的次数
Thread current = Thread.currentThread();
// 互斥锁的次数
int c = getState();
// 如果其它线程获得了写锁,直接返回-1
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 读锁被获取的次数
int r = sharedCount(c);
// 下面说明此时还没有写锁,尝试去更新state的值获取读锁
// 读者是否需要排队(是否是公平模式)
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// 获取读锁成功
if (r == 0) {
// 如果之前还没有线程获取读锁
// 记录第一个读者为当前线程
firstReader = current;
// 第一个读者重入的次数为1
firstReaderHoldCount = 1;
} else if (firstReader == current) {
//如果有线程获取了读锁且是当前线程是第一个读者
//则把其重入次数加1
firstReaderHoldCount++;
} else {
//如果有线程获取了读锁且当前线程不是第一个读者
// 再从ThreadLocal中获取
HoldCounter rh = cachedHoldCounter;
//readHolds本身是一个ThreadLocal,里面存储的是HoldCounter
if (rh == null || rh.tid != getThreadId(current))
//get()的时候会初始化rh
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
//如果rh的次数为0,把它放到ThreadLocal中去
readHolds.set(rh);
rh.count++;
}
// 获取读锁成功,返回1
return 1;
}
// 通过这个方法再去尝试获取读锁(如果之前其它线程获取了写锁,一样返回-1表示失败)
return fullTryAcquireShared(current);
}
- 如果另一个线程持有写锁定,则失败。
- 否则,此线程符合锁定wrt状态,因此请问是否由于队列策略而应阻止。如果不是,请尝试按CASing状态授予许可并更新计数。 请注意,此步骤不会检查可重入的内容被推迟到完整版本以避免在更典型的不可重入的情况下检查保留计数。
- 如果第2步失败,或者由于线程显然不合格或者CAS失败或计数饱和,请使用完全重试循环链接到版本。
private void doAcquireShared(int arg) {
//进入AQS队列中
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
//当前节点的前一个节点
final Node p = node.predecessor();
//如果前一个节点时头节点(说明是第一个排队的节点)
if (p == head) {
//再次尝试获取锁
int r = tryAcquireShared(arg);
if (r >= 0) {
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
总结:
- 先尝试获取读锁
- 如果成功了就直接结束;
- 如果失败了,就进入doAcquireShared()方法
- doAcquireShared()方法中首先会生成一个新的节点并进入AQS队列中;
- 如果头节点正好是当前节点的上一个节点,再次尝试获取锁;
- 如果成功了,则设置头节点为新节点,并传播;
- 传播即唤醒下一个读节点(如果下一个节点时读节点的话);
- 如果头节点不是当前节点的上一个节点或者5失败,则阻塞当前线程被唤醒
- 唤醒之后继续走5的逻辑
在整个逻辑中是在哪里连续唤醒读节点的呢?
答案是在doAcquireShared()方法中,在这里一个节点A获取了读锁后,会唤醒下一个读节点B,这时候B也会获取读锁,然后B继续唤醒C,依次往复,也就是说这里的节点是一个唤醒一个这样的形式,而不是一个节点获取了读锁后一次性唤醒后面所有的读节点。
那我们看看读锁的解锁。
5.2 ReadLock.unlock()
// java.util.concurrent.locks.ReentrantReadWriteLock.ReadLock.unlock
public void unlock() {
sync.releaseShared(1);
}
// java.util.concurrent.locks.AbstractQueuedSynchronizer.releaseShared
public final boolean releaseShared(int arg) {
// 如果尝试释放成功了,就唤醒下一个节点
if (tryReleaseShared(arg)) {
// 这个方法实际是唤醒下一个节点
doReleaseShared();
return true;
}
return false;
}
// java.util.concurrent.locks.ReentrantReadWriteLock.Sync.tryReleaseShared
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
if (firstReader == current) {
// 如果第一个读者(读线程)是当前线程
// 就把它重入的次数减1
// 如果减到0了就把第一个读者置为空
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--;
} else {
// 如果第一个读者不是当前线程
// 一样地,把它重入的次数减1
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;
}
for (; ; ) {
// 共享锁获取的次数减1
// 如果减为0了说明完全释放了,才返回true
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}
// java.util.concurrent.locks.AbstractQueuedSynchronizer.doReleaseShared
// 行为跟方法名有点不符,实际是唤醒下一个节点
private void doReleaseShared() {
for (; ; ) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
// 如果头节点状态为SIGNAL,说明要唤醒下一个节点
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
// 唤醒下一个节点
unparkSuccessor(h);
} else if (ws == 0 &&
// 把头节点的状态改为PROPAGATE成功才会跳到下面的if
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
// 如果唤醒后head没变,则跳出循环
if (h == head) // loop if head changed
break;
}
}
解锁的流程如下:
- 将当前线程重入次数减1;
- 将共享锁总共被获取次数减1;
- 如果共享锁获取次数减为0了,说明共享锁完全被释放了,那就唤醒下一个节点。
如下图:ABC三个节点个获取了一次共享锁,三者释放的顺序分别为ACB,那么最后B释放共享锁的时候tryReleaseShared()才会返回true,进而才会唤醒下一个节点D。
5.3 WriteLock.lock()
public void lock() {
sync.acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 状态变量state的值
int c = getState();
// 互斥锁被获取的次数
int w = exclusiveCount(c);
if (c != 0) {
// 如果c!=0且w==0,说明共享锁被获取的次数不为0
// 这句话整个的意思就是
// 如果共享锁被获取的次数不为0,或者被其它线程获取了互斥锁(写锁)
// 那么就返回false,获取写锁失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 溢出检测
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 到这里说明当前线程已经获取过写锁,这里是重入了,直接把state加1即可
setState(c + acquires);
// 获取写锁成功
return true;
}
// 如果c等于0,就尝试更新state的值(非公平模式writerShouldBlock()返回false)
// 如果失败了,说明获取写锁失败,返回false
// 如果成功了,说明获取写锁成功,把自己设置为占有者,并返回true
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
// 获取写锁失败了后面的逻辑跟ReentrantLock是一致的,进入队列排队,这里就不列源码了
写锁获取的过程如下:
- 尝试获取锁;
- 如果有读者战友这读锁,城市获取写锁失败;
- 如果有其他线程占着写锁,尝试获取锁失败
- 如果当前线程战友写锁,尝试获取写锁成功,state加一
- 如果没有线程占有着锁当前线程尝试更新state,成功了表示获取锁成功,反之失败;
- 尝试获取锁失败以后,进入队列排队,等待唤醒;
- 后续逻辑跟ReentrantLock是一致。
5.4 WriteLock.unlock()
public void unlock() {
sync.release(1);
}
//java.util.concurrent.locks.AbstractQueuedSynchronizer.release()
public final boolean release(int arg) {
// 如果尝试释放锁成功(完全释放锁)
// 就尝试唤醒下一个节点
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
// java.util.concurrent.locks.ReentrantReadWriteLock.Sync.tryRelease()
protected final boolean tryRelease(int releases) {
// 如果写锁不是当前线程占有着,抛出异常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
// 状态变量的值减1
int nextc = getState() - releases;
// 是否完全释放锁
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
// 设置状态变量的值
setState(nextc);
// 如果完全释放了写锁,返回true
return free;
}
写锁释放的过程大致为:
- 先尝试释放锁;
- 如果state减为0了,说明完全释放锁;
- 完全释放锁,则唤醒下一个等待的节点;
6、总结
- ReentrantReadWriteLock采用读写锁的思想,能提高并发的吞吐量;
- 读锁使用的是共享锁,多个读锁可以一起获取锁,互相不会影响,即读读不互斥;
- 读写、写读和写写是会互斥的,前者占有着锁,后者需要进入AQS队列中排队;
- 多个连续的读线程是一个接着一个被唤醒的,而不是一次性唤醒所有读线程;
- 只有多个读锁都完全释放了才会唤醒下一个写线程;
- 只有写锁完全释放了才会唤醒下一个等待者,这个等待者有可能是读线程,也可能是写线程;