前言
上一篇讲解了 AQS 的独占锁部分(参看:ReentrantLock 源码分析以及 AQS (一)),这一篇将介绍 AQS 的共享锁,以及基于共享锁实现读写锁分离的 ReentrantReadWriteLock。(若是遇到之前讲过的方法,将不再赘述)
先思考一下,为什么我们用读写锁分离?
我们知道 ReentrantLock 用的是独占锁,不管线程是读还是写状态,都会阻塞,这无疑会降低并发量。
但是,我们知道多个线程同时去读数据的时候,并不会产生线程安全的问题,因为它们互不干扰。那么为什么不设计一种方案,让所有的读线程可以共享,一起同时读数据呢,只需要阻塞写的线程就可以了。提高并发的同时,也不会产生数据不一致的现象。
同样的,如果有线程在写数据,那么也会阻塞其它读线程(同样阻塞其它写线程),数据写完之后才可以读数据,这样保证读到的数据都是最新的。
因此,我们可以用读、写两把锁,分别控制数据的读和写。实现读读共享、读写互斥,写写互斥。这也是 ReentrantReadWriteLock 读写分离锁的由来。它非常适合用在读多写少的场景。
ReentrantReadWriteLock
它和 ReentrantLock 一样,也是一个可重入的锁,并基于 AQS 共享锁实现了读写分离。其内部结构也大同小异,支持公平锁和非公平锁。我们看下它的构造函数,
public ReentrantReadWriteLock() {
//默认非公平
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
它定义了两个内部类来表示读锁和写锁,并且都通过内部类 Sync 来实现加锁,释放锁等功能。
public static class ReadLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -5992448646407690164L;
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
...
}
public static class WriteLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -4992448646407690164L;
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
...
}
abstract static class Sync extends AbstractQueuedSynchronizer {
}
我们再看下公平锁和非公平锁,其中有两个比较重要的方法,用来判断读锁和写锁是否应该被阻塞,后面加锁的时候会用到(其实,实际情况是否真的应该阻塞,还需要斟酌,后面会说)。
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
//公平锁的读和写都需要判断,在它前面是否已经有线程在等待。
//有的话,当前线程就需要阻塞,这也体现了公平性。
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
//非公平锁,写的时候不需要阻塞,直接返回false
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
//为了避免写线程饥饿,需要判断同步队列中第一个排队的(head.next)是否是独占锁(写线程)
//如果是的话,当前读线程就需要阻塞,这是 AQS 中的方法
return apparentlyFirstQueuedIsExclusive();
}
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null &&
(s = h.next) != null &&
!s.isShared() &&
s.thread != null;
}
思考:
我们知道 ReentrantLock 的同步状态和重入次数,是直接用 state 值来表示的。那么,现在我需要读和写两把锁,怎么才能用一个 int 类型的值来表示两把锁的状态呢?并且,锁是可重入的,重入的次数怎么记录呢?
别急,下面一个一个说。
怎么用一个 state 值表示读、写两把锁?
state 是一个 32 位的 int 值,读写锁中,把它一分为二,高 16 位用来表示读状态,其值代表读锁的线程数,如图中为 3 个,低 16位表示写状态,其值代表写锁的重入次数(因为是独占锁)。 这样,就可以分别计算读锁和写锁的个数了。其相关的属性和方法定义在 Sync 类中。
static final int SHARED_SHIFT = 16;
//表明读锁每增加一个,state的实际值增加 2^16
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
//写锁的最大重入次数,读锁的最大个数
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
//持有读锁的线程个数,参数如的 c 代表 state值
//state 的32位二进制位,无符号右移 16位之后,其实就是高16位的值
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
//写锁数量,即写锁的重入次数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
读锁的个数计算比较简单,直接无符号右移 16 位即可。我们看下写锁的重入次数是怎么计算的。先看下 EXCLUSIVE_MASK 这个值,是 (1 << 16) - 1,我们用二进制表示计算过程为:
// 1的二进制
0000 0000 0000 0000 0000 0000 0000 0001
// 1左移 16位
0000 0000 0000 0001 0000 0000 0000 0000
//再减 1
0000 0000 0000 0000 1111 1111 1111 1111
//任何一个 32位二进制数 c,和以上值做 “与” 运算都为它本身 c 的低 16 位值
//这个不用解释了吧,这个不会的话,需要好好补充一下基础知识了。。。
锁的重入次数是怎么计算的?
写锁比较简单,直接用计算出来的低16位值就可以代表写锁的重入次数。
读锁,就比较复杂了,因为高16位只能表示持有共享锁的线程个数,实在是分身乏术啊。所以,在 Sync 内部,维护了一个类,用来表示每个线程重入的次数,
static final class HoldCounter {
int count = 0;
// Use id, not reference, to avoid garbage retention
final long tid = getThreadId(Thread.currentThread());
}
这里边定义了一个计数器来表示重入次数,tid 来表示当前的线程 id 。但是,这样还不够,我们需要把 HoldCounter 和 线程绑定,这样才可以区分出来每个线程分别持有的锁个数(重入次数),这就需要用到 ThreadLocal 了。
static final class ThreadLocalHoldCounter
extends ThreadLocal {
//重写此方法,可以在 ThreadLocal 没有当前线程计数的情况下,
//直接使用 的 get 方法,初始化一个,而不必 new 一个对象
public HoldCounter initialValue() {
return new HoldCounter();
}
}
除此之外,Sync 中还定义了一些其他和读锁相关的属性,
//保存了当前线程重入的读锁次数,当重入次数减到 0 时移除
//移除应该是为了性能着想,因为可以随时通过 get 方法初始化 HoldCounter
private transient ThreadLocalHoldCounter readHolds;
//保存了最近一个获取读锁成功的线程计数,这个变量的目的是:
//如果最后一个获取到读锁的线程重复获取读锁,那么就可以直接拿来用,而不用更新。
//相当于缓存,提高效率
private transient HoldCounter cachedHoldCounter;
//第一个获取读锁的线程
private transient Thread firstReader = null;
//第一个获取读锁的线程计数
private transient int firstReaderHoldCount;
//这两个参数,是为了效率问题,当只有一个线程获得读锁时,就避免了查找 readHolds
基本知识讲完啦,那么接下来就是锁的获取和释放了。先说下写锁吧,因为有上一篇独占锁的基础了,理解起来比较容易。
写锁的获取
写锁的获取从 lock 方法开始,
//ReentrantReadWriteLock.WriteLock.lock
public void lock() {
sync.acquire(1);
}
//AbstractQueuedSynchronizer.acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
//公平锁和非公平锁调用的是同一个方法,在 Sync 类中定义
//ReentrantReadWriteLock.Sync.tryAcquire
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
//获取同步状态 state
int c = getState();
//写锁状态
int w = exclusiveCount(c);
//如果同步状态不为 0,说明有线程获得了读锁或写锁
if (c != 0) {
//如果同步状态不为 0 ,并且写锁状态为 0,说明了读锁被占用,因读写锁互斥,故返回 false
//若写锁状态不为 0,并且不是当前线程获得了写锁,则不能重入,返回 false
if (w == 0 || current != getExclusiveOwnerThread())
return false;
//如果超过了最大写锁数量,则抛出异常
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//若走到这一步,说明当前线程重入了,则计算重入次数,返回true
setState(c + acquires);
return true;
}
//到这说明 c 为 0,读锁和写锁都没有被占用
//如果写锁应该被阻塞或者 CAS 获取写锁失败,则返回false
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//把当前线程设为独占锁的所有者
setExclusiveOwnerThread(current);
return true;
}
写锁的释放
同理,写锁的释放从 unlock 方法开始,
public void unlock() {
sync.release(1);
}
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
protected final boolean tryRelease(int releases) {
//若独占锁的持有者不是当前线程,则抛出异常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//每次释放,state 减 1
int nextc = getState() - releases;
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
可以看到,写锁的获取和释放和 ReentrantLock 的基本思想是差不多的。下面,着重讲解读锁的获取和释放,相对比较复杂。
读锁的获取
tryAcquireShared
从 ReadLock.lock 方法开始,
public void lock() {
//调用 AQS 的方法
sync.acquireShared(1);
}
public final void acquireShared(int arg) {
//如果 tryAcquireShared 方法返回小于 0,说明获取读锁失败
if (tryAcquireShared(arg) < 0)
//以共享模式加入同步队列,再自旋抢锁
doAcquireShared(arg);
}
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
//如果有线程获取到了写锁,并且不是当前线程,则返回 -1 。
//这是因为,如果线程先获得了写锁,是可以重入再次获取读锁的,此为锁降级。
//否则不可重入。
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//读锁数量
int r = sharedCount(c);
//如果同时满足以下三个条件(读线程不应该被阻塞,读锁数量小于最大数量限制,CAS成功),
//则说明获取读锁成功,返回 1。然后再设置相关属性的值。
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//如果读锁状态为 0,说明还没有其他线程获取到读锁
if (r == 0) {
//就把当前线程设置为第一个获取到读锁的线程
firstReader = current;
//第一个读线程计数设置为 1
firstReaderHoldCount = 1;
} else if (firstReader == current) {
//如果当前线程是第一个获取读锁的线程,则重入,计数加 1
firstReaderHoldCount++;
} else { //读锁状态不为 0,并且当前线程不是 firstReader
//最近一个成功获取到读锁的线程计数器
HoldCounter rh = cachedHoldCounter;
//如果计数器为空,或者计数器的 tid不是当前线程 id,说明有两种情况
//1.rh 还未被任何线程设置,此时只有 firstReader 一个线程获取到了读锁。
//2.rh 已经被设置了,并且不是当前线程,说明在当前线程之前,除了 firstReader,
//还有其他线程获取到了读锁,那么当前线程就是第三个获取到读锁的(至少)。
if (rh == null || rh.tid != getThreadId(current))
//不管哪种情况,都需要创建并初始化当前线程的计数器,并赋值给 cachedHoldCounter
//因为,当前线程是此时最后一个获取到读锁的线程,需要缓存下来
cachedHoldCounter = rh = readHolds.get();
//如果当前线程是最近一个获取到读锁的线程,并且计数为0,
else if (rh.count == 0)
//就把 rh 线程持有锁的次数信息,放入到本地线程 readHolds
readHolds.set(rh);
//最后把计数加 1
rh.count++;
}
return 1;
}
//若以上三个条件任意一个不满足,则调用此方法,再次全力尝试获取锁
return fullTryAcquireShared(current);
}
fullTryAcquireShared 这个方法和 tryAcquireShared 方法非常相似,只是多了一个自旋的过程,直到返回一个确定值(-1或1),才结束。
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
//自旋,直到返回一个确定值(1或 -1)
for (;;) {
int c = getState();
//如果写锁状态不为0,说明有线程获取到了写锁
if (exclusiveCount(c) != 0) {
//获取到写锁的线程不是当前线程,则返回 -1
if (getExclusiveOwnerThread() != current)
return -1;
//这里省略了else,到这里说明了当前线程获取到了写锁,因此需要做锁降级处理,
//把写锁降级为读锁。因为如果不这样做的话,线程就会阻塞到这,会导致死锁。
//然后跳转到 ①处继续执行
//===========//
} else if (readerShouldBlock()) { //写锁空闲,并且读锁应该阻塞,说明 head.next正在等待获取写锁
//尽管读锁应该阻塞,但是此处也不应该立即阻塞,因为有可能存在读锁重入,需要再确认一下。
if (firstReader == current) {//当前线程是第一个读锁,可重入
// 将跳转到 ①处
} else {
if (rh == null) { //第一次循环进来时肯定为 null
rh = cachedHoldCounter; //取到缓存中最后一次获取到读锁的计数器
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
//计数为 0,说明当前线程没有获取到过读锁
if (rh.count == 0)
//为了性能考虑,如果计数为 0,需要把它移除掉
readHolds.remove();
}
}
//走到这,说明当前线程不是 firstReader,也没有获取到过读锁,不符合重入条件,
//那么就确定需要阻塞,只能去排队了,返回 -1 。
if (rh.count == 0)
return -1;
}
}
// ①处
//如果读锁数量达到了 MAX_COUNT,则抛出异常
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//CAS获取读锁,和 tryAcquireShared 的处理逻辑一样,不再赘述
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;
}
}
}
doAcquireShared
如果 tryAcquireShared 最终还是失败了,那么就执行 doAcquireShared 方法。
private void doAcquireShared(int arg) {
//以共享模式加入同步队列
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);
}
}
setHeadAndPropagate
private void setHeadAndPropagate(Node node, int propagate) {
//旧的头结点
Node h = head;
//把当前 node 设置为新的头结点
setHead(node);
//propagate 是 tryAcquireShared 方法的返回值
//若大于0,或者旧的头结点为空,或者头结点的 ws 小于0
//又或者新的头结点为空,或者新头结点的 ws 小于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();
}
}
读锁的释放
tryReleaseShared
从 ReadLock.unlock方法开始,
public void unlock() {
sync.releaseShared(1);
}
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//当前线程为第一个读线程
if (firstReader == current) {
//若 firstReader 的计数为1,则把它置为 null
if (firstReaderHoldCount == 1)
firstReader = null;
else
//否则,计数减 1,说明重入次数减 1
firstReaderHoldCount--;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
//如果当前线程的计数小于等于 1,则移除
readHolds.remove();
if (count <= 0)
//若计数小于等于 0,则抛出异常
throw unmatchedUnlockException();
}
//计数减 1
--rh.count;
}
for (;;) {
int c = getState();
//读锁状态减 1,其实就是state值减 65536
//因为高16位的读锁实际值,在state中的表现就是相差 65536
int nextc = c - SHARED_UNIT;
// CAS 设置 state 最新状态
if (compareAndSetState(c, nextc))
//如果读锁状态减为 0,就返回true
//释放读锁对其它读线程没有任何影响,
//但是如果读、写锁都空闲,就可以允许等待的写线程继续执行
return nextc == 0;
}
}
doReleaseShared
如果 tryReleaseShared 方法返回 true,说明读锁释放成功,需要唤醒后继节点,
private void doReleaseShared() {
for (;;) {
//头结点
Node h = head;
//说明队列中至少有两个节点
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
//如果头结点的 ws 为 -1 ,则 CAS 把它设置为 0,因为唤醒后继节点后,
//它就不需要做什么了。失败继续自旋尝试
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue; // loop to recheck cases
// CAS 成功,则唤醒后继节点
unparkSuccessor(h);
}
//如果 ws 为 0,则把它设置为 -3 ,表明共享状态可向后传播,失败则继续自旋尝试
//后来我一直在想,为什么需要设置一个 PROPAGATE 这样的状态呢,但是还没头绪
//可以看下这篇文章分析,或许有一定的参考价值:
//https://www.cnblogs.com/micrari/p/6937995.html
//只能说 Doug Lea 大神的逻辑真是太缜密了,等我以后想明白了,再补充吧。
//可以暂时先理解为,这就是一个无条件传播的标志
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
//如果此刻 h 等于头结点,说明头结点未改变,则跳出整个循环
//否则,说明头结点被其他线程修改过了,则继续下一次的循环判断
if (h == head) // loop if head changed
break;
}
}
结语
关于独占锁,比较简单。而读锁,涉及到了很多临界点和瞬时状态。其实细想,并不像表面上看起来那么简单,理解的会比较浅显,毕竟 Doug Lea 大神的思想不是常人能揣摩透的。
本篇只是我的一些个人理解,如有讲解不到位的地方,欢迎拍砖。
其实,还有很多细节问题,本文并没有展开。例如, setHeadAndPropagate 方法为什么判断两次新旧节点的 ws 状态,意义何为。 doReleaseShared 方法最后为什么需要设计 h == head 这样的判断,有什么含义。包括为什么要设计 PROPAGATE 状态,没有这个状态又如何。
看来路阻且长啊。。。以后再来补坑吧,这篇只能叫浅析了。 ̄□ ̄||
如果本文对你有用,欢迎点赞,评论,转发。
学习是枯燥的,也是有趣的。我是「烟雨星空」,欢迎关注,可第一时间接收文章推送。