在 JDK 8 中新增了 StampedLock,StampedLock 可以理解为对 ReentrantReadWriteLock 的增强,在原先读写锁的基础上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。
基本原理
和 AQS 类似,StampedLock 同样维护了一个volatile语义的共享资源变量state和一个FIFO线程等待队列。
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制就是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。
几种锁的并发粒度对比:
锁的类型 | 并发粒度 |
---|---|
ReentrantReadWriteLock | 读与读互斥,读与写互斥,写与写互斥 |
ReentrantLock | 读与读不互斥,读与写互斥,写与写互斥 |
StampedLock | 读与读不互斥,读与写不互斥,写与写互斥 |
StampedLock 和 MySQL 的MVCC机制、CopyOnWriteArrayList 有些类似。
使用示例
使用 StampedLock 实现一个线程安全的集合:
1 | public class Demo { |
注意 size() 方法中:
- 读取数据前先获取版本号
- 读取数据:将数据拷贝到线程的栈内存中
- 读之后将读之前的版本和读之后的版本进行对比,相同则说明读取期间没有其他线程修改过数据
关键成员变量
1 | public class StampedLock implements java.io.Serializable { |
- state 初始值为 ORIGIN,即:
1 0000 0000
- 当 state & WBIT != 0 时,说明有线程持有写锁(WBIT=1000 0000)
- 当 state & ABITS !=0 时,说明有线程持有读锁或写锁(ABITS=1111 1111)
- 每获取一次写锁时,state 会加上 WBIT(WBIT=1000 0000)
- 每获取一次读锁时,state 会加锁 1
写锁的获取与释放
writeLock方法
获取写锁,获取失败会一直阻塞,直到获得锁成功
1 | /** |
((((s = state) & ABITS) == 0L
判断是否有线程持有读写锁,等于0说明没有线程持有读写锁,则通过CAS尝试获取写锁,也就是将 state 会加上 WBIT(WBIT=1000 0000)- 如果当前有线程持有读写锁,或者通过CAS修改state的值失败,则执行 acquireWrite 方法
acquireWrite方法
先自旋尝试、加入等待队列、直到最终 Unsafe.park() 挂起线程
1 | /** |
tryWriteLock方法
获取写锁,获取成功返回状态值,获取失败则返回0
1 | /** |
((((s = state) & ABITS) == 0L
判断是否有线程持有读写锁,等于0说明没有线程持有读写锁,则通过CAS尝试获取写锁,也就是将 state 会加上 WBIT(WBIT=1000 0000)- 如果当前有线程持有读写锁,或者通过CAS修改state的值失败,则直接返回 0
tryWriteLock方法
在指定时间内获得写锁,获取成功返回状态值,获取失败则返回0
1 | /** |
unlockWrite方法
释放写锁,如果state不匹配或者没有持有写锁,则会抛出异常
1 | /** |
state = (stamp += WBIT)
这里明明是解锁,为什么是加 WBIT?
实际上有第8位表示是否持有写锁,加 WBIT 会导致仅为,第8位会变成0,并且第9位会变成1。实际上这是为了后面乐观读锁做铺垫,让每次写锁都留下痕迹,用来处理CAS中的ABA问题。
tryUnlockWrite方法
释放写锁,不会抛出异常
1 | /** |
悲观读锁的获取与释放
readLock方法
获取读锁,获取失败会一直阻塞,直到获得锁成功
1 | /** |
如果CLH队列为空,没有线程持有写锁,并且读锁的数量没有溢出,则通过CAS尝试获取读锁,也就是将 state 加上 RUNIT(RUNIT=1),并且CAS修改成功则返回状态,否则执行 acquireRead 方法。
acquireRead方法
1 | /** |
tryReadLock方法
尝试获取读锁,获取成功返回状态值,获取失败则返回0
1 | /** |
tryReadLock方法
在指定时间内获得读锁,获取成功返回状态值,获取失败则返回0
1 | /** |
unlockRead方法
释放读锁,如果state不匹配或者没有持有读锁,则会抛出异常
1 | /** |
tryUnlockRead方法
释放读锁,不会抛出异常
1 | /** |
乐观读锁的获取与释放
tryOptimisticRead
尝试获取乐观读锁
1 | /** |
- 如果当前有线程持有写锁,则返回0
- 否则记录写锁的状态 (s & SBITS,SBITS=1000 0000) 并返回
validate方法
校验乐观锁获取之后是否有过写操作
1 | /** |
这里说一下为什么需要使用内存屏障,使用示例中有如下代码:
1 | public int size() { |
保证 lock.tryOptimisticRead() 和 lock.validate(stamp) 中间的数据(即:int size = list.size()) load完成。
模式转换
StamedLock还支持这三种锁在一定条件下进行相互转换:
- tryConvertToWriteLock:转换为写锁
- tryConvertToReadLock:转换位悲观读锁
- tryConvertToOptimisticRead:转换为乐观读锁
悲观读占满CPU的问题
1 | public class StampedLockTest { |
如果没有中断,那么阻塞在readLock()上的线程在经过几次自旋后,会进入park()等待,一旦进入park()等待,就不会占用CPU了。但是park()这个函数有一个特点,就是一旦线程被中断,park()就会立即返回,返回还不算,它也不给你抛点异常啥的,那这就尴尬了。本来呢,你是想在锁准备好的时候,unpark()的线程的,但是现在锁没好,你直接中断了,park()也返回了,但是,毕竟锁没好,所以就又去自旋了。
转着转着,又转到了park()函数,但悲催的是,线程的中断标记一直打开着,park()就阻塞不住了,于是乎,下一个自旋又开始了,没完没了的自旋停不下来了,所以CPU就爆满了。
要解决这个问题,本质上需要在StampedLock内部,在park()返回时,需要判断中断标记为,并作出正确的处理,比如,退出,抛异常,或者把中断位给清理一下,都可以解决问题。
但很不幸,至少在JDK8里,还没有这样的处理。因此就出现了上面的,中断readLock()后,CPU爆满的问题。请大家一定要注意。
总结
- StampedLock 适用于多读场景,读写不互斥
- StampedLock 不支持锁重入
- StampedLock 没有通知等待机制