比读写锁更快的 StampedLock
预计阅读所需时间 7 分钟,建议收藏
我们先回顾上一篇 ReentrantReadWriteLock 读写锁,为什么有了 ReentrantReadWriteLock,还要引入 StampLock?
ReentrantReadWriteLock 使得多个读线程同时持有读锁(只要写未被占用),而写锁是独占的。但是很容易造成 “饥饿问题”:
读线程非常多,写线程很少的情况下,很容易导致写线程 “饥饿”
StampedLock 支持的三种锁模式
我们先来看看在使用上StampedLock 和上一篇文章讲的 ReadWriteLock 有哪些区别。
- ReadWriteLock 支持两种模式:一种是读锁,一种是写锁。写锁独占,读读共享、读写互斥。
- StampedLock 支持三种模式,分别是:写锁、悲观读锁和乐观读。其中,写锁、悲观读锁的语义和 ReadWriteLock 的写锁、读锁的语义非常类似,允许多个线程同时获取悲观读锁,但是只允许一个线程获取写锁,写锁和悲观读锁是互斥的。不同的是:StampedLock 里的写锁和悲观读锁加锁成功之后,都会返回一个 stamp;然后解锁的时候,需要传入这个 stamp。
StampedLock 支持读锁和写锁的相互转换 我们知道 RRW 中,当线程获取到写锁后,可以降级为读锁,但是读锁是不能直接升级为写锁的。StampedLock 提供了读锁和写锁相互转换的功能,使得该类支持更多的应用场景。
之所以性能比 ReentrantReadWriteLock好,其关键就是支持乐观读。ReadWriteLock 支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;
**而 StampedLock 提供的乐观读,是允许一个线程获取写锁的,也就是说不是所有的写操作都被阻塞。**
注意这里是乐观读,并不是 “乐观读锁”,其实它是无锁的,其实它跟数据库的乐观锁有异曲同工之妙。
乐观锁的实现很简单,在生产订单的表 product_doc 里增加了一个数值型版本号字段 version,每次更新 product_doc 这个表的时候,都将 version 字段加 1。
selectid,... ,version
from product_doc
whereid=777
而更新的时候匹配 version 才更新。
update product_doc
set version=version+1,...
where id=777 and version=9
你会发现数据库里的乐观锁,查询的时候需要把 version 字段查出来,更新的时候要利用 version 字段做验证。这个 version 字段就类似于 StampedLock 里面的 stamp。这样对比着看,相信你会更容易理解 StampedLock 里乐观读的用法。
StampedLock 代码示例
class Point {
// 共享变量 x、y 坐标
privatedouble x, y;
privatefinal StampedLock sl = new StampedLock();
/**
* 移动坐标
*
* @param deltaX
* @param deltaY
*/
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock(); //涉及到对共享资源的修改,使用写锁-独占
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
/**
* 使用乐观读访问共享资源:计算到原点的距离。
* 注意:乐观读锁在保证数据一致性上需要拷贝一份要操作的变量到方法栈,并且在操作数据时候 * 可能其他写线程已经修改了数据,
* 而我们操作的是方法栈里面的数据,也就是一个快照,所以最多返回的不是最新的数据,但是一致性还是得到保障的。
*
* @return
*/
public 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);
}
/**
* 读锁转换写锁:若当前坐标在原点则移动
*
* @param newX
* @param newY
*/
public void moveIfAtOrigin(double newX, double newY) {
// 不能直接使用乐观读,不是只读的方法
long stamp = sl.readLock();
try {
while (x == 0.0 && y == 0.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);
}
}
}
上述例子中,特殊的就是 distanceFromOrigin() 与 moveIfAtOrigin() 方法,第一个方法使用了 乐观读,让读写可以并发执行,通过上面例子我们也总结出 乐观读的使用模板。第二个则是使用了读锁转换成写锁的方式。
long stamp = lock.tryOptimisticRead(); // 乐观读
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
if(!lock.validate(stamp)){ // 校验是否被修改
long stamp = lock.readLock(); // 获取悲观读锁
try {
copyVaraibale2ThreadMemory(); // 拷贝变量到线程本地堆栈
} finally {
lock.unlock(stamp); // 释放悲观锁
}
}
useThreadMemoryVarables(); // 使用局部变量进行数据操作
StampedLock 使用注意事项
对于读多写少的场景 StampedLock 性能很好,简单的应用场景基本上可以替代 ReadWriteLock,但是StampedLock 的功能仅仅是 ReadWriteLock 的子集,在使用的时候,还是有几个地方需要注意一下。
- StampedLock 在命名上并没有增加 Reentrant,想必你已经猜测到 StampedLock 应该是不可重入的。事实上,的确是这样的,StampedLock 不支持重入。这个是在使用中必须要特别注意的。
- 另外,StampedLock 的悲观读锁、写锁都不支持条件变量,这个也需要注意 。
- 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 方法,会导致 CPU 飙升。所以,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,一定使用可中断的悲观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规则一定要记清楚。
文章转载自公众号:码哥字节