ReentrantReadWriteLock可重入读写锁,同样是基于AQS实现的。与ReentrantLock区别就是读写分离,将锁的粒度都将细化,提升性能。在共享数据读写操作中,读操作远远超过写操作的次数,那么可以理解为共享数据在大部分时间是不变的。synchronized和ReentrantLock作为互斥锁,用于这种场景明显会降低系统的性能。因此,读写分离的重入锁ReentrantReadWriteLock就出现了。其实,synchronized内置锁后,接连出现ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore等接连的支持,本身就是为了针对某个场景做了特殊的优化,提升性能支持的。
ReentrantReadWriteLock特点是:读-读并行、读-写互斥、写-写互斥;支持可重入;支持锁降级、不支持锁升级;实现Lock接口,所以支持tryLock、lockInterruptible、lock等;写锁支持条件变量,读锁不支持条件变量;
应用场景:读多写少,减少锁的互斥性
来个简单的例子:
public class TestReentrantReadWriteLock {
private Map<String, String> cache = new HashMap<>();
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock readLock = readWriteLock.readLock();
private Lock writeLock = readWriteLock.writeLock();
public void put(String key, String value) {
writeLock.lock();
try {
cache.put(key, value);
} finally {
wirteLock.unlock();
}
}
public String get(String key) {
readLock.lock();
try {
return cache.getOrDefault(key, "0");
} finally {
readLock.unlock();
}
}
}
分析源码:
ReentrantReadWriteLock实现ReadWriteLock接口
public interface ReadWriteLock {
//返回读锁
Lock readLock();
//返回写锁
Lock writeLock();
}
在ReentrantLock中,提供readLock和writeLock的实现方法。构造函数提供支持公平锁和非公平锁,默认为非公平锁。
//field
//ReentrantLock内部类实现的ReadLock、WriteLock
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
//method
//返回对应的读写锁
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
//构造函数
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
读锁(锁资源共享):
public void lock() {
//sync是基于AQS实现的公平锁或非公平锁
sync.acquireShared(1);
}
tryAcquireShared(arg)整体思路
1、判断当前线程是否可以尝试获取锁?
int state高16位表示读,低16位表示写。
当有写锁时:根据读写互斥的特点,低16位有值时,表示存在写锁持有锁,读锁自然获取失败,返回-1;又根据支持锁降级(持有写锁时,再尝试获取读锁),因此,当锁被非当前线程的写锁持有时,当前线程会直接返回-1;
当存在读锁时:支持公共锁和非公共锁
当支持公平锁时,双向链表为空或当前线程在head头时可以尝试获取锁
当支持非公平锁时,head头的节点是一个写锁的获取节点(下述逻辑判断时,默认已经是非当前线程的写锁)。非公平锁支持这点的原因,根他的应用场景是一致的。读锁写少,当不支持时,非常容易导致写锁饿死。
当锁没有被写锁持有,而cas设置失败或支持公平锁时存在其他节点等情况时,当前线程会循环上述流程:写锁判断、是否阻塞、是否获取等逻辑,直到返回-1(存在写锁且非当前线程)或循环上述逻辑。
下述的代码逻辑基本也就是上面的思路了。
//tryAcquireShrad尝试获取读锁
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
//为什么写锁被非当前线程持有时,才会返回呢?读-写不是互斥吗?
//假如:某个线程获取到写锁后,业务处理仍然需要读取某些共享数据时,同线程写-读互斥不就完了。
//这行代码体现了ReentrantReadWriteLock支持锁降级特性
//return -1,则该节点将在AQS获取共享锁框架中入队,如果是head后继则会再次尝试
//调用acquiredShared获取锁;非head后继则尝试park
//注意:1、ReentrantReadWriteLock不支持锁升级(拥有读锁申请写锁)
// 2、在锁升级后,释放写锁,其他线程读锁线程就可以获取读锁
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c);
//readerShouldBlock()在公平锁和非公平锁中提供的策略是不一样的。
//非公平锁中,head后继节点是EXCLUSIVE类型的,说明存在写锁获取锁,在读写锁中且非公平锁条件下,不阻塞读锁,会导致写锁饥饿,除过这种场景外,其他场景都不会阻塞读锁获取
//公平锁中,如果双向链表为空、(非空&&head后继==null)、(非空&&head后继是当前线程)时,return false,可以尝试获取锁资源
//SHARED_UNIT为65536,读写锁是将int state属性值得高16位作为读锁,低16位作为写锁
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//r==0表示读锁为0,没有线程获取读锁
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
//r!=0且firstReader==current说明firstReaderHoldCount是自己重入次数
} else if (firstReader == current) {
firstReaderHoldCount++;
//r!=0且firstReader!=current则需要获取自身的重入次数
} else {
HoldCounter rh = cachedHoldCounter;
//rh!=null&&rh.tid跟cachedHoldeCounter不一样说明上个读锁线程不是自己,则通过ThreadLocal获取自己的重入次数
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
//rh!=null&&rh.tid==current,说明就是自己了
else if (rh.count == 0)
readHolds.set(rh);
//重入次数++
rh.count++;
}
return 1;
}
//当读锁判断后需要阻塞时
return fullTryAcquireShared(current);
}
下述逻辑基本类似与上面的代码,1、是否自身占有 2、是否需要阻塞,如果需要则再次判断它是否重入,如果重入的话就不应该阻塞了 3、尝试获取锁
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
for (;;) {
int c = getState();
//持有写锁的线程不是当前线程,返回-1.后续加入双向链表中
if (exclusiveCount(c) != 0) {
if (getExclusiveOwnerThread() != current)
return -1;
//没有互斥写锁,但是需要根据公平或非公平锁判断是否需要阻塞
//1、是公平锁时,前置节点非当前线程,但是正在执行的线程是当前线程,可以尝试获取
//2、非公平锁时,当head后继节点是互斥锁时,需要阻塞,但是正在执行的线程是当前线程,则可以尝试获取锁。如:写操作时,需要读锁获取某个值,拒绝读锁获取数据岂不是就死锁了
} else if (readerShouldBlock()) {
// Make sure we're not acquiring read lock reentrantly
if (firstReader == current) {
// 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;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//尝试获取读锁,并return 1
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;
}
}
}
从上述代码可以看出,不管是否是公平锁,有一个问题是例外。如果当前正在执行的已拥有读锁或写锁的线程再次获取读锁时,都需要循环获取读锁,否则读锁一直获取不到读锁加入AQS链表中,那么该线程将会阻塞,严重影响性能。
如果锁被非当前线程的写锁持有或者公平锁场景下线程第一次获取读锁时,进入下述逻辑。
加入双向链表,1、如果是head后继则尝试获取锁,获取成功则唤醒下一个节点(保证读的并行),下一个节点开始尝试获取锁;2、保证node前置状态为SIGNAL,park当前线程并检测中断状态
private void doAcquireShared(int arg) {
//将node节点加入AQS链表
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
//线程没有被阻塞或没有获取到锁,一直循环
for (;;) {
//加入到链表后,前驱接点是head,说明它是第一个元素,则尝试获取锁
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);
}
}
读锁到这块差不多结束了,看得头昏脑涨的……
写锁结构跟ReentrantLock完全一样啊,当然tryAcquire需要子类自己实现了。首先尝试获取锁,获取失败进入链表,再判断是否为head后继,如果是则再次tryAcquire尝试获取锁,如果不是则看前继节点是否能安全的park,并检测中断属性值。
//写锁结构跟ReentrantLock完全一样,只需要关注tryAcquire(arg)即可
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();
//锁的状态
int c = getState();
//互斥锁的状态
int w = exclusiveCount(c);
//state!=0说明,写锁前有读锁或写锁申请了锁资源
if (c != 0) {
//有锁,w==0说明,上次申请锁的是读锁;current!=线程持有锁不是当前线程
//线程持有锁只会是写锁设置的
//w==0表示了state是由读锁设置的,不支持锁升级,直接返回false
if (w == 0 || current != getExclusiveOwnerThread())
//获取失败时,又到了进入双向链表,是否为head的后继尝试获取锁的流程
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//w!=0&¤t==getExclusveOwnerThread就是写锁重入了
setState(c + acquires);
return true;
}
//此处state==0,没有任何线程持有锁
//writerShouldBlock是公平锁和非公平锁都实现的方法
//非公平锁直接返回false,公平锁则判断空链表或head后继是当前线程返回false;
//cas设置state值
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//设置当前线程独占锁
setExclusiveOwnerThread(current);
return true;
}
Condition:
只有写锁支持Condition,读锁会抛出UnsupportedOperationException异常,读锁不支持Condition的原因,个人理解跟读写锁的应用场景有关,支持高并发的读。读之间都要支持Condition的话,读写锁实现会非常复杂,而且又丢了读写锁的应用场景。
读锁和写锁释放锁的逻辑就不细读了,大致就是:一直尝试释放锁,然后唤醒head后继节点,接着让它尝试获取锁的过程。
结合上述源码分析,再来总结下读写锁的特点:
线程获取读锁时:
1、当不存在写锁时,多线程间都可以获取读锁。当然在公平锁和非公平锁状态下,稍微不一样而已,但不会阻塞
2、当存在写锁时,线程与写锁是同一个线程,可以获取到读锁;如果不是同一线程,则获取失败
线程获取写锁时:
1、存在读锁则获取失败
2、存在写锁时,如果是同一个线程,则可重入获取到写锁
基于上述实现逻辑,具备下述使用特点:
1、可重入
2、读读并行、读写互斥、写写互斥
3、支持锁降级、不支持锁升级
4、读锁不存在条件变量,写锁具备条件变量
应用场景:读多写少