🏷️个人主页:牵着猫散步的鼠鼠
🏷️系列专栏:Java源码解读-专栏
🏷️个人学习笔记,若有缺误,欢迎评论区指正
目录
1. 前言
2. 读写锁是什么
3. ReentrantReadWriteLock是什么
4. 源码解读
4.1. ReadLock
4.2. WriteLock
5. 基本使用
6.性能测试
7. 总结
1. 前言
最近还在持续阅读JUC包下各种类的源码,JUC包下的每个类设计都十分巧妙,推荐小伙伴们去阅读下,一定会有不少收获的。
假如有人问你用过哪些读写锁,你会怎么回答呢,ReentrantLock和synchronized?实际上ReentrantLock和synchronized是互斥锁而不是读写锁,主要是为了确保对共享资源的互斥访问。
如果对读写锁部署,以下是一个参考回答:
在我的项目中,我主要使用 ReentrantLock 来确保对共享资源的互斥访问。虽然我没有直接使用过 ReentrantReadWriteLock,但我了解到它是 Java 提供的一个高级同步机制,特别适用于读多写少的场景。它维护了一对锁,一个用于读操作,允许多个线程同时读取资源,另一个用于写操作,确保在写入时独占访问。如果在未来遇到适合的场景,我会考虑使用 ReentrantReadWriteLock 来提高系统的并发性能
Java提供了两个读写锁类,分别是ReentrantReadWriteLock和StampedLock,ReentrantReadWriteLock就是我们今天要注重讲解的内容,StampedLock下次再更(鼠鼠肝不动了)。
2. 读写锁是什么
我们以往的学习旅程中,我们已经接触了如 synchronized 和 ReentrantLock 这样的互斥锁。这类锁的主要优势在于它们确保了线程的安全性,但它们的局限性在于同一时间仅允许一个线程持有锁,这在一定程度上降低了处理效率。另一方面,我们之前探讨的 Semaphore 虽然允许多个线程同时获取许可,但在保障线程安全方面表现不足。我们寻求的是一种既高效又安全的同步机制。
在实际应用场景中,数据读取操作的频率往往远高于写入操作。因此,富有远见的开发者们设计了一种新型锁——读写锁。在这种锁的设定下,读取数据时采用共享模式,允许多个线程同时持有读锁;而在写入数据时,为了确保线程安全,则切换到独占模式,确保同一时刻只有一个线程能够持有写锁。这样的设计理念催生了读写锁,它旨在提高并发性能的同时,不牺牲安全性。
3. ReentrantReadWriteLock是什么
ReentrantReadWriteLock是ReadWriteLock 接口的默认实现类,从名字可以看得出它也是一种具有可重入性的锁,同时也支持公平与非公平的配置,底层有两把锁,一把是 WriteLock (写锁),一把是 ReadLock(读锁) 。读锁是共享锁,写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有,也是基于AQS实现的底层锁获取与释放逻辑。
ReentrantReadWriteLock类内部的组成架构图如下:
4. 源码解读
我首先抽取了ReentrantReadWriteLock类中的核心源码,如下:
// 内部结构
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
/*1、用以继承AQS,获得AOS的特性,以及AQS的钩子函数*/
abstract static class Sync extends AbstractQueuedSynchronizer {
// 具体实现
}
/*非公平模式,默认为这种模式*/
static final class NonfairSync extends Sync {
// 具体实现
}
/*公平模式,通过构造方法参数设置*/
static final class FairSync extends Sync {
// 具体实现
}
/*读锁,底层是共享锁*/
public static class ReadLock implements Lock, java.io.Serializable {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
// 具体实现
}
/*写锁,底层是独占锁*/
public static class WriteLock implements Lock, java.io.Serializable {
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
// 具体实现
}
// 构造方法,初始化两个锁
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
// 获取读锁和写锁的方法
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
上面为底层的主要构造内容,ReentrantReadWriteLock中共写了5个静态内部类,各有功效,在上面的注释中也有提及。
其中Sync,FairSync,NonFairSync在我们前面的文章时经常涉及到,大概都是Sync继承AQS,获得AQS的特性,然后实现AQS的钩子函数来自定义获取锁和释放锁的逻辑。FairSync和NonFairSync就是在Sync基础上加入了公平和非公平的特性。这三个类我们就不细讲了,我们着重看 ReadLock 和 WriteLock ,也就是读锁和写锁。
4.1. ReadLock
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 void lock() {
sync.acquireShared(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
public boolean tryLock() {
return sync.tryReadLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.releaseShared(1);
}
}
ReadLock借助Sync来实现锁的获取与释放,可以通过构造函数传参来判断使用FairSync还是NonFairSync。
lock方法通过acquireShared共享方式来获取资源,深入acquireShared方法,发现里面调用了AQS的钩子函数acquireShared()
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
钩子函数acquireShared() 的实现在Sync,如下:
abstract static class Sync extends AbstractQueuedSynchronizer {
protected final int tryAcquireShared(int unused) {
// 1.获取当前线程,当前锁的状态(state值,0即为没人持锁)
Thread current = Thread.currentThread();
int c = getState();
// 2.如果锁被占了(state!=0),且持有锁的线程不是当前线程,返回-1,获取锁失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 3.获取共享锁持有数量
int r = sharedCount(c);
// 4.调用readerShouldBlock()判断是否要排队(如果非公平就返回false)
if (!readerShouldBlock() &&
r < MAX_COUNT &&
// 5.获取锁(CAS操作修改state值)
compareAndSetState(c, c + SHARED_UNIT)) {
// 如果读锁计数从0变为1,记录当前线程为第一个读线程,并设置其持有计数为1。
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 如果当前线程已经是第一个读线程,增加其持有计数。
firstReaderHoldCount++;
} else {
// 如果当前线程不是第一个读线程,则更新持有计数。
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 5.上述步骤中的任何条件都失败了,就进行完整的尝试获取读锁的循环,包括处理重入获取的情况。
return fullTryAcquireShared(current);
}
}
-
检查是否有写锁被其他线程持有:
- 使用getState()获取当前锁的状态。
- 调用exclusiveCount(c)检查是否有独占锁(写锁)被持有(即状态字段的低16位是否为0)。
- 如果有独占锁且持有独占锁的线程不是当前线程,则返回-1,表示获取读锁失败。
-
检查是否应该阻塞:
- 调用readerShouldBlock()方法来确定当前线程是否应该因为锁的队列策略而阻塞。这通常是基于公平性策略来决定的,如果是非公平模式,通常返回false。
- 检查当前读锁的计数r是否小于最大值MAX_COUNT。
-
尝试更新状态:
- 使用compareAndSetState(c, c + SHARED_UNIT)尝试通过CAS操作增加读锁的计数(状态字段的高16位)。如果成功,则表示获取读锁成功。
- 如果读锁计数从0变为1,记录当前线程为第一个读线程,并设置其持有计数为1。
- 如果当前线程已经是第一个读线程,增加其持有计数。
- 如果当前线程不是第一个读线程,则更新cachedHoldCounter或readHolds中的持有计数。
-
如果尝试失败:
- 如果上述步骤中的任何条件失败(如应该阻塞、CAS操作失败、读锁计数饱和等),则调用fullTryAcquireShared(current)方法,这个方法会进行完整的尝试获取读锁的循环,包括处理重入获取的情况。
总之,tryAcquireShared方法是一个尝试快速获取读锁的方法,它会尽可能地避免阻塞,并在可能的情况下立即返回。如果快速路径失败,它会调用fullTryAcquireShared方法进行更全面的尝试。
4.2. WriteLock
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;
}
public void lock() {
sync.acquire(1);
}
public void lockInterruptibly() throws InterruptedException {
sync.acquireInterruptibly(1);
}
public boolean tryLock( ) {
return sync.tryWriteLock();
}
public boolean tryLock(long timeout, TimeUnit unit)
throws InterruptedException {
return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}
public void unlock() {
sync.release(1);
}
public Condition newCondition() {
return sync.newCondition();
}
public String toString() {
Thread o = sync.getOwner();
return super.toString() + ((o == null) ?
"[Unlocked]" :
"[Locked by thread " + o.getName() + "]");
}
public boolean isHeldByCurrentThread() {
return sync.isHeldExclusively();
}
public int getHoldCount() {
return sync.getWriteHoldCount();
}
}
lock方法通过acquire独占方式来获取资源,深入acquire方法,里面调用了AQS的钩子函数tryAcquire()
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
同上,钩子函数acquireShared() 的实现在Sync,如下:
abstract static class Sync extends AbstractQueuedSynchronizer {
protected final boolean tryAcquire(int acquires) {
// 1.获取当前线程和锁的状态
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
// 2.如果锁被持有(state != 0)
if (c != 0) {
// 3.如果写锁计数w为0,则表示读锁被持有,
// 此时获取写锁失败,返回false。
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 4.重入次数加上acquires参数后超过了最大计数MAX_COUNT,则抛出错误
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 5.当前线程持有锁,state+1即可,保持锁的重入性
setState(c + acquires);
return true;
}
// 6.如果writerShouldBlock()方法返回true,表示当前线程应该因为锁的队列策略而阻塞,则返回false。否则通过CAS操作获取锁
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
}
-
检查锁的状态:
- 使用getState()获取当前锁的状态。
- 使用exclusiveCount(c)获取状态字段的低16位,即写锁的计数。
- 如果锁的状态
c
不为0,则表示锁已经被持有。
-
处理锁已被持有的情况:
- 如果写锁计数w为0,则表示读锁被持有,或者写锁被其他线程持有,此时获取写锁失败,返回false。
- 如果当前线程不是持有写锁的线程,则获取写锁失败,返回false。
- 如果当前线程已经持有写锁,并且重入次数加上acquires参数后超过了最大计数MAX_COUNT,则抛出错误,因为超过了锁的最大重入次数。
- 如果当前线程已经持有写锁,则增加写锁的计数(重入锁),更新状态,并返回true。
-
尝试获取锁:
- 如果writerShouldBlock()方法返回true,表示当前线程应该因为锁的队列策略而阻塞,则返回false。
- 使用compareAndSetState(c, c + acquires)尝试通过CAS操作更新状态来获取写锁。如果成功,则表示获取写锁成功。
- 如果CAS操作成功,设置当前线程为锁的持有者setExclusiveOwnerThread(current),并返回true。
5. 基本使用
那么这个读写锁如何使用呢?我们接下来通过一个小小的案例来示范下。
public class Test {
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private int data = 0;
/**
* 写方法
* @param value
*/
public void write(int value) {
//注意,获取锁的操作要在try/finally外面
lock.writeLock().lock(); // 获取写锁
try {
data = value;
System.out.println("线程:"+Thread.currentThread().getName() + "写" + data);
} finally {
lock.writeLock().unlock(); // 释放写锁
}
}
public void read() {
lock.readLock().lock(); // 获取读锁
try {
System.out.println("线程:" + Thread.currentThread().getName() + "读" + data);
} finally {
lock.readLock().unlock(); // 释放读锁
}
}
public static void main(String[] args) {
Test test = new Test();
// 创建读线程
Thread readThread1 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
test.read();
}
});
Thread readThread2 = new Thread(() -> {
for (int i = 0; i < 5; i++) {
test.read();
}
});
// 创建写线程
Thread writeThread = new Thread(() -> {
for (int i = 0; i < 5; i++) {
test.write(i);
}
});
readThread1.start();
readThread2.start();
writeThread.start();
try {
readThread1.join();
readThread2.join();
writeThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
输出结果为:
线程:Thread-0读0
线程:Thread-1读0
线程:Thread-2写0
线程:Thread-2写1
线程:Thread-2写2
线程:Thread-2写3
线程:Thread-2写4
线程:Thread-0读4
线程:Thread-1读4
线程:Thread-0读4
线程:Thread-1读4
线程:Thread-0读4
线程:Thread-1读4
线程:Thread-0读4
线程:Thread-1读4
通过输出内容,我们进一步得证,在ReentrantReadWriteLock在使用读锁时,可以支持多个线程获取读资源,而在调用写锁时,其他读线程和写线程均阻塞等待当前线程写完。
6.性能测试
既然都说读写锁能够提高并发性能,接下来我们就测试以下,测试代码已同步到仓库:Concurrent-MulThread/7-lock-performance-test(github.com)
public class LockPerformanceTest {
private static final int READ_THREADS = 10; //读操作线程数
private static final int WRITE_THREADS = 2; //写操作线程数
private static final int ITERATIONS = 100000; // 操作次数
private static final Lock reentrantLock = new ReentrantLock();
private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private static final Lock readLock = readWriteLock.readLock();
private static final Lock writeLock = readWriteLock.writeLock();
private static int sharedResource = 0;
public static void main(String[] args) throws InterruptedException {
long startTime, endTime;
// 测试 ReentrantLock
startTime = System.currentTimeMillis();
testReentrantLock();
endTime = System.currentTimeMillis();
System.out.println("ReentrantLock time: " + (endTime - startTime) + " ms");
// 重置共享资源
sharedResource = 0;
// 测试 ReentrantReadWriteLock
startTime = System.currentTimeMillis();
testReentrantReadWriteLock();
endTime = System.currentTimeMillis();
System.out.println("ReentrantReadWriteLock time: " + (endTime - startTime) + " ms");
}
private static void testReentrantLock() throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(READ_THREADS + WRITE_THREADS);
for (int i = 0; i < READ_THREADS; i++) {
new Thread(() -> {
try {
barrier.await();
for (int j = 0; j < ITERATIONS; j++) {
reentrantLock.lock();
// 读取共享资源
int value = sharedResource;
reentrantLock.unlock();
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
for (int i = 0; i < WRITE_THREADS; i++) {
new Thread(() -> {
try {
barrier.await();
for (int j = 0; j < ITERATIONS; j++) {
reentrantLock.lock();
// 写入共享资源
sharedResource++;
reentrantLock.unlock();
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
private static void testReentrantReadWriteLock() throws InterruptedException {
CyclicBarrier barrier = new CyclicBarrier(READ_THREADS + WRITE_THREADS);
for (int i = 0; i < READ_THREADS; i++) {
new Thread(() -> {
try {
barrier.await();
for (int j = 0; j < ITERATIONS; j++) {
readLock.lock();
// 读取共享资源
int value = sharedResource;
readLock.unlock();
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
for (int i = 0; i < WRITE_THREADS; i++) {
new Thread(() -> {
try {
barrier.await();
for (int j = 0; j < ITERATIONS; j++) {
writeLock.lock();
// 写入共享资源
sharedResource++;
writeLock.unlock();
}
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
}
}
输出结果为:
ReentrantLock time: 31 ms
ReentrantReadWriteLock time: 1 ms
可以看到在都保证了线程安全的情况下,ReentrantReadWriteLock比ReentrantLock快了不少,ReentrantReadWriteLock性能这么快,那么有啥缺点呢?答案是有的:
- 不支持分布式:单机锁的通病,这个没办法
- 线程饥饿问题:在写的时候,是独占模式,其他线程不能读也不能写,这时候若有大量的读操作的话,那这些线程也只能等待着,从而带来写饥饿。
在另一个读写锁工具类StampedLock中就解决了饥饿问题,下次再讲解
7. 总结
ReentrantReadWriteLock是 Java 提供的一个高级同步机制,特别适用于读多写少的场景。它维护了一对锁,一个用于读操作,允许多个线程同时读取资源,另一个用于写操作,确保在写入时独占访问。相比于ReentrantLock 直接锁读写会有更细的锁粒度,提高读写的并发性能,但也存在线程饥饿问题,也就是在写的时候,其他线程不能读也不能写,这时候若有大量的读操作的话,就会让很多线程等待,造成饥饿问题,在StampedLock中解决了这个问题,下次讲解。
此外,博主祝您五一小长假快乐~~