目录
- ReentrantReadWriteLock详解
- 1、ReentrantReadWriteLock简介
- 2、ReentrantReadWriteLock类继承结构和类属性
- 3、ReentrantReadWriteLock的读写锁原理分析
- 4、ReentrantReadWriteLock.WriteLock类的核心方法详解
- 非公平写锁的获取
- 非公平写锁的释放
- 公平写锁的获取
- 公平写锁的释放
- 5、ReentrantReadWriteLock.ReadLock类的核心方法详解
- 非公平读锁的获取
- 非公平读锁的释放
- 公平读锁的获取
- 公平读锁的释放
- 6、读写锁的使用注意事项
- 利用锁降级保证可见性和效率的做法
- 举例:
- 谈一下 Single Threaded Execution模式
- 谈一下Read-Write Lock模式
- 7、总结
ReentrantReadWriteLock详解
1、ReentrantReadWriteLock简介
ReentrantReadWriteLock
是 Java 并发包中的一个类,这个类的字面意思是可重入的读写锁。 在关于ReentrantLock这篇文章中 对ReentrantLock进行了详细说明,ReentrantLock是独占锁,某一时刻只有一个线程可以获取该锁,这就导致在读多写少的场景下浪费了性能。因为多个线程对共享资源的读操作不加锁也不会出现线程安全问题。
ReentrantReadWriteLock 就是为了提升这种读多写少场景下的并发性能而设计的。 其采用读写分离的策略,允许多个线程可以同时获取读锁,也就是读锁是共享的,写锁依然是互斥的。
2、ReentrantReadWriteLock类继承结构和类属性
类继承结构:
描述:
ReentrantReadWriteLock
实现了 ReadWriteLock
接口,并且是 Serializable
,允许其对象进行序列化。这个类主要提供读写锁的实现,并且管理读锁和写锁的状态。
Sync
是 ReentrantReadWriteLock
的内部抽象类,继承自 AbstractQueuedSynchronizer
(AQS)。Sync
负责管理读写锁的同步机制,处理锁的获取和释放。它有两个主要的具体实现类:NonfairSync
和 FairSync
。
NonfairSync
是 Sync
的具体实现,代表非公平锁的同步策略。它允许线程在获取锁时插队,从而可能提升性能,但可能导致线程饥饿。
FairSync
是 Sync
的具体实现,代表公平锁的同步策略。它按照线程请求的顺序来获取锁,避免线程饥饿,但可能会带来额外的性能开销。
ReadLock
实现了 Lock
接口,并且是 Serializable
。它提供读锁的具体实现,允许多个线程同时持有读锁,只要没有线程持有写锁。ReadLock
提供了获取和释放读锁的方法。
类属性
构造方法:
- ①、无参构造
- ②、有参构造
传true
表示公平锁,传false
表示非公平锁
3、ReentrantReadWriteLock的读写锁原理分析
这里先说结论,方便下面对方法深入分析。(下面这段话部分截取自《Java并发编程之美》,我觉得这本书对并发编程的解释比较通俗易懂,比较推荐阅读。)
由上面ReentrantReadWriteLock
类的继承结构可以看出读写锁的内部维护了一个 ReadLock
和一个 WriteLock
,它们依赖 Sync
实现具体功能。而 Sync
继承自 AQS
,并且也提供了公平和非公平的实现。我们知道 AQS
中只维护了一个 state
状态,而 ReentrantReadWriteLock
则需要分别维护读状态和写状态。
一个 int
类型的state
字段 怎么表示写和读两种状态呢 ? ReentrantReadWriteLock 巧妙地使用 state
的高 16 位表示读状态,也就是获取到读锁的次数 ;使用低 16 位表示获取到写锁的线程的可重入次数。
看下Sync类的具体实现:
总结:
在 ReentrantReadWriteLock
中,state
字段的高 16 位用于存储读锁计数,低 16 位用于存储写锁计数。这种设计巧妙地利用了位运算来在一个整数中同时存储读锁和写锁的状态。具体来说:
- 读锁计数:通过将
state
右移16位来提取。 - 写锁计数:通过掩码操作提取。
此外,为了支持写锁的可重入性,HoldCounter
类用于记录线程对写锁的持有次数,并通过 ThreadLocalHoldCounter
来保证每个线程有自己的 HoldCounter
实例。这样设计不仅提高了锁的性能,还简化了锁的管理。
4、ReentrantReadWriteLock.WriteLock类的核心方法详解
非公平写锁的获取
WriteLock类的lock方法
AQS的 acquire方法
Sync内部类实现的tryAcquire方法
NonfairSync类的writerShouldBlock方法
总结
WriteLock.lock():
调用 Sync.acquire(1) 尝试获取写锁。
Sync.acquire(int arg):
尝试通过 tryAcquire(arg) 方法直接获取写锁。
如果直接获取失败,将当前线程加入等待队列,并尝试从等待队列中获取锁。
Sync.tryAcquire(int acquires):
检查锁状态:
确保当前线程可以获取写锁,如果锁被其他线程持有,或计数超出最大值,则返回 false。
尝试获取锁:
如果没有其他线程持有锁,且状态更新成功,将当前线程设置为写锁持有者,并返回 true。
非公平写锁的释放
WriteLock类的unlock方法
AQS的release方法
Sync的tryRelease方法
总结:
WriteLock.unlock():sync.release(1)
调用会尝试释放一个写锁,如果锁成功释放,会通知等待的线程。
AQS.release(int arg):tryRelease(arg)
方法尝试释放锁并更新锁的状态。如果锁成功释放(即 tryRelease
返回 true),并且头结点的 waitStatus
不为0(表示有线程在等待),则调用 unparkSuccessor(h)
唤醒头结点的后继节点,使其能够尝试获得锁。
Sync.tryRelease(int releases):
首先检查当前线程是否持有锁,如果没有持有,则抛出 IllegalMonitorStateException
异常。然后计算释放锁后的状态值,如果状态值为0,则表示锁完全释放,这时候将独占线程设置为 null
。最后更新状态值并返回锁是否完全释放的状态。
公平写锁的获取
公平写锁的获取过程和非公平写锁类似,但通过 FairSync
类的 writerShouldBlock
方法实现公平性。
FairSync的writerShouldBlock
方法
AQS的hasQueuedPredecessors
方法
总结:
公平性实现:FairSync.writerShouldBlock()
方法调用 hasQueuedPredecessors()
来检查队列中是否有其他线程在当前线程之前,从而实现公平性。
hasQueuedPredecessors()
方法:
检查队列: 通过比较头节点和尾节点,以及检查头节点的下一个节点来确定是否存在其他线程在当前线程之前,确保公平性。
公平写锁的释放
同上面非公平写锁的释放步骤。
5、ReentrantReadWriteLock.ReadLock类的核心方法详解
非公平读锁的获取
因为读锁是共享锁,所以调用的方法都是xxxShared
命名的方法。
ReadLock的lock方法
AQS的releaseShared方法
Sync的tryReleaseShared方法
NonfairSync的readerShouldBlock方法
Sync的fullTryAcquireShared方法
AQS 的 doAcquireShared(int arg) 方法
总结:
ReadLock.lock() 方法:
调用 Sync.acquireShared(1) 尝试获取共享读锁。
Sync.acquireShared(int arg) 方法:
直接调用 tryAcquireShared(arg) 尝试获取读锁。
如果直接获取失败,则调用 doAcquireShared(arg) 方法将线程加入等待队列。
Sync.tryAcquireShared(int unused) 方法:
检查锁状态:
如果独占锁存在且持有者不是当前线程,返回 -1。
尝试获取锁:
如果可以直接获取锁,则更新状态,并设置读线程信息。
如果直接获取失败,则调用 fullTryAcquireShared 进一步处理。
AQS.doAcquireShared(int arg) 方法:
将线程加入等待队列:
将当前线程封装为共享节点并加入等待队列。
尝试获取锁:
如果当前线程的前驱节点是头节点,则尝试获取共享读锁。
如果获取锁失败,则挂起当前线程,等待锁的释放。
非公平读锁在获取锁时不会强制保证线程的公平性。线程可以在任何时候被允许获取读锁,前提是没有其他线程持有独占锁(写锁)。
非公平读锁的释放
ReadLock的unlock() 方法
AQS的releaseShared(int arg) 方法
Sync的tryReleaseShared(int unused) 方法
AQS的doReleaseShared() 方法
总结:
ReadLock.unlock() 方法:
调用 Sync.releaseShared(1) 尝试释放一个共享读锁。
AQS.releaseShared(int arg) 方法:
调用 tryReleaseShared(arg) 尝试释放共享读锁。
成功释放锁后,调用 doReleaseShared() 确保正确地唤醒等待线程。
Sync.tryReleaseShared(int unused) 方法:
检查并更新读锁持有计数:
如果当前线程是第一个读线程,更新相关信息。
如果当前线程不是第一个读线程,更新缓存的持有计数。
更新锁状态:
尝试减少共享单位并更新状态。
返回 true 表示所有读锁已释放。
AQS.doReleaseShared() 方法:
确保释放后正确地唤醒等待线程。
如果需要传播信号,将状态设置为 PROPAGATE。
公平读锁的获取
整体步骤和 非公平读锁的获取差不多。 公平性的保证主要通过readerShouldBlock
方法保证。
FairSync类的readerShouldBlock方法
总结
FairSync.readerShouldBlock():
通过调用 hasQueuedPredecessors() 方法来决定当前读线程是否应该阻塞。主要用于维护公平性,确保新来的读线程在获取锁之前,如果队列中有其他等待的线程,则阻塞。
hasQueuedPredecessors():
检查队列中是否存在其他线程在当前线程之前等待。如果队列中有等待的线程(特别是写线程),则返回 true,表示当前线程应该阻塞。
这些方法通过确保读线程在获取锁之前,检查是否有其他等待的线程,从而保证了读锁的公平性。
公平读锁的释放
同非公平读锁的释放步骤。
6、读写锁的使用注意事项
利用锁降级保证可见性和效率的做法
补充知识点:
这里说的锁降级是指线程在持有写锁的前提下,获取读锁,再释放写锁的过程。 注意全程都是有锁的状态。
但是不能进行锁升级,也就是持有读锁的前提下,获取写锁,因为写锁是互斥的。
举例:
我养了几只特别厉害的狗,这几只狗会做大骨汤,等骨头准备好了,所有的狗狗就可以同时并发的吃骨头。如果骨头没准备好,狗狗想吃骨头就得等做骨头的那只狗先把骨头汤煮好才能全部开吃。
总结:
假设秀逗跑的最快。
线程获取读锁:
秀逗首先尝试获取读锁,因为读取操作通常是安全的,多个线程可以并发读取数据。
由于初始时骨头还未准备好,秀逗发现需要准备骨头,于是释放读锁并获取写锁。
获取写锁并准备骨头:
秀逗获取写锁后开始准备骨头(如制作骨头汤)。写锁是独占的,这确保了在准备骨头的过程中没有其他线程能够修改或读取骨头。
由于写锁是互斥的,其他线程必须等待秀逗完成骨头准备。
锁降级:
准备完骨头后,秀逗需要释放写锁以允许其他线程访问骨头。
在释放写锁之前,秀逗再一次获取读锁。这样,秀逗在告知其他狗子骨头准备好之前,自己相当于先盛了一碗骨头汤。 (还是秀逗聪明~ 自己做的自己先盛一碗没毛病吧~ )
通知其他线程:
一旦秀逗获取到读锁,就释放写锁(虽然秀逗先偷偷盛了一碗,但仍然等通知了其他狗子之后再吃,秀逗还是很讲义气的~),其他狗子得到通知也能获取读锁并开始吃骨头。
通过这种方式,秀逗保证了自己首先获取读锁,同时公平地让其他线程也能得到通知获取读锁。
读写锁一般还可以用来实现线程安全的缓存。这里就不写示例了。
谈一下 Single Threaded Execution模式
下面摘自《图解Java多线程设计模式》
有一座独木桥,非常细,每次只允许一个人经过。如果这个人还没有走到桥的另一头,则下一个人无法过桥。如果同时有两个人上桥,桥就会塌掉,掉进河里。
所谓 Single Threaded Execution 模式,意即“以一个线程执行”。就像独木桥同一时间内只允许一个人通行一样,该模式用于设置限制,以确保同一时间内只能让一个线程执行处理。
Single Threaded Execution有时候也称为临界区(critical section)或临界域(critical region )Single Threaded Execution这个名称侧重于执行处理的线程(过桥的人),而临界区或临界域的名称则侧重于执行范围(人过的桥)。
我觉得这个模式算是多线程同步的基础。也可以算是互斥锁的基础思想。
上面例子中 秀逗准备骨头汤的过程就是 Single Threaded Execution。 而整个例子又是Read-Write Lock模式。
谈一下Read-Write Lock模式
学生们正在一起看老师在黑板上写的板书。这时,老师想擦掉板书,再写新的内容。而学生们说道:“老师,我们还没看完,请先不要擦掉!”于是,老师就会等待大家都看完。
我觉得这个解释的角度很有意思,从读锁的角度解释。 一般我们理解读写锁,容易从写锁角度去理解,比如写的过程中不能读不能写。 上面的解释也很到位,因为写锁是互斥的也要等没有读锁的时候才能获取。
在 Read-Write Lock模式中,读取操作和写入操作是分开考虑的。在执行读取操作之前,线程必须获取用于读取的锁。而在执行写入操作之前,线程必须获取用于写人的锁。
由于当线程执行读取操作时,实例的状态不会发生变化,所以多个线程可以同时读取。但在读取时,不可以写入。
当线程执行写人操作时,实例的状态就会发生变化。因此,当有一个线程正在写入时,其他线程不可以读取或写入。
一般来说,执行互斥处理会降低程序性能。但如果把针对写入的互斥处理和针对读取的互斥处理分开来考虑,则可以提高程序性能。
7、总结
ReentrantReadWriteLock 只是读写锁思想的一个具体Java实现。 重要的是理解这种思想。掌握这些思想可以帮助我们在不同编程语言或框架中应用类似的锁机制。
参考资源(非常感谢下面这些资料):
《图解Java多线程设计模式》
《Java并发编程的艺术》
https://pdai.tech/md/java/thread/java-thread-x-lock-ReentrantReadWriteLock.html
https://javaguide.cn/java/concurrent/java-concurrent-questions-02.html#reentrantreadwritelock