ReentrantReadWriteLock及共享锁的实现
介绍
ReentrantReadWriteLock
是j.u.c
包下提供的ReadWriteLock
接口的实现。
ReadWriteLock
作为读写锁,提供了返回读锁和返回写锁两个方法。
/**
* 读写锁
*/
public interface ReadWriteLock {
/**
* 返回读锁
* @return
*/
Lock readLock();
/**
* 返回写锁
* @return
*/
Lock writeLock();
}
其中读锁是一个共享锁,而写锁是一个独占锁。
也就是说多个线程可以同时持有读锁,而写锁只能被一个线程持有。
除此之外,ReentrantReadWriteLock
还有几个特性:
- 读锁和写锁都是可重入的
- 线程在持有写锁时,可以再获取读锁(称之为锁的降级)
- 在读锁被持有时,任何线程都无法获取写锁(写锁的重入除外),即锁无法被升级
- 读写锁也有公平模式和非公平模式,默认是非公平模式
ReentrantReadWriteLock成员变量及内部类介绍
成员变量
private static final long serialVersionUID = -6992448646407690164L;
/** Inner class providing readlock */
//内部变量,保存一个读锁
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
//内部变量,保存一个写锁
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
//读锁和写锁内部都用sync来控制
final Sync sync;
ReentrantReadWriteLock
拥有的成员变量比较简单,一个内部类的对象readerLock
表示读锁,一个内部类的对象writerLock
表示写锁,另外一个sync
,了解排他锁实现的同学应该知道,Sync
一般是AQS的抽象实现。
内部类
ReentrantReadWriteLock类下直接的内部类
ReentrantReadWriteLock
类下直接的内部类共有五种: Sync
、NonfairSync
、 FairSync
、ReadLock
和WriteLock
。
Sync
Sync
是AbstractQueuedSynchronzier
的抽象实现,主要是根据锁不同的特性去实现某个方法。
FairSync和NonfairSync
FairSync
和NonfairSync
是对Sync
的实现,主要是区分公平锁和非公平锁的差别。这一点和ReentrantLock
是一样的。
ReadLock
ReadLock
表示读锁,其成员变量和构造函数如下:
private final Sync sync;
/**
* Constructor for use by subclasses
*
* @param lock the outer lock object
* @throws NullPointerException if the lock is null
*/
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
可以和其他的锁一样,内部也是通过Sync
来实现锁的功能。
WriteLock
WriteLock
表示写锁,其成员变量和构造函数和ReadLock
一样:
private final Sync sync;
/**
* Constructor for use by subclasses
*
* @param lock the outer lock object
* @throws NullPointerException if the lock is null
*/
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
Sync
在ReentrantReadWriteLock
中,由锁实现的逻辑可以说是几乎没有,真正的逻辑全是委托给Sync
处理。对ReentrantReaderWriteLock
的代码分析也基本上是集中在对Sync
代码的分析上。
Sync中的内部类
除了ReentrantReadWriteLock
之外,Sync
下也有两个内部类:
HoldCounter
和ThreadLocalHoldCount
。
HoldCount
HoldCount
用来记录读锁被线程持有的次数以及线程的id:
static final class HoldCounter {
int count = 0;
// Use id, not reference, to avoid garbage retention
final long tid = getThreadId(Thread.currentThread());
}
ThreadLocalHoldCounter
ThreadLocalHoldCounter
通过ThreadLocal
的特性为每个线程保存自己的HoldCount
对象:
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
//在ThreadLocal.get()的值为null时,设置初始值
public HoldCounter initialValue() {
return new HoldCounter();
}
}
Sync中的域
Sync
定义的域也并不多,主要分为两部分,第一部分是一些静态的常量,另一个部分则是成员变量。
常量
static final int SHARED_SHIFT = 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;
整形的常量,大概可以看出是和位运算有关的内容。具体的左右我们稍后分析。
成员变量
/**
* 记录每个线程读锁的获取次数
*/
private transient ThreadLocalHoldCounter readHolds;
/**
* 记录最后一个成功获取读锁的缓存,用专门的变量保存可以提高效率(因为常用)
*/
private transient HoldCounter cachedHoldCounter;
//记录第一个读锁获取的线程
private transient Thread firstReader = null;
//记录第一个读锁获取的次数
private transient int firstReaderHoldCount;
这些成员变量均和Thread
以及HoldCount
相关,是Sync
用来保存读锁获取情况的信息。firstReaderHoldCount
和cachedHoldCounter
其实都能从readHolds
中取出,这里在用额外的变量表示很大一部分原因是出于锁吞吐量的考虑。
读写锁共用一个Sync
从上面的介绍我们看出尽管ReentrantReadWriteLock
可以返回ReadLock
和WriteLock
两个锁,但是这两个锁在创建时传入的Sync
是同一个对象。这也是实现读写锁特性所必须的。
我们知道AQS
有个关键的变量是state
,记录锁被获取的次数
,用来控制锁是否可以被获取。
那么既然只有一个state
,而且Sync
及其子类也没有定义额外的变量再用来存放锁的次数,读写锁是怎么通过一个变量来同时表示读锁的获取次数和写锁的获取次数的呢?
这里Doug Lea老爷子用一个很巧妙的方式来处理,那就是把state一分为二,高16位用来表示读锁获取的次数,而低16位用来表示写锁获取的次数,这也就是上文我们在Sync
内部中看到有那么多与位运算有关的常量。同时,Sync
还提供两个方法,分别用来取state
的高16位和后16位。
//获取共享模式的值,从实现上看是取了高的16位
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}
/** Returns the number of exclusive holds represented in count */
//获取独占模式的值,从实现上看是去了低的16位
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
理解如何表示和处理state
这一点很重要。因为这是在判断锁是否可以被获取的重要条件。
读者可以在这里稍作停顿,思考下。
另外,我个人还有另外一个问题,就是如前文所说,为什么不干脆直接用两个state
值分开表示呢?
我个人的理解是,首先
AQS
内部是采用CAS来控制的,如果分开两个变量表示,那么就无法通过CAS保证线程的安全。另外一点就是AQS
本身在已经提供了很多实现,只有少部分实现是留给子类的完成的,而那些既定的实现都是根据一个state
的原则下实现的,如果这里采用两个state
去实现,那么父类中的一些方法可能就变得不再正确了。
读锁的获取及释放
读锁的获取过程
因为读锁是共享锁,所以读锁的lock方法是委托给sync.acquireShared()
方法。我们以非公平锁为例,看一下读锁获取的过程。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
acquireShared
是以共享的方式获取锁。过程主要分为两步:
第一步是先通过tryAcquireShared
方法尝试获取锁,如果该方法返回值大于等于0,方法直接返回,说明此时获取共享锁成功。如果该方法返回值小于0,则表示获取失败。
此时就会进入第二步,doAcquireShared()
方法。这个方法会将获取锁的线程封装成Node节点,并放入AQS的阻塞队列,等待唤醒后再尝试获取。
tryAcquireShared
tryAcquireShared()
并未在AQS
方法中提供具体的实现,但是AQS
在该方法的注释中对返回值做了规定,负值表示方法获取锁失败,0表示此次获取成功,但是无法继续被获取,大于1则表示此次获取成功,且还能继续被获取。
子类需要根据锁的特性自己去实现tryAcquireShared()
方法。
对ReentrantReadWriteLock
而言,该方法的实现如下:
protected final int tryAcquireShared(int unused) {
/*
* Walkthrough:
* 1. If write lock held by another thread, fail.
* 2. Otherwise, this thread is eligible for
* lock wrt state, so ask if it should block
* because of queue policy. If not, try
* to grant by CASing state and updating count.
* Note that step does not check for reentrant
* acquires, which is postponed to full version
* to avoid having to check hold count in
* the more typical non-reentrant case.
* 3. If step 2 fails either because thread
* apparently not eligible or CAS fails or count
* saturated, chain to version with full retry loop.
*/
Thread current = Thread.currentThread();
int c = getState();
//说明此时有排他属性(写锁被其他线程持有),则获取读锁失败
//&& 后面的条件是照顾写锁降级读锁的情况
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//
int r = sharedCount(c);
//验证此时读锁是否需要被阻塞,因为AQS的队列之前有写锁在等待
//第一个条件先判断读锁是否需要被阻塞
//第二个条件是判断读锁的重入次数是否要移除(最大次数为2^16-1)
//第三个条件CAS的操作是在state的高16位增加1
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//进入这里,说明成功获取读锁
if (r == 0) {//线程是当前第一个获取的读锁
//设置第一个读的线程,及读锁持有次数
/**注意这里是如何保证在CAS后依旧线程安全呢**/
//因为这里的r是之前读锁的次数
//假设t1首先CAS成功,将读锁共享获取次数设置为1
//那么t2在同时CAS的情况下,只能CAS失败而无法进入当前循环,
//或者t2是在t1 CAS成功后,CAS的预期值不为0的情况下 CAS成功,这种情况,r不可能为0,因此也无法进入这个if
//从而保证了设置的firstReader和firstReadHolderCount的正确性
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) { //说明当前是重入的读锁
firstReaderHoldCount++; //更新读锁持有数
} else { //说明非第一个持有读锁的线程
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) //如果记录的最后一个线程不是当前线程,则覆盖cachedHoldCounter
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0) //这里应该是在localThread中初始化以下该线程的获取次数
readHolds.set(rh);
rh.count++; //读锁次数加1
}
//返回1表示获取成功
return 1;
}
/****何时会进入到fullTryAcquireShared*****/
//1.readerShouldBLock返回true
//2.CAS操作失败
//说明在尝试获取锁时存在竞争(可能是读锁重入的竞争,也可能是读写锁获取的竞争)
//对于读锁重入的竞争,是不需要加入阻塞队列被挂起的,只需要重试直到重入锁成功即可
//这也就是这个方法的主要目的,不让读锁的重入进入阻塞队列
//把握这一目的可以帮助更好的理解这个方法
return fullTryAcquireShared(current);
}
每一行代码基本上都给出了比较详细的注释。这里在整理下会直接返回值的两种情况(异常除外)。
- return -1的情况:上文说了返回负值表示尝试获取失败,此时线程会被添加进阻塞队列,进入等待。会返回负值的条件是
exclusiveCount(c) !=0 && getExclusiveOwnerThread() != current
。说明是写锁已经被其他线程持有,那么当前线程是无法获取读锁的,需要等待写锁被释放后才能再去尝试获取读锁。 - return 1的情况:返回正值表示此次尝试获取读锁成功,对应的条件是
!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)
说明此时阻塞队列中没有尝试获取写锁的请求且此次CAS成功,表示读锁能被获取且这次获取成功。 - 如果上面两个情形都不对,那么会进入
fullTryAcquireShared()
方法,进入另一个版本的尝试锁获取流程。如果fullTryAcquireShared()
依旧失败,才会进入doAcquireShared()
。
fullTryAcquireShared()
为什么在第一尝试失败后,没有直接进入等待队列,而是换个方法继续尝试获取锁?
首先从tryAcquireShared()
方法中,即没有明确返回失败也没有明确返回成功的情况是什么呢。
说明此时没有线程持有写锁(因为如果写锁被持有,那么非持有锁的线程尝试获取读锁时会失败,而锁的持有尝试获取读锁时CAS一定成功),并且此时AQS
的阻塞队列可能有写锁获取请求在等待或是此时线程CAS失败。
我们考虑CAS失败的情况,假设t1
和t2
同时获取锁请求,假设t1
先获取到了读锁,而t2
在CAS由于和t1
存在竞争,导致CAS失败,此时如果让t2直接进入AQS
的阻塞队列等待,直到t1
释放完锁在唤醒t2
显然不符合读写锁中读锁是共享的这一特性。因此这里需要通过fullTryAcquireShared()
继续让t2
CAS尝试而不必等待。
了解了这一点,那么在看fullTryAcquireShared()
方法会简单很多,因为很多代码其实和tryAcquireShared()
方法类似。
final int fullTryAcquireShared(Thread current) {
/*
* This code is in part redundant with that in
* tryAcquireShared but is simpler overall by not
* complicating tryAcquireShared with interactions between
* retries and lazily reading hold counts.
*/
HoldCounter rh = null;
//for循环包围,说明也需要不断重试
//注意何时会从for循环中退出
//1.写锁被持有,且持有者不是当前线程,返回-1 说明尝试以共享方式获取锁失败
//2.写锁获取请求已经在等待队列中,且当前线程未在前一获取读锁的线程完全释放锁前获取到读锁,则不再尝试循环CAS获取锁,返回-1,表示失败,进阻塞队列等待,优先级让给读锁
//3.当前线程CAS设置成功,返回1,说明尝试共享方法获取读锁成功
for (;;) {
int c = getState();
if (exclusiveCount(c) != 0) { //已经有人占有写锁
if (getExclusiveOwnerThread() != current) //且占有写锁的线程不是当前线程
return -1; //获取失败
// else we hold the exclusive lock; blocking here
// would cause deadlock.
} else if (readerShouldBlock()) { //是否需要阻塞获取读锁的过程
//说明此时阻塞队列的下一个节点是写锁的请求
//由于要考虑写锁有些的情况(避免写饥饿),
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) { //如果是首次获取读锁的线程重入,说明读锁暂时还不会被释放,直接进入下面CAS去更新state
// assert firstReaderHoldCount > 0;
} else { //非首次获取读锁的线程尝试获取读锁
//这里说明是其他线程重入
if (rh == null) {
//上次一个获取锁线程的情况
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
//如果上一个获取读锁的线程已经不再持有读锁了
//则此次尝试换取读锁失败,进入阻塞队列等待
//将优先级让给写锁
if (rh.count == 0)
return -1;
/****这里就来说明上面的代码是如何保证写锁的优先级的****/
//上面代码规定了当前线程在尝试时,如果在前一个持有读锁的线程完全释放前,依旧无法CAS获取读锁
//则当前线程退出循环CAS尝试,进入等待队列
//即使此时有其他线程占有读锁,这样便保证了读锁获取的优先级
}
}
//读锁次数已经到最大次数,则异常抛出
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//CAS尝试让读锁次数加1
if (compareAndSetState(c, c + SHARED_UNIT)) {
//这里的逻辑和tryAcquireShared相似
//CAS成功,说明此次操作成功
//因此这里必会返回
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;
}
}
}
可以看到整个方法内部整个被包裹在一个大的for循环中,说明方法确实是一直在不断尝试的。
同样我们也分析对应退出的情况。
- 在尝试中,写锁被其他线程获取,此时获取读锁的线程必须进入等待,直到写锁被释放。
rh.count == 0
,首先rh在CAS不曾成功的情况下记录的是cachedHoldCounter
,表示最后一个获取锁的线程。为什么当最后获取读锁的线程完全释放后,当前线程就不在继续尝试呢?首先很重要的一点是这个方法前还有个判断readerShouldBlock()
,对于非公平锁而言,这说明此时AQS
的阻塞队列除头节点外的第一个节点在等待获取写锁。我的理解是读写锁为了考虑写锁不一直因为读锁被持有而进入饥饿,而限制了后续读锁尝试CAS的时间是有限的。如果一段时间内无法获取,则同样需要进入阻塞队列。因此,这里返回值也是-1。
上述情况对于这种情形,假设共有四个线程:
t1
,t2
,t3
和t4
。t1
作为firstReader
获取到了读锁,紧接着t2
也获取到了读锁,因此被记录为cachedHoldCounter
。这时t3
尝试获取写锁,因为读锁已经被持有,所以t3
尝试获取写锁失败,进入AQS
的阻塞队列。此时,t4
也尝试在获取读锁,假设t4
运气不好,由于t1
在不断重入读锁,t4
CAS一直失败。此时t2
已经释放了读锁,如果一直让t4
继续保持尝试,那么t4
总能获取都读锁,那么t3
会继续进入等待。假设还有线程t5
,t6
...tN
要获取读锁,那么t3
将一直等待,从而导致t3
的饥饿。因此有效的尝试时间是必要的。- 在尝试过程中,CAS成功,说明此时能够获取共享锁。因此返回值是1。
doAcquireShared
介绍完了尝试获取共享锁的过程,接下来看尝试获取失败,让线程进入等待的doAcquireShared()
方法。
doAcquireShared()
方法在AQS
中就提供了实现,并未留给子类实现,说明对于任何共享锁而言,这是一个通用的流程。
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) {
//先已共享方式获取锁,主要是看AQS的state是否已经等于0
int r = tryAcquireShared(arg);
if (r >= 0) { //获取锁成功
//唤醒后继节点
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
//如果前继节点不为头节点 或者获取锁失败
//检查线程是否需要被Park,有则Park线程,线程会执行到parkAndCheckInterrupt中被挂起
//并在唤醒后,检查是否由于中断而唤醒的
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个方法比较简单,因为和独占锁的doAcquireInterruptibly()
方法类似,大致流程都是将线程包装成Node
节点,加入AQS的阻塞队列挂起,等待被唤醒,当唤醒时检查自己的前继节点是否已经是阻塞队列的head
节点,如果是,说明自己需要被唤醒,再次尝试获取锁,如果获取失败,则检查是否需要被挂起,需要则挂起。如此循环,直到获取锁或被中断。
有区别的几点是共享模式在尝试获取锁和独占模式尝试获取时调用的方法不用,另一点是独占模式在成功获取锁后只会更新阻塞队列的头节点,而共享模式不仅需要更新阻塞的队列头节点,还需要唤醒阻塞队列中的其他节点(setHeadAndPropagate()
)。
唤醒后并再次尝试获取锁成功后的代码(代码中的⇨符号处)又涉及后续的唤醒过程,将在后续再做介绍。这里主要先把握上锁失败进入阻塞队列并等待的过程。
AQS中共享锁获取的主体流程
上文也介绍到了,AQS
其实已经规定了共享锁获取的流程,这里在做一次整理,帮助读者加深印象。
共享锁获取过程
acquireShared(){
if(tryAcqurireShared()//1){
doAcquireShared(); //2
}
}
acquireShared()
方法是整个流程的主入口,内部实现分为两步:
tryAcquireShared()
:尝试获取锁,由AQS的子类根据锁的特性自己实现,基本的思路就是根据state
判断doAcquireShared()
:在尝试失败后,进入AQS
的阻塞队列等待唤醒,如果是正常的唤醒(非中断和假唤醒)则再次调用tryAccquireShared()
继续尝试获取共享锁,如果成功,则更新AQS阻塞队列的head
,并开始唤醒其他节点。
其他共享锁获取过程的介绍
这里简单介绍一下CountDownLatch
和Semaphore
共享锁获取的过程。严格意义上将,这两者均不是锁,因为没有实现Lock
接口,但是其内部的实现都依赖了AQS
的特性。
CountDownLath
CountDownLatch
是一个栅栏,创建时需要往构造函数中传入一个count
值,表示这个栅栏需要接受count
个信号量才会打开。
public CountDownLatch(int count) {
if (count < 0) throw new IllegalArgumentException("count < 0");
//锁的state初始化成count
this.sync = new Sync(count);
}
其实就是让Sync
的state
在初始化时就被设置为count
。而await()
方法其实就是通过AQS.acquireSharedInterruptibly()
方法(可响应中断,本质上和acquireShared()
没差别)。
CownDownLatch
中tryAcquireShared()
相对简单,只要state
没有减为0,反方就返回失败,否则返回成功。
protected int tryAcquireShared(int acquires) {
//getState == 0 说明信号量已经满了,此时可以已共享模式获取锁,返回正值,否则返回负值
return (getState() == 0) ? 1 : -1;
}
Semaphore
Semaphore
在一些地方被称做凭证管理器,管理permits
个凭证,在凭证发放完前,线程都能acquire()
成功,反之则要被阻塞等待线程归还。
对于Semaphore
而言,state
值的含义有点特殊,不再表示锁已经被获取的次数,相反它表示锁还能被获取的次数(remaining)。当state-acquires > 0
时,表示凭证还有剩余,acquire()
方法返回成功,反之则返回失败,进入AQS的阻塞队列。
来看一下Semaphore
的tryAcquireShared()
的实现(以非公平锁为例):
//非公平方式获取锁
//返回值 >=0 说明尝试以共享方式获取锁成功
final int nonfairTryAcquireShared(int acquires) {
//因为此时无法保证线程安全,因此要以CAS失败重试的方式更新AQS的state值
for (;;) {
//当前state值(表示当前剩余的凭证)
int available = getState();
//此次acquire之后仍剩余的凭证
int remaining = available - acquires;
//凭证不足 或是 CAS设置成功,说明尝试获取结束
//尝试获取的结果 则通过返回值判断
if (remaining < 0 ||
compareAndSetState(available, remaining))
return remaining;
}
}
读锁的释放过程
读锁的释放是委托给sync.releaseShared()
方法。
public final boolean releaseShared(int arg) {
//先尝试以共享模式释放锁
if (tryReleaseShared(arg)) {
//如果成功,则唤醒等待节点
doReleaseShared();
return true;
}
return false;
}
这个方法也分为两步,第一步tryReleaseShared()
方法用来尝试释放锁,交由AQS
的子类根据锁的特性实现,主要的作用就是更新state
值,并根据state
判断时候需要唤醒等待的节点。如果返回true
,说明需要唤醒等待的节点,则进入doReleaseShared()
,负责处理节点的唤醒工作。
tryReleaseShared
/**
* 尝试释放共享锁
* @param unused
* @return
*/
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//如果当前线程是第一次获取锁的线程
/*****这一部分主要处理各线程HoldCount****/
//这里不会存在竞争,因为每个条线程只会修改自己的数据
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
//如果该线程释放完了,则清空firstReader(注意这里firstReader释放完了后并没有将其更新为下一个持有读锁的线程,而是直接清空)
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--; //持有次数减1
} else {//当前并非是第一个获取读锁的线程
//获取该线程对应的HoldCounter对象
//先假设是cachedHoldCounter,如果不是在用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) //这里可以防止没获取读锁的线程释放锁,抛出IllegalMonitorStateException
throw unmatchedUnlockException();
}
--rh.count;
}
/**这一部分则是更新读共享锁的次数**/
//循环CAS设置
for (;;) {
int c = getState();
//state的高16位上减1
int nextc = c - SHARED_UNIT;
//CAS设置
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
//如果CAS设置成功,则从该方法中返回,当读共享锁持有的次数为0时,返回true(说明读锁已经完全释放,需要处理阻塞队列中的线程)
return nextc == 0;
}
}
读锁的尝试过程tryReleaseShared()
主要也分成两步:
第一步是先处理HoldCount
相关的变量,此时虽然没有保障线程安全的措施,但是因为每个线程修改的都是自己的数据,因此不存在竞争,也是安全的。
第二步则是更新state
值。这里采用了CAS循环设置的方式去尝试,如果CAS成功,则通过state
值是否等于0决定是否需要开始唤醒。注意这里state
等于0,说明读锁和写锁都没有被人持有,只有这样才能唤醒等待的线程。
doReleaseShared
//共享模式的释放锁
//和独占模式最大的区别就是共享模式下的唤醒过程会开始传播,而独占模式下只会唤醒头节点的后继节点
private void doReleaseShared() {
/*
* Ensure that a release propagates, even if there are other
* in-progress acquires/releases. This proceeds in the usual
* way of trying to unparkSuccessor of head if it needs
* signal. But if it does not, status is set to PROPAGATE to
* ensure that upon release, propagation continues.
* Additionally, we must loop in case a new node is added
* while we are doing this. Also, unlike other uses of
* unparkSuccessor, we need to know if CAS to reset status
* fails, if so rechecking.
*/
for (;;) {
Node h = head;
//如果头节点不为空 或者 头节点不等于尾节点 说明阻塞队列中有正在等待的线程
if (h != null && h != tail) {
int ws = h.waitStatus;
//如果节点的 waitStatus 为SIGNAL,表示有后继节点需要等待唤醒
if (ws == Node.SIGNAL) {
//CAS操作更新waitStatus值
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0)) //CAS失败说明已经有其他线程修改了head的waitStatus
continue; // loop to recheck cases
//唤醒等待的节点
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE)) //ws等于0说明没有后继节点, 通过CAS将waitStatus设置为 PROPAGATE
continue; // loop on failed CAS
}
//如果头节点发生了改变,说明唤醒了后继线程,继续循环尝试唤醒剩下的等待节点
//如果头节点未发生改变,说明唤醒失败(可能是没有等待的节点,也可能是其他线程已经在处理唤醒),线程完成唤醒任务
if (h == head) // loop if head changed
break;
}
}
关于锁释放的过程我理解了好久,尤其是这个传播的过程。
这里,我们借助以下情形帮助我们理解:
假设,现在有5个线程t1
,t2
,t3
,t4
,t5
。
t1
在获取写锁后,又获取了读锁。此时线程t2
,t3
,t4
,t5
依次尝试获取读锁,因为写锁被占有,均失败进入AQS的阻塞队列被Park。此时,AQS的阻塞队列中个节点及其ws如下所示:
| head | t2 | t3 | t4 | t5 |
|ws = SIGNAL|ws = SIGNAL|ws = SIGNAL|ws = SIGNAL| ws = 0 |
接着,t1
线程在释放写锁后又释放读锁,t1
便会执行releaseShared()
方法,并且由于此时没有其他线程持有锁,tryReleaseShared()
方法会返回true
,线程运行doReleaseShared()
方法。上述AQS队列中head
节点的ws会先被更新为-1,然后t1
线程会调用unparkSuccessor()
方法让t2
从等待中醒来。我们先让t1
在此处暂停下,看看t2
醒来后会发生什么。
t2
醒来后,会回到上文doAcquireShared()
标记过的右箭头⇨处(读者可以回到上文doAcquireShared()
方法中)。此时t2
的前继节点正是head
,因此它又开始通过tryAcquireShared()
方法尝试获取共享锁,这次尝试应该能够成功,所以返回值1为1。t2
获取锁成功,进入setHeadAndPropagate()
方法中。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
//更新队列的头节点
setHead(node);
/*
* Try to signal next queued node if:
* Propagation was indicated by caller,
* or was recorded (as h.waitStatus either before
* or after setHead) by a previous operation
* (note: this uses sign-check of waitStatus because
* PROPAGATE status may transition to SIGNAL.)
* and
* The next node is waiting in shared mode,
* or we don't know, because it appears null
*
* The conservatism in both of these checks may cause
* unnecessary wake-ups, but only when there are multiple
* racing acquires/releases, so most need signals now or soon
* anyway.
*/
//如果满足以下一个条件,则需要唤醒后继节点(如果存在的话)
//1)propagate > 0 说明需要唤醒后继节点
//2) h == null 说明没有阻塞的情况
//3)waitStatus < 0
//4)或者新的head满足以上2,3条件
//TODO 后面几个判断对应什么情形还待研究
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
这个方法从名字就可以看出其作用——更新AQS的头节点并传播唤醒。
更新头节点很好理解,AQS的head
节点被此时被更新为t2
的节点。
传播唤醒是指继续唤醒后续的节点。从上面的代码中,我们能发现t2
在确定之后的节点仍为SHARED
模式的情况下,会继续调用doReleaseShared()
方法。
看一下更新完head
节点后,AQS队列的情况。
|head(t2)| t3 | t4 | t5 |
|ws = 0 |ws = SIGNAL|ws = SIGNAL| ws = 0 |
这里再切换为t1
的线程的视角,线程还在doReleaseShared()
方法中执行,由于head
已经发生了改变,t1
将不会从if(h==head)
的条件中跳出循环,从而继续执行唤醒的操作。
继续回到t2
线程执行的过程,t2
也开始执行doReleaseShared()
方法,此时开始有两条线程均在doReleaseShared()
方法中负责唤醒线程,并且之后唤醒的线程也同样会进入到doReleaseShared()
方法中。负责唤醒的线程越来越多。该过程正是具有蔓延性的,会进一步爆发,这也正是propagate
的意义。
在多个线程线程同时在处理唤醒的过程中,难免会存在竞争,比如,t1
和t2
如果同时想要唤醒t3
,先执行的线程会将t3
的ws更新为0,而后到的线程则会将t3
的ws再更新为Node.PROPAGATE
,表示被蔓延过。
该过程如下:
第一次更新t3
的ws
:
|head(t2)| t3 | t4 | t5 |
|ws = 0 | ws = 0 |ws = SIGNAL| ws = 0 |
第二次更新t3
的ws
:
|head(t2)| t3 | t4 | t5 |
|ws = 0 |ws=PROPAGATE|ws = SIGNAL| ws = 0 |
最后,在针对doReleaseShared()
方法中if(h==head) break;
这段代码谈谈自己的理解。
h==head
说明此时头节点并未被更新,我觉得可能的原因有两个:
第一点很好理解,就是之后唤醒的线程在tryAcquireShared()
失败或者AQS的阻塞队列中没有需要唤醒的节点了。此时让线程跳出循环,结束唤醒的工作。
第二个点就是,传播过程中唤醒的线程太多,某些线程得不到CPU运行时间,没有更新head
,此时让一部分线程结束,可以避免过多的线程参与唤醒工作。比如上述t1
和t2
线程的唤醒过程,假设机器是单核的,且t1
唤醒t2
后,t2
一直无法得到CPU运行时间,此时t1
判断h==head
成立,t1
就会从doReleaseShared()
方法中退出,让t2
负责唤醒就好了。
AQS中共享锁释放的主体流程
共享锁释放过程
同样的,AQS也已经规定了共享锁的释放过程。
public final boolean releaseShared(int arg) {
//先尝试以共享模式释放锁
if (tryReleaseShared(arg)) {
//如果成功,则唤醒等待节点
doReleaseShared();
return true;
}
return false;
}
其中的tryReleaseShared()
是由AQS的子类根据锁的特性去实现的,而doReleaseShared()
方法已经在AQS中定义好了,主要过程就是修改当前head
的ws,并且唤醒head
的后继节点。
其他共享锁释放过程的介绍
CountDownLatch
CountDownLatch
的countDown()
方法其实就是AQS的释放过程。
protected boolean tryReleaseShared(int releases) {
// Decrement count; signal when transition to zero
for (;;) {
//获取当前state
int c = getState();
//如果已经等于0 说明栅栏已经打开,不需要再释放
if (c == 0)
return false;
//释放一次
int nextc = c-1;
//采用CAS设置state值
if (compareAndSetState(c, nextc))
//如果设置成功,则看state是否已经等于0
return nextc == 0;
}
}
}
当state
值减为0时,就开始唤醒在await()
的线程。
Semaphore
Semaphore
归还凭证的过程也是释放共享锁的过程。一旦凭证被归还,就需要唤醒等待的节点,因此tryReleaseShared()
返回值必为true
。至于醒来的节点能够获取锁则由tryAcquireShared()
方法决定(归还的凭证是否够获取的次数)。
protected final boolean tryReleaseShared(int releases) {
for (;;) {
//当前state值
int current = getState();
int next = current + releases;
if (next < current) // overflow
throw new Error("Maximum permit count exceeded");
//采用CAS失败重试 更新state
if (compareAndSetState(current, next))
//如果CAS设置成功,则说明有凭证被归还,开始唤醒等待的节点
return true;
}
}
写锁的获取及释放
写锁是一个排他锁,整体获取和释放流程和ReentrantLock
比较接近。但是由于读写锁共用一个state
,因此有小部分特殊的逻辑存在。
写锁的获取过程
写锁的获取过程是委托给sync.acquire()
方法。
/**
* acquire方法会阻塞直到成功获取锁
*/
public final void acquire(int arg) {
//先通过tryAcquire尝试获取锁,如果获取成功,则上锁成功
//否则将在AQS维护的队列(链表)中,添加一个新的节点Node,AQS会在需要时从链表中选择一个结点唤醒
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个方法在之前介绍ReentrantLock
的上锁过程已经介绍过了,不再过多介绍。主要看一下tryAcquire()
方法。
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {//说明读锁或写锁至少一个被持有
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread()) //读锁被持有 或是 写锁被持有且非当前线程持有 返回获取失败
return false;
//上面的if没进则给了我们一个很重要的信息
//可以推出 => w !=0 && current == getExclusiveOwnerThread()
//说明接下来的部分是写锁的重入
if (w + exclusiveCount(acquires) > MAX_COUNT) //写锁共享次数溢出 抛出错误
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
//低16位的增加不需要特殊的处理
setState(c + acquires);
return true;
}
//这是说明c == 0 没有锁未被任务线程持有
//如果写请求需要被阻塞 或是 CAS设置state时失败 说明尝试获取写锁失败,需要被加入阻塞队列
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//这里说明获取写锁成功
//设置锁的排他性拥有线程
setExclusiveOwnerThread(current);
return true;
}
这个方法也可以分为两部分去理解:
第一个if(c != 0)
的判断内说明此时写锁或读锁中至少有一个被线程持有。 其中先返回false
的情况有两种:1)读锁被持有而写锁未被持有,由于锁无法升级因此返回false
。2)写锁被持有,当持有者非当前线程,由于写锁的排他性,返回false
。如果以上情况均不满足,说明此时是写锁重入,返回true
。
第二个条件说明此时没有线程持有锁,对非公平锁而言,此时可以争抢锁(公平锁需要视情况而言决定是否进入等待队列),日过CAS成功,则说明上锁成功,返回true
。反之返回false
。
AQS中独占锁获取的主体流程
这里在概括一下AQS获取锁的流程。
先通过tryAcqure()
方法尝试获取锁,如果尝试成功,则说明上锁成功,否则需要进入AQS的阻塞队列,等待唤醒后再次尝试。
写锁的释放过程
写锁的释放过程也和其他独占锁大体一致,都是通过AQS的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;
}
其中tryRelease()
是子类自己实现的:
//尝试释放锁
protected final boolean tryRelease(int releases) {
//先检查 避免 未持有锁的线程释放锁
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//这里之后的代码是线程安全的,因为只有锁持有者才能执行
//更新state
int nextc = getState() - releases;
//如果写锁完全被释放
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);//清空锁的持有线程
setState(nextc);//更新state
return free;//返回锁是否被完全释放
}
主要思路也是更新state
值,只是在更新state
时,只更新低16位,并根据低16的值判断写锁是否已经完全释放,如果释放成功,则需要唤醒后继的节点。
AQS中独占锁释放的主体流程
同样对AQS独占锁的释放过程做个回顾。
首先,tryRelease()
会更新state
值,然后根据锁是否被完全释放,返回true
或false
。当返回true
时需要通过doRelease()
负责唤醒AQS阻塞队列中的后继节点。
总结
这篇文章写的比较多比较杂。不仅介绍了ReentrantReadWriteLock
读写锁中读锁和写锁上锁和释放的过程(可中断的过程留给读者作为课后练习)。还因为读锁是一个共享锁,顺带介绍了其他利用AQS共享模式实现的“锁”。
此外,对AQS的排他锁和共享锁的上锁释放过程都做了概括,读下来会发现AQS是一个很具有对称特性的类,把握AQS的模板方法,能更好的帮助我们理顺锁控制的流程。
最后,针对ReentrantReadWriteLock
而言,两个比较重要的知识点是1)state
高16位用来表示读锁的获取次数,低16位用来表示写锁的获取次数;2)理解共享锁在唤醒时的蔓延性。
最后,更多的源码注释可以见我Github上的项目read-jdk。
ReentrantReadWriteLock及共享锁的实现的更多相关文章
- Java并发——显示锁
Java提供一系列的显示锁类,均位于java.util.concurrent.locks包中. 锁的分类: 排他锁,共享锁 排他锁又被称为独占锁,即读写互斥.写写互斥.读读互斥. Java的ReadW ...
- java高并发总结-常用于面试复习
定义: 独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性. 共享锁则是一种乐观锁, ...
- Java并发编程系列-(4) 显式锁与AQS
4 显示锁和AQS 4.1 Lock接口 核心方法 Java在java.util.concurrent.locks包中提供了一系列的显示锁类,其中最基础的就是Lock接口,该接口提供了几个常见的锁相关 ...
- Java 中的各种锁和 CAS + 面试题
Java 中的各种锁和 CAS + 面试题 如果说快速理解多线程有什么捷径的话,那本文介绍的各种锁无疑是其中之一,它不但为我们开发多线程程序提供理论支持,还是面试中经常被问到的核心面试题之一.因此下面 ...
- 一篇blog带你了解java中的锁
前言 最近在复习锁这一块,对java中的锁进行整理,本文介绍各种锁,希望给大家带来帮助. Java的锁 乐观锁 乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人 ...
- 一篇博客带你轻松应对java面试中的多线程与高并发
1. Java线程的创建方式 (1)继承thread类 thread类本质是实现了runnable接口的一个实例,代表线程的一个实例.启动线程的方式start方法.start是一个本地方法,执行后,执 ...
- Java多线程系列--“JUC锁”08之 共享锁和ReentrantReadWriteLock
概要 Java的JUC(java.util.concurrent)包中的锁包括"独占锁"和"共享锁".在“Java多线程系列--“JUC锁”02之 互斥锁Ree ...
- Java锁--共享锁和ReentrantReadWriteLock
转载请注明出处:http://www.cnblogs.com/skywang12345/p/3505809.html ReadWriteLock 和 ReentrantReadWriteLock介绍 ...
- 多线程编程-- part 6 共享锁和ReentrantReadWriteLock
介绍: ReadWriteLock,顾名思义,是读写锁.它维护了一对相关的锁 — — “读取锁”和“写入锁”,一个用于读取操作,另一个用于写入操作.(1)“读取锁”用于只读操作,它是“共享锁”,能同时 ...
随机推荐
- PTA数据结构与算法题目集(中文) 7-4
PTA数据结构与算法题目集(中文) 7-4 是否同一颗二叉搜索树 给定一个插入序列就可以唯一确定一棵二叉搜索树.然而,一棵给定的二叉搜索树却可以由多种不同的插入序列得到.例如分别按照序列{2, 1, ...
- 数据结构和算法(Golang实现)(30)查找算法-2-3-4树和普通红黑树
文章首发于 阅读更友好的GitBook. 2-3-4树和普通红黑树 某些教程不区分普通红黑树和左倾红黑树的区别,直接将左倾红黑树拿来教学,并且称其为红黑树,因为左倾红黑树与普通的红黑树相比,实现起来较 ...
- Go golang语言特性
一.垃圾回收 1.内存自动回收. 2.只需要创建,不需要释放 二.天然并发: 1.语言层支持并发,对比python,少了GIL锁. 2.goroute,轻量级线程. 3.基于CSP模型实现 三.cha ...
- 路由与交换,cisco路由器配置,基础知识点(二)
1.进退用户/特权/全局模式 (1)从用户模式进入特权模式 enable (2)从特权模式进入全局配置模式 configure terminal (3)从其他模式回到特权模式 end (4)从特权模式 ...
- Tomcat目录解析
bin 可执行文件的储存 conf 配置文件 lib 依赖jar包 logs 日志文件 temp 临时文件 webapps 创建的web应用程序 work 存放运行时数据 如何启动Tomcat? 启动 ...
- CSS 布局水平 & 垂直对齐
元素居中对齐 margin: auto; 文本居中对齐 text-align: center; 图片居中对齐 要让图片居中对齐, 可以使用 margin: auto; 并将它放到 块 元素中 左右对齐 ...
- Altium Designer 3D
- Node.js 的事件循环机制
目录 微任务 事件循环机制 setImmediate.setTimeout/setInterval 和 process.nextTick 执行时机对比 实例分析 参考 1.微任务 在谈论Node的事件 ...
- Daily Scrum 1/5/2015
Process: Zhaoyang: Fix some crash bugs and increase the program stability. Yangdong: Complete some b ...
- ASE past project:interview & analysis
采访往届ASE课程学员李潇,他所在的团队blog戳这里http://www.cnblogs.com/smart-code/ Q1:师兄你觉得在团队项目中,有哪些需要注意的事情? A1:团队合作吧.首先 ...