StampedLock
是 JDK1.8 版本中在 J.U.C 并发包里新增的一个锁,StampedLock
是对读写锁ReentrantReadWriteLock
的增强,优化了读锁、写锁的访问,更细粒度控制并发。这篇文章就来介绍一下StampedLock
,分为如下几个问题:
StampedLock
StampedLock
锁的三种模式StampedLock
的使用以及注意问题StampedLock
ReentrantReadWriteLock
的问题既然说StampedLock
是对读写锁ReentrantReadWriteLock
的增强与优化,那么就要先弄清楚ReentrantReadWriteLock
到底存在什么问题。
ReentrantReadWriteLock
可能会导致写线程饥饿。关于并发编程中的公平与饥饿这里不再介绍了,不了解的可以看这篇公平锁与非公平锁。
首先我们来回顾读写锁的几个知识点:
现在来解释下导致写线程饥饿的情况:当线程 A 持有读锁读取数据时,线程 B 要获取写锁修改数据就只能到队列里排队。此时又来了线程 C 读取数据,那么线程 C 就可以获取到读锁,而要执行写操作线程 B 就要等线程 C 释放读锁。由于该场景下读操作远远大于写的操作,此时可能会有很多线程来读取数据而获取到读锁,那么要获取写锁的线程 B 就只能一直等待下去,最终导致饥饿。
StampedLock
读写锁导致写线程饥饿的原因是读锁和写锁互斥,StampedLock
提供了解决这一问题的方案————乐观读锁 Optimistic reading,即一个线程获取的乐观读锁之后,不会阻塞线程获取写锁。
StampedLock
提供了三种模式来控制读写操作:写锁 writeLock
、悲观读锁 readLock
、乐观读锁 Optimistic reading
。
类似ReentrantReadWriteLock
的写锁,独占锁,当一个线程获取该锁后,其它请求的线程必须等待。
获取:没有线程持有悲观读锁或者写锁的时候才可以获取到该锁。
释放:请求该锁成功后会返回一个 stamp
票据变量用来表示该锁的版本,当释放该锁时候需要将这个 stamp
作为参数传入解锁方法。
类似ReentrantReadWriteLock
的读锁,共享锁,同时多个线程可以获取该锁。
获取:在没有线程获取独占写锁的情况下,同时多个线程可以获取该锁。
释放:请求该锁成功后会返回一个 stamp
票据变量用来表示该锁的版本,当释放该锁时候需要 unlockRead
并传递参数 stamp
。
悲观读锁:悲观的认为在具体操作数据前其他线程会对自己操作的数据进行修改,所以当前线程获取到悲观读锁的之后会阻塞线程获取写锁。
获取:不需要通过 CAS 设置锁的状态,如果当前没有线程持有写锁,直接简单的返回一个非 0 的 stamp 版本信息,表示获取锁成功。
释放:并没有使用 CAS 设置锁状态所以不需要显示的释放该锁。
乐观读锁如何保证数据一致性呢?
乐观读锁在获取 stamp 时,会将需要的数据拷贝一份出来。在真正进行读取操作时,验证 stamp 是否可用。如何验证 stamp 是否可用呢?从获取 stamp 到真正进行读取操作这段时间内,如果有线程获取了写锁,stamp 就失效了。如果 stamp 可用就可以直接读取原来拷贝出来的数据,如果 stamp 不可用,就重新拷贝一份出来用。我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。
乐观读锁:乐观的认为在具体操作数据前其他线程不会对自己操作的数据进行修改,所以当前线程获取到乐观读锁的之后不会阻塞线程获取写锁。 为了保证数据一致性,在具体操作数据前要检查一下自己操作的数据是否经过修改操作了,如果进行了修改操作,就重新读一次。 乐观读锁在读多写少的情况下提供更好的性能,因为乐观读锁不需要进行 CAS 设置锁的状态而只是简单的测试状态。
Oracle 官方的例子:
class Point {
private double x, y;// 成员变量
private final StampedLock sl = new StampedLock();// 锁实例
/**
* 写锁writeLock
* 添加增量,改变当前point坐标的位置。
* 先获取到了写锁,然后对point坐标进行修改,然后释放锁。
* 写锁writeLock是排它锁,保证了其他线程调用move函数时候会被阻塞,直到当前线程显示释放了该锁,也就是保证了对变量x,y操作的原子性。
*/
void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
/**
* 乐观读锁tryOptimisticRead
* 计算当前位置到原点的距离
*/
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // 尝试获取乐观读锁(1)
double currentX = x, currentY = y; // 将全部变量拷贝到方法体栈内(2)
// 检查票据是否可用,即写锁有没有被占用(3)
if (!sl.validate(stamp)) {
// 如果写锁被抢占,即数据进行了写操作,则重新获取
stamp = sl.readLock();// 获取悲观读锁(4)
try {
// 将全部变量拷贝到方法体栈内(5)
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);// 释放悲观读锁(6)
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);// 真正读取操作,返回计算结果(7)
}
/**
* 悲观读锁readLock
* 如果当前坐标为原点则移动到指定的位置
*/
void moveIfAtOrigin(double newX, double newY) {
long stamp = sl.readLock();// 获取悲观读锁(1)
try {
// 如果当前点在原点则移动(2)
while (x == 0.0 && y == 0.0) {
long ws = sl.tryConvertToWriteLock(stamp);// 尝试将获取的读锁升级为写锁(3)
if (ws != 0L) {
// 升级成功,则更新票据,并设置坐标值,然后退出循环(4)
stamp = ws;
x = newX;
y = newY;
break;
} else {
// 读锁升级写锁失败,则释放读锁,显示获取独占写锁,然后循环重试(5)
sl.unlockRead(stamp);
stamp = sl.writeLock();
}
}
} finally {
sl.unlock(stamp);// 释放写锁(6)
}
}
}
move
方法,添加增量,改变当前 point 坐标的位置。没啥可说的,就是正常的独占锁。
distanceFromOrigin
方法,计算当前位置到原点的距离。
代码(1)首先尝试获取乐观读锁,如果当前没有其它线程获取到了写锁,那么(1)会返回一个非 0 的 stamp 用来表示版本信息。如果当前有线程占有写锁,返回的 stamp 为 0,会在代码(3)中检验失败。这里获取乐观锁并没有通过 CAS 操作修改锁的状态而是简单的通过与或操作返回了一个版本信息。
代码(2)拷贝变量到本地方法栈里面。
代码(3)检查在(1)获取到的票据 stamp 是否还有效,从执行完代码(1)到执行代码(3)这段时间内,如果有线程获取了写锁,stamp 就失效了。之所以还要在此校验是因为代码(1)获取读锁时候并没有通过 CAS 操作修改锁的状态而是简单的通过与或操作返回了一个版本信息。这里如果校验成功则执行(7)使用本地方法栈里面的值进行计算然后返回,也就是真正的读操作。需要注意的是在代码(3)校验成功后,代码(7)计算中其他线程可能获取到了写锁并且修改了 x,y 的值,而当前线程执行代码(7)进行计算时候采用的是修改前值的拷贝,也就是说操作是对之前值的一个拷贝,并不是新的值。
代码(2)和(3)能否互换呢?不能。假设位置换了,那么首先执行 validate,假如验证通过了,要拷贝 x,y 值到本地方法栈,而在拷贝的过程中很有可能其他线程已经修改了 x,y 中的一个,这就造成了数据的不一致性了。而不交换(2)和(3),如果在拷贝 x,y 值到本地方法栈里面时候也会存在其他线程修改了 x,y 中的一个值,那么肯定是有线程获取写锁进行了修改,validate 校验时候就会失败。
代码(4)在 validate 检验失败后获取悲观读锁,如果此时有线程持有写锁则代码(4)会导致的当前线程阻塞直到其它线程释放了写锁。写锁释放,也就是修改完成后唤醒当前线程执行下面的拷贝操作。
代码(5)获取到读锁后,拷贝变量到本地方法栈。
代码(6)释放悲观读锁,拷贝的时候由于加了读锁保证了在拷贝期间其它线程不能获取写锁来修改数据,从而保证了数据的一致性。
代码(7)使用方法栈里面数据计算返回,这里在计算时候使用的数据也可能不是最新的,其它写线程可能已经修改过原来的 x,y 值了。
总结乐观读锁的使用步骤:
long stamp = lock.tryOptimisticRead(); // 非阻塞获取版本信息
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
if(!lock.validate(stamp)){ // 校验
long stamp = lock.readLock(); // 获取读锁
try {
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
} finally {
lock.unlock(stamp); // 释放悲观锁
}
}
useThreadMemoryVarables(); // 使用线程本地堆栈里面的数据进行操作
moveIfAtOrigin
方法,如果当前坐标为原点则移动到指定的位置。
代码(1)获取悲观读锁,保证其它线程不能获取写锁修改 x,y 值。
代码(2)判断当前点在原点则更新坐标
代码(3)尝试升级读锁为写锁,这里升级不一定成功,因为多个线程都可以同时获取悲观读锁,当多个线程都执行到(3)时候只有一个可以升级成功,升级成功则返回非 0 的 stamp,否非返回 0。
假设当前线程升级成功,然后执行步骤(4)更新 stamp 值和坐标值然后退出循环;如果升级失败则执行步骤(5)首先释放读锁然后申请写锁,获取到写锁后在循环重新设置坐标值。
StampedLock
是不可重入的,如果一个线程已经持有了写锁,再去获取写锁的话就会造成死锁。StampedLock
支持读锁和写锁的相互转换。我们知道ReentrantReadWriteLock
中,当线程获取到写锁后,可以降级为读锁,但是读锁是不能直接升级为写锁的。而StampedLock
提供了读锁和写锁相互转换的功能,使得该类支持更多的应用场景。读写锁在读线程非常多,写线程很少的情况下可能会导致写线程饥饿,JDK1.8 新增的StampedLock
通过乐观读锁来解决这一问题。
StampedLock
有三种访问模式:
①写锁writeLock:功能和读写锁的写锁类似
②悲观读锁readLock:功能和读写锁的读锁类似
③乐观读锁Optimistic reading:一种优化的读模式
所有获取锁的方法,都返回一个票据 Stamp,Stamp 为 0 表示获取失败,其余都表示成功;所有释放锁的方法,都需要一个票据 Stamp,这个 Stamp 必须是和成功获取锁时得到的 Stamp 一致。
乐观读锁:乐观的认为在具体操作数据前其他线程不会对自己操作的数据进行修改,所以当前线程获取到乐观读锁的之后不会阻塞线程获取写锁。为了保证数据一致性,在具体操作数据前要检查一下自己操作的数据是否经过修改操作了,如果进行了修改操作,就重新读一次。因为乐观读锁不需要进行 CAS 设置锁的状态而只是简单的测试状态,所以在读多写少的情况下有更好的性能。