文章目录
- 一、StampedLock锁概述
- 1、StampedLock锁简介
- 2、ReentrantReadWriteLock回顾
- 3、ReentrantReadWriteLock导致锁饥饿问题
- 4、锁饥饿问题的缓解
- 5、StampedLock与ReentrantReadWriteLock的对比
- 6、StampedLock特点
- 7、StampedLock的缺点
- 二、StampedLock的使用
- 1、StampedLock的三种模式介绍
- (1)写锁
- (2)悲观读锁
- (3)乐观读锁
- 2、官方案例
- 3、使用案例
- 三、源码分析
- 1、实例化
- 2、获取锁过程分析
- (1)ThreadA获取写锁
- (2)ThreadB获取读锁
- (3)ThreadC获取读锁
- (4)ThreadD获取写锁
- 3、释放锁过程分析
- (1)ThreadA释放写锁
- (2)ThreadB和ThreadC释放读锁
- 4、乐观读锁解析
一、StampedLock锁概述
1、StampedLock锁简介
StampedLock类是在JDK8引入的一把新锁,其是对原有ReentrantReadWriteLock读写锁的增强,增加了一个乐观读模式,内部提供了相关API不仅优化了读锁、写锁的访问,也可以让读锁与写锁间可以互相转换,从而更细粒度的控制并发。
也叫邮戳锁、票据锁。
2、ReentrantReadWriteLock回顾
- 读写锁适用于读多写少的场景,内部有写锁和读锁。
- 读锁是一把共享锁,当一个线程持有某一个数据的读锁时,其他线程也可以对这条数据进行读取,但是不能写。
- 写锁是一把独占锁,一个线程持有某一个数据的写锁时,其他线程是不可以获取到这条数据的写锁和读锁的。
- 对于锁升级来说,当一个线程在没有释放读锁的情况下,就去申请写锁,是不支持的。
- 对于锁降级来说,当一个线程在没有释放写锁的情况下,去申请读锁,是支持的。
3、ReentrantReadWriteLock导致锁饥饿问题
在使用读写锁时,还容易出现写线程饥饿的问题。主要是因为读锁和写锁互斥。比方说:当线程 A 持有读锁读取数据时,线程 B 要获取写锁修改数据就只能到队列里排队。此时又来了线程 C 读取数据,那么线程 C 就可以获取到读锁,而要执行写操作线程 B 就要等线程 C 释放读锁。由于该场景下读操作远远大于写的操作
,此时可能会有很多线程来读取数据而获取到读锁,那么要获取写锁的线程 B 就只能一直等待下去,最终导致饥饿。
也就是说,ReentrantReadWriteLock一旦读操作比较多的时候,想要获取写锁就变得比较困难了。假如说当前1000个线程,999个读,1个写,有可能999个读线程长时间抢到了锁,那1个写线程就悲剧了,因为当前有可能会一直存在读锁,而无法获得写锁
,根本没机会写。
4、锁饥饿问题的缓解
对于写线程饥饿问题,可以通过公平锁
进行一定程度的解决,但是它是以牺牲系统吞吐量
为代价的。
new ReentrantReadWriteLock(true);
5、StampedLock与ReentrantReadWriteLock的对比
ReentrantReadWriteLock
允许多个线程同时读,但是只允许一个线程写,在线程获取到写锁的时候,其他写操作和读操作都会处于阻塞状态,读锁和写锁也是互斥的,所以在读的时候是不允许写的,读写锁比传统的Synchronized速度要快很多,原因就是在于ReentrantReadWriteLock支持读并发,读读可以共享
。
ReentrantReadWriteLock
的读锁被占用的时候,其他线程尝试获取写锁的时候会被阻塞。
但是StampedLock
采取乐观获取锁后,其他线程尝试获取写锁时不会被阻塞,这其实是对读锁的优化,所以,在获取乐观读锁后,还需要对结果进行校验
。
总之,对短的只读代码段,使用乐观模式通常可以减少争用并提高吞吐量
。
6、StampedLock特点
获取锁的方法,会返回一个票据(stamp)
,当该值为0
代表获取锁失败,其他值都代表成功。
释放锁的方法,都需要传递获取锁时返回的票据(stamp)
,这个stamp必须是和成功获取锁时得到的Stamp一致,从而控制是同一把锁。
StampedLock是不可重入
的,如果一个线程已经持有了写锁,再去获取写锁就会造成死锁。
StampedLock提供了三种模式控制读写操作:写锁、悲观读锁、乐观读锁
。
在StampedLock中读锁和写锁可以相互转换,而在ReentrantReadWriteLock中,写锁可以降级为读锁,而读锁不能升级为写锁。
7、StampedLock的缺点
- StampedLock不支持重入。
- StampedLock的悲观读锁和写锁都不支持条件变量(Condition)。
- 使用StampedLock一定不要调用中断操作,即不要使用interrupt()方法,会影响性能。
二、StampedLock的使用
1、StampedLock的三种模式介绍
(1)写锁
使用类似于ReentrantReadWriteLock,是一把独占锁,当一个线程获取该锁后,其他请求线程会阻塞等待。 对于一条数据没有线程持有写锁或悲观读锁时,才可以获取到写锁,获取成功后会返回一个票据,当释放写锁时,需要传递获取锁时得到的票据。
(2)悲观读锁
使用类似于ReentrantReadWriteLock,是一把共享锁,多个线程可以同时持有该锁。当一个数据没有线程获取写锁的情况下,多个线程可以同时获取到悲观读锁,当获取到后会返回一个票据,并且阻塞线程获取写锁。当释放锁时,需要传递获取锁时得到的票据。
(3)乐观读锁
这把锁是StampedLock新增加的。可以把它理解为是一个悲观锁的弱化版。当没有线程持有写锁时,可以获取乐观读锁,并且返回一个票据。值得注意的是,它认为在获取到乐观读锁后,数据不会发生修改,获取到乐观读锁后,其并不会阻塞写入
的操作。
那这样的话,它是如何保证数据一致性的呢? 乐观读锁在获取票据时,会将需要的数据拷贝一份,在真正读取数据时,会调用StampedLock中的API,验证
票据是否有效。如果在获取到票据到使用数据这期间,有线程获取到了写锁并修改数据的话,则票据就会失效。 如果验证票据有效性时,当返回true,代表票据仍有效,数据没有被修改过,则直接读取原有数据。当返回flase,代表票据失效,数据被修改过,则重新拷贝最新数据使用。
乐观读锁适用于一些很短的只读代码
,它可以降低线程之间的锁竞争,从而提高系统吞吐量。但对于读锁获取数据结果必须要进行校验。
2、官方案例
public class Point {
//定义共享数据
private double x, y;
//实例化锁
private final StampedLock sl = new StampedLock();
//写锁案例
void move(double deltaX, double deltaY) {
//获取写锁
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
//释放写锁
sl.unlockWrite(stamp);
}
}
//使用乐观读锁案例
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); //获得一个乐观读锁
double currentX = x, currentY = y; //将两个字段读入本地局部变量
if (!sl.validate(stamp)) { //检查发出乐观读锁后同时是否有其他写锁发生?
stamp = sl.readLock(); //如果有,我们再次获得一个读悲观锁
try {
currentX = x; // 将两个字段读入本地局部变量
currentY = y; // 将两个字段读入本地局部变量
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX
+ currentY * currentY);
}
//使用悲观读锁并锁升级案例
void moveIfAtOrigin(double newX, double newY) {
// 获取悲观读锁
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.0) {//循环,检查当前状态是否符合
//锁升级,将读锁转为写锁
long ws = sl.tryConvertToWriteLock(stamp);
//确认转为写锁是否成功
if (ws != 0L) {
stamp = ws; //如果成功 替换票据
x = newX; //进行状态改变
y = newY; //进行状态改变
break;
} else { //如果不成功
sl.unlockRead(stamp); //显式释放读锁
stamp = sl.writeLock(); //显式直接进行写锁 然后再通过循环再试
}
}
} finally {
//释放读锁或写锁
sl.unlock(stamp);
}
}
}
3、使用案例
public class StampedLockDemo {
static int number = 10;
static StampedLock stampedLock = new StampedLock();
// 写锁案例
public void write() {
// 写锁,获取stamp
long stamp = stampedLock.writeLock();
System.out.println(Thread.currentThread().getName() + "\t" + "写线程准备修改");
try {
// 写操作
number = number + 1;
} finally {
// 释放锁
stampedLock.unlockWrite(stamp);
}
System.out.println(Thread.currentThread().getName() + "\t" + "写线程结束修改");
}
// 悲观读锁案例,读的过程不允许写锁介入
public void read() {
// 读锁
long stamp = stampedLock.readLock();
System.out.println(Thread.currentThread().getName() + "\t" + "读线程begin");
try {
System.out.println(Thread.currentThread().getName() + "\t" + "正在读取 4s");
Thread.sleep(4000);
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
int result = number;
System.out.println(Thread.currentThread().getName() + "\t" + "读取结束" + result);
} finally {
// 释放读锁
stampedLock.unlockRead(stamp);
}
}
// 乐观读,读的过程允许写锁介入
public void read2() {
// 乐观读
long stamp = stampedLock.tryOptimisticRead();
// 乐观的认为,读取中没有线程修改过值
int result = number;
try {
System.out.println(Thread.currentThread().getName() + "\t" + "正在读取 3s");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//检查发出乐观读锁后同时是否有其他写锁发生,true为无修改,false为有修改
if(!stampedLock.validate(stamp)) {
System.out.println(Thread.currentThread().getName() + "\t" + "乐观读失败,数据有变动,升级为悲观读");
try {
// 变更锁,升级为悲观锁
stamp = stampedLock.readLock();
result = number;
} finally {
// 解锁
stampedLock.unlockRead(stamp);
}
}
System.out.println(Thread.currentThread().getName() + "\t" + "读取的数据" + result);
}
public static void main(String[] args) {
StampedLockDemo stampedLockDemo = new StampedLockDemo();
// 读线程
new Thread(() -> {
stampedLockDemo.read2();
}, "readThread1").start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 写线程
new Thread(() -> {
stampedLockDemo.write();
}, "writeThread").start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 读线程
new Thread(() -> {
stampedLockDemo.read2();
}, "readThread2").start();
}
}
三、源码分析
1、实例化
StampedLock是基于CLH自旋锁实现,锁会维护一个等待线程链表
队列,所有没有成功申请到锁的线程都以FIFO
的策略记录到队列中,队列中每个节点代表一个线程,节点保存一个标记位,判断当前线程是否已经释放锁。
当一个线程试图获取锁时,首先取得当前队列的尾部节点作为其前序节点,并判断前序节点是否已经释放锁,如果前序节点没有释放锁,则当前线程还不能执行,进入自旋等待。如果前序节点已经释放锁,则当前线程执行。
首先需要先了解一些StampedLock类的常量值,方便与后面源码的理解。
另外还有两个很重要的属性:state、readerOverFlow:
state:当前锁的状态,是由写锁占用还是由读锁占用。其中long的倒数第八位是1,则表示由写锁占用(00000001),前七位由读锁占用(1-126)。
readerOverFlow:当读锁的数量超过了范围,通过该值进行记录。
当实例化StampedLock时,会设置节点状态值为ORIGIN(0000 0000)。
2、获取锁过程分析
假设现在有四个线程:ThreadA获取写锁、ThreadB获取读锁、ThreadC获取读锁、ThreadD获取写锁。
(1)ThreadA获取写锁
该方法用于获取写锁
,如果当前读锁和写锁都未被使用的话,则获取成功并更新state,返回一个long值,代表当前写锁的票据,如果获取失败,则调用acquireWrite()将写锁放入等待队列中。
因为当前还没有任务线程获取到锁,所以ThreadA获取写锁成功。
// java.util.concurrent.locks.StampedLock#writeLock
public long writeLock() {
long s, next; // bypass acquireWrite in fully unlocked case only
return ((((s = state) & ABITS) == 0L &&
U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
next : acquireWrite(false, 0L));
}
(2)ThreadB获取读锁
// java.util.concurrent.locks.StampedLock#readLock
public long readLock() {
long s = state, next; // bypass acquireRead on common uncontended case
return ((whead == wtail && (s & ABITS) < RFULL &&
U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
next : acquireRead(false, 0L));
}
该方法用于获取读锁,如果写锁未被占用,则获取成功,返回一个long值,并更新state,如果有写锁存在,则调用acquireRead(),将当前线程包装成一个WNODE放入等待队列,线程会被阻塞。
因为现在ThreadA已经获取到了写锁并且没有释放,所以ThreadB在获取读锁时,一定会阻塞,被包装成WNode进入等待队列中。
在acquireRead()内部会进行两次
for循环进行自旋尝试获取锁,每次for循环次数由CPU核数决定,进入到该方法后,首先第一次自旋会尝试获取读锁,获取成功,则直接返回。否则,ThreadB会初始化等待队列,并创建一个WNode,作为队头放入等待队列,其内部模式为写模式,线程对象为null,status为0【初始化】。同时还会将当前线程ThreadB包装为WNode放入等待队列的队尾中,其内部模式为读模式,thread为当前ThreadB对象,status为0。
当进入到第二次自旋后,还是先尝试获取读锁,如果仍没有获取到,则将前驱节点的状态设置为-1【WAITING】,用于代表当前ThreadB已经进入等待阻塞。
(3)ThreadC获取读锁
ThreadC在获取读锁时,其过程与ThreadB类似,因为ThreadA的写锁没有释放,ThreadC也会进入等待队列。但与ThreadB不同的是,ThreadC不会占用等待队列中的一个新节点,因为其前面的ThreadB也是一个读节点,它会赋值给用于表达ThreadB的WNode中的cowait属性,实际上构成一个栈。
(4)ThreadD获取写锁
由于ThreadA的写锁仍然没有释放,当ThreadD调用writeLock()获取写锁时,内部会调用acquireWrite()。
acquireWrite()内部的逻辑和acquireRead()类似,也会进行两次
自旋。第一次自旋会先尝试获取写锁,获取成功则直接返回,获取失败,则会将当前线程TheadD包装成WNode放入等待队列并移动队尾指针,内部属性模式为写模式,thread为ThreadD对象,status=0【初始化】。
当进入到第二次自旋,仍然会尝试获取写锁,如果获取不到,会修改其前驱节点状态为-1【等待】,并阻塞当前线程。
3、释放锁过程分析
(1)ThreadA释放写锁
当要释放写锁时,需要调用unlockWrite(),其内部首先会判断,传入的票据与获取锁时得到的票据是否相同,不同的话,则抛出异常。如果相同先修改state,接着调用release(),唤醒等待队列中的队首节点【即头结点whead的后继节点】
// java.util.concurrent.locks.StampedLock#unlockWrite
public void unlockWrite(long stamp) {
WNode h;
if (state != stamp || (stamp & WBIT) == 0L)
throw new IllegalMonitorStateException();
state = (stamp += WBIT) == 0L ? ORIGIN : stamp;
if ((h = whead) != null && h.status != 0)
release(h);
}
// 唤醒队列的队首节点【头结点whead的后继节点】
private void release(WNode h) {
if (h != null) {
WNode q; Thread w;
U.compareAndSwapInt(h, WSTATUS, WAITING, 0); // 将头结点状态从-1变为0,标识要唤醒其后继节点
if ((q = h.next) == null || q.status == CANCELLED) { // 判断头结点的后继节点是否为null或状态为取消
for (WNode t = wtail; t != null && t != h; t = t.prev) // 从队尾查找距头结点最近的状态为等待的节点
if (t.status <= 0)
q = t; // 赋值
}
if (q != null && (w = q.thread) != null)
U.unpark(w); // 唤醒队首节点
}
}
在release()中,它会先将头结点whead的状态修改从-1变为0,代表要唤醒其后继节点,接着会判断头结点whead的后继节点是否为null或者其后继节点的状态是否为1【取消】。 如果不是,则直接调用unpark()唤醒队首节点,如果是的话,再从队尾开始查找距离头结点最近的状态<=0【WAITING或初始化】的节点。
当ThreadB被唤醒后,它会从cowait中唤醒栈中的所有线程,因为读锁是一把共享锁,允许多线程同时占有。
当所有的读锁都被唤醒后,头结点指针会后移,指向ThreadB这个WNode,并将原有的头结点移出等待队列
此时ThreadC已经成为了孤立节点,最终会被GC。最终队列结构:
(2)ThreadB和ThreadC释放读锁
读锁释放需要调用unlockRead(),其内部先判断票据是否正确,接着会对读锁数量进行扣减,当读锁数量为0,会调用release()唤醒队首节点。
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) {
if (U.compareAndSwapLong(this, STATE, s, s - RUNIT)) {
if (m == RUNIT && (h = whead) != null && h.status != 0)
release(h);
break;
}
}
else if (tryDecReaderOverflow(s) != 0L)
break;
}
}
其内部同样会先将头结点状态从-1该为0,标识要唤醒后继节点
当ThreadD被唤醒获取到写锁后,头结点指针会后移指向ThreadD,并原有头部节点移出队列。
4、乐观读锁解析
在ReentrantReadWriteLock中,只有写锁和读锁的概念,但是在读多写少的环境下,容易出现写线程饥饿问题,虽然能够通过公平锁解决,但会造成系统吞吐量降低。
乐观读锁只需要获取,不需要释放。在获取时,只要没有线程获取写锁,则可以获取到乐观读锁,同时将共享数据储存到局部变量中。同时在获取到乐观读锁后,并不会阻塞其他线程对共享数据进行修改。
因为就会造成当使用共享数据时,出现数据不一致的问题。因为在使用乐观读锁时,要反复的对数据进行校验。
public long tryOptimisticRead() {
long s; // 没有线程获取写锁,则乐观读锁获取成功,返回票据
return (((s = state) & WBIT) == 0L) ? (s & SBITS) : 0L;
}
public boolean validate(long stamp) {
U.loadFence(); // 传入乐观读锁stamp,验证是否有线程获取到写锁
return (stamp & SBITS) == (state & SBITS);
}