文章目录
0. Why StampedLock
ReadWriteLock
适合读多写少的高并发场景,但读写互斥(读时不允许写,写时不允许读)
- 写饥饿问题:一个线程”写“的时候,其它线程不能读也不能写(阻塞该线程,会影响性能)
StampedLock
:在读的时候如果发生了写,应该通过重试的方式来获取新的值,而不应该阻塞写操作(乐观读,无锁编程,类似CAS的思想)
- 支持多个线程申请乐观读的同时,还允许一个线程申请写锁
- 避免了写饥饿,适合读多写少且写线程非常少的场景(比
ReadWriteLock
更快):- 没有写只有读时:不用加锁读
- 写操作后:加锁读
- 底层并不是基于AQS的
- 读写都不可重入
1. StampedLock
概念
1.1 三种访问模式
- 乐观读:
- 在多个线程读取共享变量时,允许一个线程对共享变量进行写操作
- 不加锁,直接操作数据:
- 操作数据前也不CAS设置锁状态,仅位运算测试stamp。
- 获取该 stamp 后在具体操作数据前还需要调用
validate
方法验证该 stamp 是否己经不可用
- 读锁(悲观)与写锁:
- 与
ReadWriteLock
类似:只允许一个线程获取写锁(独占),写锁和读锁也是互斥的 - 与
ReadWriteLock
不同:StampedLock
获取读锁或写锁成功后,都会返回一个Long,释放锁时需要传入这个Long。且StampedLock
不可重入。
- 与
1.2 stamp 印戳
StampedLock
获取锁后,返回一个long类型的stamp,通过位运算来确定锁的状态
- 结果为0:如果当前没有线程持有写锁
- 结果不为0:如果当前有线程持有写锁,只能悲观读写
2. StampedLock
的使用
2.1 读写锁的获取与使用
public class StampedLockDemo{
//创建StampedLock锁对象
public StampedLock stampedLock = new StampedLock();
//获取、释放读锁
public void testGetAndReleaseReadLock(){
long stamp = stampedLock.readLock();
try{
//执行获取读锁后的业务逻辑
}finally{
//释放锁
stampedLock.unlockRead(stamp);
}
}
//获取、释放写锁
public void testGetAndReleaseWriteLock(){
long stamp = stampedLock.writeLock();
try{
//执行获取写锁后的业务逻辑。
}finally{
//释放锁
stampedLock.unlockWrite(stamp);
}
}
}
2.2 乐观读
如果在执行乐观读操作时,另外的线程对共享变量进行了写操作,则会把乐观读升级为悲观读锁,比如下面的distanceFromOrigin
package org.example.LockEx;
import java.util.concurrent.locks.StampedLock;
public class StampedLockEx {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double deltaX, double deltaY) {
// 写锁 exclusive
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失效
// 即被持有写锁的线程修改了,validate返回false
// 乐观读锁转为悲观读锁
stamp = sl.readLock();
try {
// 悲观读锁跟写锁是互斥的,重新读一遍资源,后面就不会被写锁修改了
currentX = x;
currentY = y;
}finally {
// 释放悲观读锁
sl.unlock(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
void moveIfAtOrigin(double newX, double newY) { // upgrade
// Could instead start with optimistic, not read mode
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);
}
}
}
2.2.1 为什么乐观读要升级为悲观读锁而不像CAS一样自旋?
乐观读期间遇到另一个线程写时会升级为悲观读锁,此时读写互斥
因为如果不升级,程序会在循环中反复执行乐观读,直到乐观读期间没有线程执行写操作,类似CAS自旋。
但是前面说过,StampLock
适合读多写少且写线程较少的场景,它性能之所以比ReadWriteLock
好,就是因为大量的读操作线程可以通过乐观读的方式无锁进行。因此可能会有大量乐观读的线程,如果全都在循环执行,会消耗大量的CPU资源
- 这里不是说
StampLock
里不用CAS,内部关于stamp的操作(比如我们获取锁时返回stamp的方法)都涉及到CAS
3. StampLock
的实现:state+队列
state
核心就是使用戳记(stamp)的方式来标记数据的版本,乐观读的时候就是对比stamp来保证线程安全,而获取锁的方法返回的stamp则是通过state属性位运算得到的
state是一个int,默认值是256(1 0000 0000),共32位:
- 前24位表示版本号
- 低8位表示锁
- 低8位的第1位表示是否为写锁:1表示写锁、0表示没有写锁
- 剩下7位表示悲观读锁的个数
队列
StampedLock
内部是基于CLH锁实现的,CLH是一种自旋锁,且是公平的(保证FIFO,不会有锁饥饿)
注意StampedLock
并不通过AQS实现,但是AQS内部的队列也是CLH的变体,所以还是有很多类似的地方
StampLock
的CLH就是维护了一个线程的等待队列,所有申请锁但是没有成功的线程都会包装成一个节点存入队列(类似AQS)。AQS中,每个节点有各种状态(SIGNAL、CONDITION等等),而CLH为每个节点维护了一个locked
属性,true代表获取到锁,false表示成功释放锁
当一个线程试图获得锁时,取得等待队列的尾部节点作为其前序节点,并循环判断前一节点是否成功释放锁:
while (pred.locked) {
//省略操作
}
这也是跟AQS不同的地方,AQS中排队等待获取锁的线程节点是被前一线程释放锁后unpark()
唤醒的。而StampLock
的CLH中,等待的节点是一直在询问前一节点是否释放锁
注意事项
-
StampedLock
不支持重入:因此不能嵌套使用 -
StampedLock
不支持条件变量 -
StampedLock
使用不当会导致CPU飙升:如果某个线程阻塞在StampedLock
的readLock()
或者writeLock()
方法上时,此时调用阻塞线程的interrupt()方法中断线程,会导致CPU飙升到100%:public void testStampedLock() throws Exception{ final StampedLock lock = new StampedLock(); Thread thread01 = new Thread(()->{ // 获取写锁 lock.writeLock(); // 永远阻塞在此处,不释放写锁 LockSupport.park(); }); thread01.start(); // 保证thread01获取写锁 Thread.sleep(100); Thread thread02 = new Thread(()-> //阻塞在悲观读锁 lock.readLock() ); thread02.start(); // 保证T2阻塞在读锁 Thread.sleep(100); //中断线程thread02 //会导致线程thread02所在CPU飙升 thread02.interrupt(); thread02.join(); }