在上一篇《你真的懂ReentrantReadWriteLock吗?》中我给你们留了一个引子,一个更高效同时能够避免写饥饿的读写锁---StampedLock。StampedLock实现了不只多个读不互相阻塞,同时在读操做时不会阻塞写操做。java
为何StampedLock这么神奇?可以达到这种效果,它的核心思想在于,在读的时候若是发生了写,应该经过重试的方式来获取新的值,而不该该阻塞写操做。这种模式也就是典型的无锁编程思想,和CAS自旋的思想同样。这种操做方式决定了StampedLock在读线程很是多而写线程很是少的场景下很是适用,同时还避免了写饥饿状况的发生。这篇文章将经过如下几点来分析StampedLock。编程
先来看一个官方给出的StampedLock使用案例:缓存
public class Point {
private double x, y;
private final StampedLock stampedLock = new StampedLock();
//写锁的使用
void move(double deltaX, double deltaY){
long stamp = stampedLock.writeLock(); //获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); //释放写锁
}
}
//乐观读锁的使用
double distanceFromOrigin() {
long stamp = stampedLock.tryOptimisticRead(); //得到一个乐观读锁
double currentX = x;
double currentY = y;
if (!stampedLock.validate(stamp)) { //检查乐观读锁后是否有其余写锁发生,有则返回false
stamp = stampedLock.readLock(); //获取一个悲观读锁
try {
currentX = x;
} finally {
stampedLock.unlockRead(stamp); //释放悲观读锁
}
}
return Math.sqrt(currentX*currentX + currentY*currentY);
}
//悲观读锁以及读锁升级写锁的使用
void moveIfAtOrigin(double newX,double newY) {
long stamp = stampedLock.readLock(); //悲观读锁
try {
while (x == 0.0 && y == 0.0) {
long ws = stampedLock.tryConvertToWriteLock(stamp); //读锁转换为写锁
if (ws != 0L) { //转换成功
stamp = ws; //票据更新
x = newX;
y = newY;
break;
} else {
stampedLock.unlockRead(stamp); //转换失败释放读锁
stamp = stampedLock.writeLock(); //强制获取写锁
}
}
} finally {
stampedLock.unlock(stamp); //释放全部锁
}
}
}
复制代码
首先看看第一个方法move,能够看到它和ReentrantReadWriteLock写锁的使用基本同样,都是简单的获取释放,能够猜想这里也是一个独占锁的实现。须要注意的是 在获取写锁是会返回个只long类型的stamp,而后在释放写锁时会将stamp传入进去。这个stamp是作什么用的呢?若是咱们在中间改变了这个值又会发生什么呢?这里先暂时不作解释,后面分析源码时会解答这个问题。安全
第二个方法distanceFromOrigin就比较特别了,它调用了tryOptimisticRead,根据名字判断这是一个乐观读锁。首先什么是乐观锁?乐观锁的意思就是先假定在乐观锁获取期间,共享变量不会被改变,既然假定不会被改变,那就不须要上锁。在获取乐观读锁以后进行了一些操做,而后又调用了validate方法,这个方法就是用来验证tryOptimisticRead以后,是否有写操做执行过,若是有,则获取一个读锁,这里的读锁和ReentrantReadWriteLock中的读锁相似,猜想也是个共享锁。bash
第三个方法moveIfAtOrigin,它作了一个锁升级的操做,经过调用tryConvertToWriteLock尝试将读锁转换为写锁,转换成功后至关于获取了写锁,转换失败至关于有写锁被占用,这时经过调用writeLock来获取写锁进行操做。源码分析
看过了上面的三个方法,估计你们对怎么使用StampedLock有了一个初步的印象。下面就经过对StampedLock源码的分析来一步步了解它背后是怎么解决锁饥饿问题的。性能
从上面的使用示例中咱们看到,在StampedLock中,除了提供了相似ReentrantReadWriteLock读写锁的获取释放方法,还提供了一个乐观读锁的获取方式。那么这三种方式是如何交互的呢?根据AQS的经验,StampedLock中应该也是使用了一个状态量来标志锁的状态。经过下面的源码能够证实这点:测试
// 用于操做state后获取stamp的值
private static final int LG_READERS = 7;
private static final long RUNIT = 1L; //0000 0000 0001
private static final long WBIT = 1L << LG_READERS; //0000 1000 0000
private static final long RBITS = WBIT - 1L; //0000 0111 1111
private static final long RFULL = RBITS - 1L; //0000 0111 1110
private static final long ABITS = RBITS | WBIT; //0000 1111 1111
private static final long SBITS = ~RBITS; //1111 1000 0000
//初始化时state的值
private static final long ORIGIN = WBIT << 1; //0001 0000 0000
//锁共享变量state
private transient volatile long state;
//读锁溢出时用来存储多出的毒素哦
private transient int readerOverflow;
复制代码
上面的源码中除了定义state变量外,还提供了一系列变量用来操做state,用来表示读锁和写锁的各类状态。为了方便理解,我将他们都表示成二进制的值,长度有限,这里用低12位来表示64的long,高位自动用0补齐。要理解这些状态的做用,就须要具体分析三种锁操做方式是怎么经过state这一个变量来表示的,首先来看看获取写锁和释放写锁。优化
public StampedLock() {
state = ORIGIN; //初始化state为 0001 0000 0000
}
public long writeLock() {
long s, next;
return ((((s = state) & ABITS) == 0L && //没有读写锁
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ? //cas操做尝试获取写锁
next : acquireWrite(false, 0L)); //获取成功后返回next,失败则进行后续处理,排队也在后续处理中
}
public void unlockWrite(long stamp) {
WNode h;
if (state != stamp || (stamp & WBIT) == 0L) //stamp值被修改,或者写锁已经被释放,抛出错误
throw new IllegalMonitorStateException();
state = (stamp += WBIT) == 0L ? ORIGIN : stamp; //加0000 1000 0000来记录写锁的变化,同时改变写锁状态
if ((h = whead) != null && h.status != 0)
release(h);
}
复制代码
这里先说明两点结论:读锁经过前7位来表示,每获取一个读锁,则加1。写锁经过除前7位后剩下的位来表示,每获取一次写锁,则加1000 0000,这两点在后面的源码中均可以得倒证实。 初始化时将state变量设置为0001 0000 0000。写锁获取经过((s = state) & ABITS)
操做等于0时默认没有读锁和写锁。写锁获取分三种状况:ui
没有读锁和写锁时,state为0001 0000 0000 0001 0000 0000 & 0000 1111 1111 = 0000 0000 0000 // 等于0L,能够尝试获取写锁
有一个读锁时,state为0001 0000 0001 0001 0000 0001 & 0000 1111 1111 = 0000 0000 0001 // 不等于0L
有一个写锁,state为0001 1000 0000 0001 1000 0000 & 0000 1111 1111 = 0000 1000 0000 // 不等于0L
获取到写锁,须要将s + WBIT设置到state,也就是说每次获取写锁,都须要加0000 1000 0000。同时返回s + WBIT的值 0001 0000 0000 + 0000 1000 0000 = 0001 1000 0000
释放写锁首先判断stamp的值有没有被修改过或者屡次释放,以后经过state = (stamp += WBIT) == 0L ? ORIGIN : stamp
来释放写锁,位操做表示以下: stamp += WBIT
0010 0000 0000 = 0001 1000 0000 + 0000 1000 0000 这一步操做是重点!!!写锁的释放并非像ReentrantReadWriteLock同样+1而后-1,而是经过再次加0000 1000 0000来使高位每次都产生变化,为何要这样作?直接减掉0000 1000 0000不就能够了吗?这就是为了后面乐观锁作铺垫,让每次写锁都留下痕迹。
你们能够想象这样一个场景,字母A变化为B能看到变化,若是在一段时间内从A变到B而后又变到A,在内存中自会显示A,而不能记录变化的过程,这也就是CAS中的ABA问题。在StampedLock中就是经过每次对高位加0000 1000 0000来达到记录写锁操做的过程,能够经过下面的步骤理解:
能够看到第8位在获取和释放写锁时会产生变化,也就是说第8位是用来表示写锁状态的,前7位是用来表示读锁状态的,8位以后是用来表示写锁的获取次数的。这样就有效的解决了ABA问题,留下了每次写锁的记录,也为后面乐观锁检查变化提供了基础。
关于acquireWrite
方法这里不作具体分析,方法很是复杂,感兴趣的同窗能够网上搜索相关资料。这里只对该方法作下简单总结,该方法分两步来进行线程排队,首先经过随机探测的方式屡次自旋尝试获取锁,而后自旋必定次数失败后再初始化节点进行插入。
public long readLock() {
long s = state, next;
return ((whead == wtail && (s & ABITS) < RFULL && //队列为空,无写锁,同时读锁未溢出,尝试获取读锁
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ? //cas尝试获取读锁+1
next : acquireRead(false, 0L)); //获取读锁成功,返回s + RUNIT,失败进入后续处理,相似acquireWrite
}
public void unlockRead(long stamp) {
long s, m; WNode h;
for (;;) {
if (((s = state) & SBITS) != (stamp & SBITS) ||
(stamp & ABITS) == 0L || (m = s & ABITS) == 0L || m == WBIT)
throw new IllegalMonitorStateException();
if (m < RFULL) { //小于最大记录值(最大记录值127超事后放在readerOverflow变量中)
if (U.compareAndSwapLong(this, STATE, s, s - RUNIT)) { //cas尝试释放读锁-1
if (m == RUNIT && (h = whead) != null && h.status != 0)
release(h);
break;
}
}
else if (tryDecReaderOverflow(s) != 0L) //readerOverflow - 1
break;
}
}
复制代码
悲观读锁的获取和ReentrantReadWriteLock相似,不一样在于StampedLock的读锁很容易溢出,最大只有127,超事后经过一个额外的变量readerOverflow来存储,这是为了给写锁留下更大的空间,由于写锁是在不停增长的。悲观读锁获取分下面四种状况:
没有读锁和写锁时,state为0001 0000 0000 // 小于 0000 0111 1110,能够尝试获取读锁 0001 0000 0000 & 0000 1111 1111 = 0000 0000 0000
有一个读锁时,state为0001 0000 0001 // 小于 0000 0111 1110,能够尝试获取读锁 0001 0000 0001 & 0000 1111 1111 = 0000 0000 0001
有一个写锁,state为0001 1000 0000 // 大于 0000 0111 1110,不能够获取读锁 0001 1000 0000 & 0000 1111 1111 = 0000 1000 0000
读锁溢出,state为0001 0111 1110 // 等于 0000 0111 1110,不能够获取读锁 0001 0111 1110 & 0000 1111 1111 = 0000 0111 1110 读锁的释放过程在没有溢出的状况下是经过s - RUNIT
操做也就是-1来释放的,当溢出后则将readerOverflow变量-1。
乐观读锁由于实际上没有获取过锁,因此也就没有释放锁的过程,只是在操做后经过验证检查和获取前的变化。源码以下:
//尝试获取乐观锁
public long tryOptimisticRead() {
long s;
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
//验证乐观锁获取以后是否有过写操做
public boolean validate(long stamp) {
//该方法以前的全部load操做在内存屏障以前完成,对应的还有storeFence()及fullFence()
U.loadFence();
return (stamp & SBITS) == (state & SBITS); //比较是否有过写操做
}
复制代码
乐观锁基本原理就时获取锁时记录state的写状态,而后在操做完成以后检查写状态是否有变化,由于写状态每次都会在高位留下记录,这样就避免了写锁获取又释放后得不到准确数据。获取写锁记录有三种状况:
没有读锁和写锁时,state为0001 0000 0000 //((s = state) & WBIT) == 0L) true 0001 0000 0000 & 0000 1000 0000 = 0000 0000 0000 //(s & SBITS) 0001 0000 0000 & 1111 1000 0000 = 0001 0000 0000
有一个读锁时,state为0001 0000 0001 //((s = state) & WBIT) == 0L) true 0001 0000 0001 & 0000 1000 0000 = 0000 0000 0000 //(s & SBITS) 0001 0000 0001 & 1111 1000 0000 = 0001 0000 0000
有一个写锁,state为0001 1000 0000 //((s = state) & WBIT) == 0L) false 0001 1000 0000 & 0000 1000 0000 = 0000 1000 0000 //0L 0000 0000 0000
验证过程当中是否有过写操做,分四种状况
写过一次 0001 0000 0000 & 1111 1000 0000 = 0001 0000 0000 0010 0000 0000 & 1111 1000 0000 = 0010 0000 0000 //false
未写过,但读过 0001 0000 0000 & 1111 1000 0000 = 0001 0000 0000 0001 0000 1111 & 1111 1000 0000 = 0001 0000 0000 //true
正在写 0001 0000 0000 & 1111 1000 0000 = 0001 0000 0000 0001 1000 0000 & 1111 1000 0000 = 0001 1000 0000 //false
以前正在写,不管是否写完都不会为0L 0000 0000 0000 & 1111 1000 0000 = 0000 0000 0000 //false
分析完了StampedLock的实现原理,这里对StampedLock、ReentrantReadWriteLock以及Synchronized分别在各类场景下进行性能测试,测试的基准代码采用https://blog.takipi.com/java-8-stampedlocks-vs-readwritelocks-and-synchronized/ 文章中的代码,首先贴出上述博客中的测试结果,文章中的OPTIMISTIC模式因为采用了“脏读”模式,这里不采用OPTIMISTIC的测试结果,只比较StampedLock、ReentrantReadWriteLock以及Synchronized。
5个读线程和5个写线程场景:表现最好的是StampedLock的正常模式以及ReentrantReadWriteLock。
看完了上面的测试,前面3种场景表现最好的都为StampedLock,但第4种状况下StampedLock表现不好,因而我本身对代码又进行了一遍测试,同时鉴于读写锁的大量应用在缓存场景下,读写差距极大,我增长了100个读和1个写的场景。
测试机器:MAC OS(10.12.6),CPU : 2.4 GHz Intel Core i5,内存:8G 软件版本:JDK1.8 测试结果以下: 19个读线程和1个写线程场景:表现最好的是StampedLock以及Synchronized。 读线程: 19. 写线程: 1. 循环次数: 5. 计算总和: 1000000
同时鉴于原生的Synchronized后期可优化空间比较大,并且在代码复杂性以及安全性上面都具备必定优点,所以在绝大多数场景可使用Synchronized来进行同步,对性能有必定要求的在某些特定场景下可使用StampedLock。测试所用代码在我所引用的博客中均可以找到,你们能够自行尝试测试,若是对结果有什么疑问,欢迎在评论中提出。
参考资料: