目录
- StampedLock详解
- 1、StampedLock简介
- StampedLock 三个主要的锁模式
- 戳记
- 获取锁和释放锁的方法
- 转换锁
- 并发度比较
- 2、StampedLock简单使用示例
- 使用乐观读锁需要遵循一定的规则:
- `validate`方法的注意点
- 3、StampedLock获取释放锁详解
- 类继承结构
- 类属性
- StampedLock对于state变量的设计
- StampedLock乐观锁实现原理
- StampedLock的构造方法
- `tryOptimisticRead`方法
- `readLock` 方法
- unlockRead方法
- `writeLock` 方法
- unlockWrite方法
- StampedLock管理线程的队列
- 4、`StampedLock`和`ReentrantReadWriteLock`对比
- 5、 总结
StampedLock详解
1、StampedLock简介
StampedLock
是JUC并发包里面 JDK8 版本新增的一个锁,是读写锁的一种具体实现,和ReentrantReadWriteLock
不同的是其不提供可重入性,不基于某个类似Lock或者ReadWriteLock接口实现,而是基于CLH锁思想实现这点这AQS有些类似,并且StampedLock不支持条件变量 Condition
。 关于ReentrantReadWriteLock
可以参考上篇文章 ReentrantReadWriteLock。
那么JDK既然实现了这个锁,就说明这个锁一定有优势,那就是性能优势,下面会具体分析。
StampedLock 三个主要的锁模式
写锁模式(writeLock()): 用于排他性地写操作。在写锁模式下,其他线程既无法获取读锁,也无法获取写锁。
乐观读锁模式(tryOptimisticRead()): 允许线程进行读操作而不获取读锁,这种模式假设在读操作过程中数据不会被其他线程修改。如果发现数据被修改,可以重新获取悲观读锁以保证数据一致性。
悲观读锁模式(readLock()): 类似于 ReadWriteLock 的读锁,允许多个线程同时获取读锁,但无法与写锁共存。
戳记
戳记(stamp) 是 StampedLock 的关键,表示当前锁的状态。获取锁时返回的戳记值在后续的锁操作中用于验证锁的有效性,确保在锁的释放或转换操作中锁的状态是正确的。使用戳记有助于减少锁的争用和开销, StampedLock 通过提供乐观读锁在多线程多读的情况下提供了更好的性能,这是因为获取乐观读锁时不需要进行 CAS 操作设置锁的状态,而只是简单地测试状态。
获取锁和释放锁的方法
获取锁:
tryOptimisticRead(): 获取乐观读锁,返回戳记。
readLock(): 获取悲观读锁,返回戳记。
writeLock(): 获取写锁,返回戳记。
释放锁:
unlockRead(stamp): 释放读锁(悲观读锁或乐观读锁)。
unlockWrite(stamp): 释放写锁。
转换锁
可以看出tryConvertToWriteLock
方法表明StampedLock支持锁升级,这也是和ReentrantReadWriteLock
不同的点。
并发度比较
锁 | 并发度 |
ReentrantLock | 读读互斥,读写互斥,写写互斥 |
ReentrantReadWriteLock | 读读不互斥、读写互斥、写写互斥 |
StampedLock | 读读不互斥、读写不互斥、写写互斥 |
上面对于StampedLock 的读写不互斥是指 乐观读和写,而不是悲观读和写。
乐观读的思想和数据库中MCVV(Multi-Version Concurrency Control,多版本并发控制)有点类似。
2、StampedLock简单使用示例
还是拿狗吃骨头举例:
总结:
需要注意的点就是,lock.tryOptimisticRead();
获取悲观锁方法并不真正获取锁,而是假设在读操作期间数据不会被修改。使用戳记来验证数据是否在读操作期间被修改,必要时需要升级为悲观读锁来保证数据一致性。
使用乐观读锁需要遵循一定的规则:
比如吃骨头的方法中获取狗头数量的时候需要遵循下面几个步骤:
并且一定要按照下面几个步骤顺序处理。
①、乐观读操作:
先尝试使用 tryOptimisticRead()
获取乐观读锁。此时不持有实际的读锁,仅仅假设数据在读取期间不会被修改。
进行乐观读操作,相当于把共享变量读取到线程的栈内存,这一步很重要需要在验证之前执行。
②、验证和转换:
使用 validate(stamp)
检查在乐观读期间数据是否被修改。如果数据未被修改,读取的结果就是可靠的。
如果数据被修改,需要使用 readLock()
获取悲观读锁,再进行一遍悲观读取操作来确保数据的一致性。
③、释放锁:
如果获取了悲观读锁,必须在读操作完成后使用 unlockRead(stamp) 释放锁。
使用乐观锁、悲观锁思想也是StampedLock提升读取性能的一个方式。因为tryOptimisticRead
和 validate
这两个方法都比悲观读锁的CAS操作要快。
validate
方法的注意点
由于乐观读操作对顺序要求很严格,并且乐观读返回的是普通long类型变量,所以为了防止重排序,在validate
方法中使用了读屏障( U.loadFence();
) 确保在屏障之前的所有读操作在屏障之后的读操作之前完成。这意味着,U.loadFence()
保证了在它之前的内存读取操作不会被重新排序到它之后。
StampedLock
中的state
变量是volatile修饰的,但是validate方法的入参stamp
并不能保证是volatile变量,所以需要加个读屏障,确保 stamp
和 state
的读取操作不会被重排序,从而保证 stamp
的有效性检查是准确的。如果不使用读屏障,可能会出现 stamp
和 state
的读取操作被重排序的情况,这可能导致 validate 方法返回不正确的结果。
3、StampedLock获取释放锁详解
类继承结构
类属性
可以看出StampedLock
是通过内部类 WNode
来管理队列中的节点(队列属于双向链表结构,利用了CLH锁思想)。并且用了大量的标志位和位运算来处理锁的逻辑。 CLH锁的详解可以参考 AQS详解这篇文章。
StampedLock对于state变量的设计
由于只有一个state
变量,又需要表示读写锁,所以StampedLock
也把state变量拆成了读和写的部分,但是不同于 ReentrantReadWriteLock
的int类型的state变量把高16位表示读锁计数,低16位表示写锁计数。
StampedLock
中 锁状态的初始值是 ORIGIN
也就是 1<<7,也就是 1000 0000
(前面的0省略)
用最低的8位表示读和写的状态,其中最低的7位表示读锁的状态(版本),第8位表示写锁的状态。
因为写锁是互斥的且不可重入,用一位就够了。
StampedLock乐观锁实现原理
结合tryOptimisticRead
和validate
方法分析:
tryOptimisticRead
方法中 state&WBIT!=0
,说明state
变量表示写锁的第八位为1,也就是有线程持有写锁,那么tryOptimisticRead
方法就会返回0。表示获取乐观读锁失败。然后我们再调用validate(0)
一定会得到false,也就是校验失败。这个符合当有线程持有写锁时与其他锁互斥的逻辑。
为什么validate
方法,比较的是(stamp & SBITS) == (state & SBITS);
?
因为需要支持读读不互斥,即使修改了state的低7位也就是读锁的部分,(stamp & SBITS) == (state & SBITS);
依然会返回true。
StampedLock的构造方法
tryOptimisticRead
方法
这个方法允许线程在没有写锁持有者的情况下进行乐观读操作,这可以提高并发性能,因为乐观读操作不需要获取实际的读锁。
readLock
方法
readLock方法
readLock
方法总结:
目标: 尝试在无竞争情况下快速获取读锁。如果直接获取失败,则调用 acquireRead
处理复杂情况。
具体步骤:
检查队列状态:
如果队列为空(whead == wtail)且状态允许增加读锁计数((s & ABITS) < RFULL),即没有达到读锁计数上限:
尝试通过 CAS 操作将 state 增加 RUNIT,即增加读锁计数。
如果 CAS 操作成功,返回新的状态 next。
调用 acquireRead:
如果队列不为空,或者 CAS 操作失败,则调用 acquireRead 方法来处理更复杂的情况,实际获取读锁。
acquireRead方法
这个方法就开始上强度了,不太好理解
acquireRead 方法总结:
目标: 在有竞争的情况下获取读锁,包括处理线程等待、队列管理、自旋等。
acquireRead 的详细步骤:
检查队列状态:
如果队列为空(whead == wtail),尝试直接增加读锁计数(state)。
如果成功,返回新的状态 ns。否则,进行自旋尝试。
创建或管理节点:
如果队列为空,初始化一个新的头节点。
如果节点 node 为空,则创建一个新的读节点。
尝试将新的读节点添加到队列中,处理节点前驱和队列管理。
处理自旋等待:
如果直接获取读锁失败,通过自旋等待的方式尝试获取读锁。
处理线程等待:
如果自旋仍然失败,将线程放入等待队列中,处理中断和超时。
唤醒等待线程:
当读锁被成功获取后,唤醒等待的线程。
unlockRead方法
unlockRead方法
unlockRead
方法总结:
目标:
释放读锁,验证印章的有效性,并根据读锁计数的状态更新锁状态。
步骤:
自旋检查印章有效性:
进入自旋循环,获取当前锁状态 s。
检查传入的印章 stamp 是否与当前状态一致(通过比较 SBITS),以及印章是否有效((stamp & ABITS) == 0L)
,或者当前状态是否无效(m == WBIT)
。
如果印章无效,抛出 IllegalMonitorStateException。
处理读计数:
如果读计数小于 RFULL:
尝试通过 CAS 操作将状态 s 减少 RUNIT,即减少读锁计数。
如果读计数减少后为 RUNIT,并且队列头部节点状态不为 0,调用 release(h) 释放队列中的线程。
跳出循环,完成解锁。
如果读计数达到 RFULL:
调用 tryDecReaderOverflow 方法处理读计数溢出情况。
处理读计数溢出:
tryDecReaderOverflow 方法用于减少在 RFULL 状态下的溢出读线程计数。
tryDecReaderOverflow方法
tryDecReaderOverflow方法总结:
目标: 处理在读锁计数达到上限时的溢出情况,确保锁状态的正确性。
步骤:
检查读计数是否达到 RFULL:
如果当前状态的读位计数等于 RFULL,则尝试更新状态以处理溢出情况。
更新状态:
通过 CAS 操作将状态更新为包含读位的状态(s | RBITS)
。
如果 readerOverflow 计数器大于 0,减少计数器并保持状态。
如果 readerOverflow 为 0,减少读锁计数。
更新 state 变量,并返回更新后的状态值。
处理无法减少计数的情况:
如果无法处理溢出情况,让线程让步(Thread.yield()),并返回 0L。
writeLock
方法
writeLock方法
writeLock 方法总结:
目标: writeLock 方法尝试直接获取写锁,如果失败则调用 acquireWrite 方法通过排队的方式获取写锁。
步骤:
检查当前锁状态:
获取当前锁状态 s。
如果当前状态没有任何读锁或写锁(即 ABITS 中没有任何标志位),尝试通过 CAS 操作将状态更新为加上写锁位 WBIT 的新状态 next。
更新锁状态:
如果 CAS 操作成功,则返回新的状态值 next。
调用 acquireWrite:
如果状态更新失败(锁已被其他线程持有或队列中存在等待写锁的线程),调用 acquireWrite 方法来通过排队的方式获取写锁。
acquireWrite方法
acquireWrite 方法总结:
目标: 负责处理排队、等待和自旋等复杂逻辑,确保在竞争条件下正确地获取写锁。
步骤:
自旋排队:
方法开始时,尝试通过自旋的方式将当前线程排入等待队列。如果当前没有读锁或写锁,尝试直接获取写锁。如果无法直接获取写锁,初始化队列或创建新的等待节点,将其添加到队列中。
等待队列初始化:
如果队列为空,创建队列头节点,并将尾节点指向该头节点。若节点已经存在,则创建新的节点并将其加入队列。
自旋等待写锁:
在自旋阶段,尝试检查队列头节点并自旋等待写锁。更新队列头节点为当前节点,确保其他线程能够正确地获取写锁。
处理过时的等待节点:
在等待期间,帮助释放已经过时的等待节点,并唤醒那些被阻塞的线程。
等待获取写锁:
如果条件允许,将当前线程进入等待状态,直到写锁可用。支持中断处理,如果线程被中断则取消等待。
unlockWrite方法
unlockWrite方法
unlockWrite方法总结:
目标: 安全地释放持有的写锁,并确保相关线程被正确唤醒。
步骤:
验证印章:
首先,方法会检查提供的印章是否与当前锁状态匹配,且印章是否包含写锁位 (WBIT)。如果不匹配或不包含写锁位,抛出 IllegalMonitorStateException。
更新锁状态:
如果印章有效,解锁后将状态重置为原始值 ORIGIN(如果 stamp 加上 WBIT 结果为 0L),否则将状态更新为印章加上 WBIT。这个操作确保锁的状态正确反映当前的锁持有情况。
释放阻塞线程:
检查队列头部节点 (whead) 是否存在且状态不为 0。如果存在,则调用 release 方法尝试释放队列中的线程。
release方法
release方法总结:
目标: 从队列中释放阻塞的线程,并确保下一个等待的线程被正确唤醒。
步骤:
更新节点状态:
将队列头部节点的状态从 WAITING 更新为 0,表示该节点不再需要等待。此操作标志着头部节点已处理完毕,准备释放或处理下一个节点。
查找并设置下一个待唤醒的节点:
如果头部节点的下一个节点为空或状态为 CANCELLED,从队列尾部向前查找有效的待唤醒节点。这样可以确保唤醒操作的正确性,并避免唤醒已取消或不再需要的线程。
唤醒线程:
如果找到有效的待唤醒节点,并且该节点关联的线程不为空,则唤醒该线程。通过调用 U.unpark(w),可以确保线程能够继续执行,减少系统中的线程阻塞时间。
StampedLock管理线程的队列
StampedLock内部基于WNode
实现的阻塞队列和AQS实现的阻塞队列类似。
初始化时,新建个空节点,whead=wtail=NULL
。
之后再往里面加入一个个读线程或写线程节点。
但是上面acquireRead
和acquireWrite
方法对于自旋的操作就和AQS有很大不同了。
在AQS
里面,当一个线程CAS state
失败之后,会立即加入阻塞队列,并且进入阻塞状态。但在StampedLock
中,CAS state
失败之后,会不断自旋,自旋足够多的次数之后,如果还拿不到锁,才进入阻塞状态。为此,根据CPU的核数,定义了自旋次数的常量值。如果是单核的CPU,肯定不能自旋,在多核情况下,才采用自旋策略。
还有个比较特殊的地方在于,每个WNode里面有一个cowait指针,用于串联起所有的读线程。
例如,队列尾部阻塞的是一个读线程 1,现在又来了读线程 2、3,那么会通过cowait指针,把1、2、3串联起来。1被唤醒之后,2、3也随之一起被唤醒,因为读和读之间不互斥。
也就是 当入队一个线程时,如果队尾是写结点,则直接链接到队尾。
当入队一个读线程时,如果队尾是读节点,则直接链接到该读结点的cowait链中。
4、StampedLock
和ReentrantReadWriteLock
对比
特性 |
|
|
引入版本 | JDK 8 | JDK 5 |
锁类型 | 提供乐观读锁、悲观读锁和写锁 | 提供悲观读锁和写锁 |
乐观读锁 | 支持( | 不支持 |
悲观读锁 | 支持( | 支持( |
写锁 | 支持( | 支持( |
锁升级 | 支持从乐观读锁升级到悲观读锁或写锁( | 不支持 |
锁降级 | 支持从写锁降级为读锁( | 支持从写锁降级为读锁 |
锁管理复杂度 | 复杂,需要管理戳记,确保在转换锁时一致性 | 相对简单,直接使用 |
性能优化 | 乐观读锁减少了锁竞争,提高了读取性能 | 读写锁性能依赖于锁的竞争情况(大量并发读可能会导致写线程饥饿) |
公平性 | 不提供公平性(锁的获取是非公平的) | 可以选择公平性(通过构造函数 |
条件变量 | 不支持 | 支持 |
5、 总结
StampedLock
参考资料:
https://www.skjava.com/series/article/1446338301 《Java并发编程之美》
《Java并发实现原理:JDK源码剖析》