预计阅读所需时间 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。
select id,... ,version from product_doc where id=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 坐标 private double x, y; private final 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()。这个规则一定要记清楚。