深入理解StampedLock及其实现原理

StampedLock是java8在java.util.concurrent.locks新增的一个API。

ReentrantReadWriteLock 在沒有任何读写锁时,才可以取得写入锁,这可用于实现了悲观读取。然而,如果读取很多,写入很少的情况下,使用 ReentrantReadWriteLock 可能会使写入线程遭遇饥饿问题,也就是写入线程无法竞争到锁定而一直处于等待状态。

StampedLock(印戳锁)是对ReentrantReadWriteLock读写锁的一种改进,主要的改进为:在没有写只有读的场景下,StampedLock支持不用加读锁而是直接进行读操作,最大程度提升读的效率,只有在发生过写操作之后,再加读锁才能进行读操作

StampedLock有三种模式的锁,用于控制读取/写入访问。StampedLock的状态由版本和模式组成。锁获取操作返回一个用于展示和访问锁状态的票据(stamp)变量,它用相应的锁状态表示并控制访问,数字0表示没有写锁被授权访问。在读锁上分为悲观锁和乐观锁。锁释放以及其他相关方法需要使用邮编(stamps)变量作为参数,如果他们和当前锁状态不符则失败,这三种模式为:

  • 写入:方法writeLock可能为了获取独占访问而阻塞当前线程,返回一个stamp变量,能够在unlockWrite方法中使用从而释放锁。也提供了tryWriteLock。当锁被写模式所占有,没有读或者乐观的读操作能够成功。
  • 读取:方法readLock可能为了获取非独占访问而阻塞当前线程,返回一个stamp变量,能够在unlockRead方法中用于释放锁。也提供了tryReadLock。
  • 乐观读取:方法tryOptimisticRead返回一个非0邮编变量,仅在当前锁没有以写入模式被持有。如果在获得stamp变量之后没有被写模式持有,方法validate将返回true。这种模式可以被看做一种弱版本的读锁,可以被一个写入者在任何时间打断。乐观读取模式仅用于短时间读取操作时经常能够降低竞争和提高吞吐量。

StampedLock 的读写锁都是不可重入锁,所以在获取锁后释放锁前不应该再调用会获取锁的操作,以避免造成调用线程被阻塞

下面是java doc提供的StampedLock一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class Point {
//一个点的x,y坐标
private double x, y;
/**
* Stamped类似一个时间戳的作用,每次写的时候对其+1来改变被操作对象的Stamped值
* 这样其它线程读的时候发现目标对象的Stamped改变,则执行重读
*/
private final StampedLock stampedLock = new StampedLock();

// an exclusively locked method
void move(doubledeltaX, doubledeltaY) {
/**stampedLock调用writeLock和unlockWrite时候都会导致stampedLock的stamp值的变化
* 即每次+1,直到加到最大值,然后从0重新开始 */
long stamp = stampedLock.writeLock(); //写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp);//释放写锁
}
}

double distanceFromOrigin() { // A read-only method
/**tryOptimisticRead是一个乐观的读,使用这种锁的读不阻塞写
* 每次读的时候得到一个当前的stamp值(类似时间戳的作用)*/
long stamp = stampedLock.tryOptimisticRead();

//这里就是读操作,读取x和y,因为读取x时,y可能被写了新的值,所以下面需要判断
double currentX = x, currentY = y;

/**如果读取的时候发生了写,则stampedLock的stamp属性值会变化,此时需要重读,
* 再重读的时候需要加读锁(并且重读时使用的应当是悲观的读锁,即阻塞写的读锁)
* 当然重读的时候还可以使用tryOptimisticRead,此时需要结合循环了,即类似CAS方式
* 读锁又重新返回一个stampe值*/
if (!stampedLock.validate(stamp)) {
stamp = stampedLock.readLock(); //读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp);//释放读锁
}
}
//读锁验证成功后才执行计算,即读的时候没有发生写
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}


StampedLock源码分析

StampedLock也是通过一个int变量state、一个队列来实现的
state
state的默认值是256 1 0000 0000
state是一个int变量,总共有32位
前24位表示版本号、低8位表示锁。
低8位的第1位表示是否为写锁,1表示写锁、0表示没有写锁
剩下7位表示悲观读锁的个数

StampedLock引入乐观读,读时不加读锁,写锁也可进行写,避免写线程被饿死,读数据时读出来发现数据被修改了,再升级为悲观读,再读一次

StampedLock是一个读写锁,因此也会像读写锁那样,把一个state变量分成两半,分别表示读锁和写锁的状态,同时,还需要一个数据的version,但是,一次CAS没有办法操作两个变量,所以这个state变量本身同时也表示了数据的version。下面先分析state变量

1
2
3
4
5
6
7
8
9
10
11
public class StampedLock implements java.io.Serializable {
private static final int LG_READERS = 7;
private static final long RUNIT = 1L;
private static final long WBIT = 1L << LG_READERS; // 第8位表示写锁
private static final long RBITS = WBIT - 1L; // 最低的7位表示读锁
private static final long RFULL = RBITS - 1L; // 读锁的数目
private static final long ABITS = RBITS | WBIT; // 读锁和写锁状态合二为一
private static final long SBITS = ~RBITS;
private static final long ORIGIN = WBIT << 1; // state的初始值
private transient volatile long state;
}

用最低的8位表示读和写的状态,其中第8位表示写锁的状态,最低的7位表示读锁的状态。因为写锁只有一个bit位,所以写锁是不可重入的

初始值不为0,而是把WBIT 向左移动了一位,也就是上面的ORIGIN 常量,构造方法如下所示

为什么state的初始值不设为0呢?看乐观锁的实现:

上面两个方法必须结合起来看:当state&WBIT != 0的时候,说明有线程持有写锁,上面的tryOptimisticRead会永远返回0。这样,再调用validate(stamp),也就是validate(0)也会永远返回false。这正是我们想要的逻辑:当有线程持有写锁的时候,validate永远返回false,无论写线程是否释放了写锁。因为无论是否释放了(state回到初始值)写锁,state值都不为0,所以validate(0)永远为false
为什么上面的validate(…)方法不直接比较stamp=state,而要比较state&SBITS=state&SBITS 呢?
因为读锁和读锁是不互斥的!
所以,即使在“乐观读”的时候,state 值被修改了,但如果它改的是第7位,validate(…)还是会返回true。
另外要说明的一点是,上面使用了内存屏障VarHandle.acquireFence();,是因为在这行代码的下一行里面的stamp、SBITS变量不是volatile的,由此可以禁止其和前面的currentX=X,currentY=Y进行重排序
通过上面的分析,可以发现state的设计非常巧妙。只通过一个变量,既实现了读锁、写锁的状态记录,还实现了数据的版本号的记录


转载自:
https://www.cnblogs.com/zmhjay1999/p/15183390.html
http://www.importnew.com/14941.html