大家好,我是程序员牛肉。
有一说一,这个类的名字怎么这么长!看的我头都大了。但是吧这个类所有的设计思路都体现在名字中了。
ReentrantReadWriteLock,我说白了我们直接看翻译:
嗷,原来这玩意就是一个支持可重入的读写锁而已。一想到Reentrant我就想到了ReentrantLock。
ReentrantLock有什么特性?支持构造公平锁和非公平锁吧。
我都说了这些类的名字不可能乱起,所以ReentrantReadWriteLock也支持构造公平锁和非公平锁。
所以当我们查看这个类的构造函数的时候,就可以发现:
ReentrantReadWriteLock确实支持以参数的形式来选择构造公平锁还是非公平锁。并且默认选择构造非公平锁。
一看到sync就放心多了,sync是继承自AQS的一个工具类。所以ReentrantReadWriteLock本质上还是在基于AQS来实现的。但是我们这篇文章不讲AQS了,这篇文章我们先死咬ReentrantReadWriteLock来聊,后面我一定会详细的讲一讲AQS。
从ReentrantReadWriteLock的构造函数中,我们可以看到这个锁在初始化的时候,内部实际上创建了两个锁:读锁和写锁。
而这两个锁本质上走的还是AQS的那一套逻辑。
这里最有意思的是ReentrantReadWriteLock如何使用一个AQS状态变量同时表示读锁和写锁的状态?
在普通的ReentrantLock中,AQS的state变量表示锁被重入的次数。但在ReentrantReadWriteLock中,需要同时跟踪读锁和写锁的状态,这就需要一些巧妙的设计了。
具体来说,ReentrantReadWriteLock使用AQS的state变量(一个32位long型)的高16位表示读锁的持有数量,低16位表示写锁的持有数量:
这种设计非常巧妙,通过位操作,可以在一个变量中同时存储两种状态,并且可以原子地更新这个变量。
读锁的获取和释放使用AQS的共享模式(shared mode),写锁的获取和释放使用AQS的独占模式(exclusive mode)。这正好对应了读锁可以被多个线程同时持有,而写锁只能被一个线程持有的语义。
当线程尝试获取读锁时,会调用AQS的acquireShared方法,最终会调用到tryAcquireShared方法:
protected final long tryAcquireShared(long unused) {
Thread current = Thread.currentThread();
long c = getState();
// 如果写锁被其他线程持有,则获取读锁失败
if (exclusiveCount(c) != && getExclusiveOwnerThread() != current)
return -1L;
int r = sharedCount(c);
// 如果读线程不应该被阻塞,且读锁数量未达到最大值,且CAS更新状态成功
if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) {
// 各种读锁计数的维护逻辑
if (r == ) {
firstReader = current;
firstReaderHoldCount = ;
} elseif (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != LockSupport.getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
elseif (rh.count == )
readHolds.set(rh);
rh.count++;
}
return1L;
}
// 处理CAS失败或其他情况
return fullTryAcquireShared(current);
}在获取读锁的时候,我们先获取当前的进入方法的线程,在调用exclusiveCount来判断写锁是否有被获取并且获取写锁的线程是不是当前线程:
当写锁没有被获取的时候,会继续执行后续的逻辑,主要是在进行各种优化措施。因为读操作不会对数据进行任何修改,所以我们要优化这里的锁操作,使得其性能更加接近于无锁。
1.如果当前读锁计数器为0的话,就直接进行更新
2.如果当前读锁被同一个线程重入了,就就对计数器做++
3.如果当前读锁被不同的线程获取了,那就获取线程级别的计数器来做统计
当获取读锁失败的时候,他会进入fullTryAcquired方法中进行重试,代码和tryAcquireShared没有大的区别,就是有了一个写死的for循环来做自旋操作。
那获取了读锁之后,如何释放读锁呢?
其实就是和获取锁的时候执行相反的步骤,之前是给state++,那现在就--.因此不赘述了。
看完了读锁之后,我们再来看看写锁:
写锁是排他锁,同一时刻只能有一个线程持有写锁。WriteLock类也实现了Lock接口,同样将核心功能委托给Sync类。
让我们来看看写锁是如何获取锁的,核心代码在tryAcquire中:
@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != ) {
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == || current != getExclusiveOwnerThread())
returnfalse;
if (w + exclusiveCount(acquires) > MAX_COUNT)
thrownew Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
returntrue;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
returnfalse;
setExclusiveOwnerThread(current);
returntrue;
}
看完了前面的读锁之后,这个写锁的获取锁逻辑有啥好讲的?
写锁的获取逻辑也很清晰:如果已经有读锁被持有(不是当前线程持有的写锁),或者写锁被其他线程持有,则获取失败如果是当前线程已经持有写锁,则增加重入计数如果当前没有锁被持有,则检查是否应该阻塞,如果不应该阻塞,则尝试通过CAS更新状态如果CAS成功,则设置独占线程为当前线程,返回成功
释放写锁的逻辑在tryRelease方法中:
释放写锁的逻辑比较简单:减少写锁的计数,如果计数为0,则清除独占线程。
在读写锁之间,ReentrantreadWriteLock支持锁降级机制,即持有写锁的线程可以在不释放写锁的情况下获取读锁,然后释放写锁,降级为读锁状态。
那为什么为什么需要锁降级?
我们可以考虑这样一个场景:线程A需要更新一个缓存,然后立即读取这个缓存。如果线程A先释放写锁,再获取读锁,那么在这两个操作之间,可能有其他线程修改了缓存,导致线程A读取到的数据与它刚刚写入的数据不一致。通过锁降级,线程A可以保证在读取数据时,数据与它刚刚写入的数据一致
具体的逻辑实现代码在tryAcquireShared中:
这行代码检查如果写锁被持有,但持有者不是当前线程,则获取读锁失败。反过来说,如果写锁被当前线程持有,是可以获取读锁的,这就是锁降级的基础。
注意,ReentrantReadWriteLock不支持锁升级(从读锁升级到写锁)。如果一个线程持有读锁,想要获取写锁,必须先释放读锁,再获取写锁。这是因为如果支持锁升级,可能会导致死锁:如果两个线程都持有读锁,都想升级到写锁,那么它们会互相等待对方释放读锁,形成死锁。
讲完了读锁和写锁之后,我们再来聊一聊公平和非公平是怎么实现的:
当我们选择了公平锁之后,就会在内部多出来一个队列。在每一次获取锁的时候都调用hasQueuedPredecessors()方法来检查是否有其他线程等待时间更长。如果有,当前线程就应该被阻塞,让等待时间更长的线程先获取锁。
而非公平锁则有点不一样,当我们构造出来一个非公平锁的时候,是这样构造的:
在这个非公平锁中,写线程总是可以尝试获取锁(返回false表示不应该阻塞),而读线程则需要检查队列头部是否是一个等待的写线程。如果是,读线程应该阻塞,让写线程有机会获取锁,避免写线程饥饿。
这是一种有趣的折中:完全的非公平可能导致写线程饥饿(因为总有新的读线程插队获取读锁),而完全的公平又可能导致性能下降。所以ReentrantReadWriteLock采用了一种"半公平"策略:写线程可以随时插队,读线程则需要考虑是否会导致写线程饥饿。
在Java 8之后,引入了一个新的读写锁实现:StampedLock,它提供了乐观读的功能,在某些场景下性能更好。如果你使用的是Java 8或更高版本,可以考虑使用StampedLock。后续我也会写相关的文章来解读源码。
总的来说,ReentrantReadWriteLock是一个设计精巧、功能强大的并发工具,掌握它的使用可以帮助我们写出更高效、更健壮的并发程序。
那今天关于ReentrantReadWriteLock的源码解读就到这里了,相信通过我的介绍,你已经大致理解了它的设计思路。