本文代码基于Java8
前言
读写锁如果使用不当,容易出现“饥饿”问题,比如在读线程非常多,写线程非常少的情况下,很容易导致写线程“饥饿”。虽然公平策略在一定程度上可以缓解这个问题,但是鱼与熊掌不可兼得,公平策略是以牺牲系统吞吐量为代价的。
于是StampedLock
类应运而生,在 JDK1.8 时引入,是对读写锁 ReentrantReadWriteLock
的增强,该类提供了一些功能,优化了读锁、写锁的访问,同时使读写锁之间可以互相转换,可以更细粒度控制并发。
不过该类的设计初衷是作为一个内部工具类,用于辅助开发其它线程安全组件。用得好,该类可以提升系统性能,用不好,容易产生死锁和其它莫名其妙的问题。
StampedLock 类结构
StampedLock
类结构如下:
StampedLock
的内部类包括四个,分别为 WriteLockView
、ReadLockView
、ReadWriteLockView
以及 WNode
。
StampedLock 底层实现
StampedLock
虽然不像其它锁一样定义了内部类来实现 AQS 框架,但是 StampedLock
的基本实现思路还是利用 CLH 队列进行线程的管理,通过同步状态值来表示锁的状态和类型。
StampedLock
把读分为了悲观读和乐观读,悲观读就等价于 ReadWriteLock
的读,而乐观读在一个线程写共享变量时,不会被阻塞,乐观读是不加锁的。
StampedLock
内部定义了很多常量,定义这些常量的根本目的和 ReentrantReadWriteLock
一样,对同步状态值按位切分,以通过位运算对State进行操作
对于 StampedLock 来说,写锁被占用的标志是第8位为1,读锁使用0-7位,正常情况下读锁数目为1-126,超过126时,使用一个名为的 readerOverflow int整型保存超出数。
源码如下:
1 | public class StampedLock implements java.io.Serializable { |
三种锁视图
三种锁视图分别为读锁视图、写锁视图、读写锁(悲观读)视图。这些视图其实是对 StamedLock
方法的封装,便于习惯了 ReentrantReadWriteLock
的用户使用。具体实现如下:
1 | // 读锁视图 |
ReadWriteLock
方法实现如下:
1 | // 返回读锁 |
使用示例
在使用 StampedLock
的时候,建议这样操作:乐观读时,如果有写操作修改了共享变量则升级乐观读为悲观读锁,因为这样可用避免乐观读反复的循环等待写锁的释放,造成 CPU 资源的浪费。
1 | class Point { |
StampedLock 获取锁和释放锁的实现
StampedLock
在获取锁和乐观读时,都会返回一个 stamp,解锁时需要传入这个 stamp,在乐观读时是用来验证共享变量是否被其他线程写过。
StampedLock
中,等待队列的结点要比AQS中简单些,仅仅三种状态。
- 初始状态
- 等待中
- 取消
获取写锁
写锁(独占锁),如果获取失败则进入阻塞队列,不响应中断。返回非 0 代表成功。
1 | public long writeLock() { |
(s = state) & ABITS) == 0L
为 true 代表读锁与写锁均未被使用,compareAndSwapLong
表示通过 CAS 更新同步状态值 state。
1 | public long tryWriteLock() { |
获取悲观读锁
悲观读锁(非独占锁),为获得锁一直处于阻塞状态,直到获得锁为止,不响应中断。返回非 0 代表成功。
1 | public long readLock() { |
whead == wtail
代表队列为空 ,s & ABITS) < RFULL
没有写锁且读锁数小于 126 。compareAndSwapLong
表示通过 CAS 更新同步状态值 state。
1 | public long tryReadLock() { |
获取乐观读锁
相对于悲观读锁来说的,在操作数据前并没有通过 CAS 设置锁的状态,仅仅是通过位运算测试。如果当前没有线程持有写锁,则简单的返回一个非 0 的 stamp 版本信息。由于 tryOptimisticRead
并没有使用 CAS 设置锁状态,所以不需要显示的释放该锁。
1 | public long tryOptimisticRead() { |
获取该 stamp 后在具体操作数据前还需要调用 validate
验证下该 stamp 是否已经不可用,也就是看当调用 tryOptimisticRead
返回 stamp 后,到当前时间是否有其它线程持有了写锁。如果是那么 validate
会返回 0,否者就可以使用该 stamp 版本的锁对数据进行操作。
1 | public boolean validate(long stamp) { |
使用乐观读锁还是很容易犯错误的,必须要严谨。需要遵循相应的调用模板(判断共享变量是否已经被其他线程写过,如果被写过则升级为悲观读锁),防止出现数据不一致的问题。
释放锁
锁释放需要锁状态和 stamp 匹配,才能释放对应的锁。
1 | // 如果锁状态和 stamp 匹配,则释放写锁 |
释放读锁、写锁的 “try” 版本,即“一次性”版本
1 | public boolean tryUnlockWrite() { |
锁转换
StampedLock
读写锁之间可以互相转换,可以更细粒度控制并发。包括三种转换:
- 转换成写锁
- 转换成悲观读锁
- 转换成乐观读锁
1 | // 尝试转换成写锁 |
性能对比
和 ReadWritLock
相比,在一个线程情况下,是读速度其4倍左右,写是1倍。
和 ReadWritLock
相比,六个线程情况下,读性能是其几十倍,写性能也是近10倍左右。
和 ReadWritLock
相比,吞吐量提高
总结
StampedLock
提供的读写锁与 ReentrantReadWriteLock
类似,前者不支持重入,不支持条件变量,也就是没 Condition
。不过前者通过提供乐观读锁在多线程多读的情况下能提供更好的性能,这是因为获取乐观读锁时候不需要进行 CAS 操作设置锁的状态,而只是简单的测试状态。
writeLock()
或者 readLock()
获得锁之后,线程还没执行完就被 interrupt()
的话,会导致 CPU 飙升。
另外,StampedLock
使用时要特别小心,避免锁重入的操作,在使用乐观读锁时也需要遵循相应的调用模板,防止出现数据不一致的问题。