JAVA并发编程——ReentrantReadWriteLock锁降级和StampedLock

1.锁的一路演变
2.ReentrantReadWriteLock锁降级
3.比读写锁更快的锁————邮戳锁
4.总结
1.锁的一路演变
当我们在学习java的锁的时候,经历了以下四个阶段的锁演变:无锁→独占锁→读写锁→邮戳锁。
无锁:
我们一开始学会编写代码的时候,肯定写的都是无锁的代码。
优点:执行效率高
缺点:多线程无序抢夺导致错误数据
然后我们发现其中的问题,就学会了synchronized, reentrantlock。
优点:串行化保证了数据一致性
缺点:所有操作互斥,执行效率低
接着我们发现这样子效率太低,如果读线程占大多数,写线程占少数,就又去学习了ReentrantReadWriteLock:
优点:读读共享,读写互斥,提升了大面积的共享性能
缺点:读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿)
然后其实还有比读写锁更快的锁StampedLock(我们会在下文进行讲解)
优点:读的过程中也允许获取写锁介入,效率更高
缺点:不支持重入,不支持Condition,也不支持中断
这样就有了以下这样的表格:

无锁 synchronized, reentrantlock ReentrantReadWriteLock StampedLock
优点 执行效率高 串行化保证了数据一致性 读读共享,读写互斥,提升了大面积的共享性能 读的过程中也允许获取写锁介入,效率更高
缺点 多线程无序抢夺导致错误数据 所有操作互斥,执行效率低 读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿) 不支持重入,不支持Condition,也不支持中断
2.ReentrantReadWriteLock锁降级
我们前面刚学习了JAVA并发编程——Synchronized与锁升级,今天我们来学习一下锁降级。
我们先来看一下ReentrantReadWriteLock锁的定义:
一个资源能别多个读线程访问,或者被一个写线程访问。
也就是说:
读读不互斥
写写互斥
读写互斥
只有在读多写少情境之下,读写锁才具有较高的性能体现。
而锁降级又是什么呢?
锁降级:
将写入锁降级为读锁(就像linux文件读写权限,写权限一定会高于读权限)
换句话说,我们在lock.writeLock(); 的同时,可以再进行lock.readLock(),这个时候读锁就会降级成写锁,反之则不行,程序会死锁。
这样说我们可能还是看不太懂,我们直接用代码解释好了。
import java.util.concurrent.locks.ReentrantReadWriteLock; /** * 锁降级:遵循获取写锁→再获取读锁→再释放写锁的次序,写锁能够降级成为读锁。 * * 如果一个线程占有了写锁,在不释放写锁的情况下,它还能占有读锁,即写锁降级为读锁。 */ public class LockDownGradingDemo { public static void main(String[] args) { ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock(); ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock(); writeLock.lock(); System.out.println("-------正在写入"); readLock.lock(); System.out.println("-------正在读取"); writeLock.unlock(); } }

JAVA并发编程——ReentrantReadWriteLock锁降级和StampedLock
文章图片

读写锁降级的目的:
在高并发的情况下,我们为了让程序感知到我们修改了内容,就先用读锁锁住这个结果,不让其它写线程进来,因为读读是可以共享的,保证了该次变化的数据可见性。
以下摘自ReentrantReadWriteLock的源码:
JAVA并发编程——ReentrantReadWriteLock锁降级和StampedLock
文章图片

1 代码中声明了一个volatile类型的cacheValid变量,保证其可见性。
2 首先获取读锁,如果cache不可用,则释放读锁,获取写锁,在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true,然后在释放写锁前获取读锁;此时,cache中数据可用,处理cache中数据,最后释放读锁。这个过程就是一个完整的锁降级的过程,目的是保证数据可见性。
如果违背锁降级的步骤
如果当前的线程C在修改完cache中的数据后,没有获取读锁而是直接释放了写锁,那么假设此时另一个线程D获取了写锁并修改了数据,那么C线程无法感知到数据已被修改,则数据出现错误。
如果遵循锁降级的步骤
线程C在释放写锁之前获取读锁,那么线程D在获取写锁时将被阻塞,直到线程C完成数据处理过程,释放读锁。这样可以保证返回的数据是这次更新的数据,该机制是专门为了缓存设计的。
3.比读写锁更快的锁————邮戳锁
因为读写锁有锁饥饿的问题。
锁饥饿:如果现在有1000个线程,999个读,1个写,那就是读线程长时间占据锁,而写线程长时间无法获取锁。
那么如何缓解锁饥饿问题?我们有下面这几个解决办法
1)使用公平锁策略可以一定程度上缓解这个问题,但是吞吐量不高
2)使用邮戳锁
为了解决这个问题,我们使用邮戳锁:
SteampedLock有三种访问模式
1)Reading(读模式):功能和ReentrantReadWriteLock读锁类似
2)Writing(写模式):功能和ReentrantReadWriteLock写锁类似
3)Optimistic reading(乐观读模式):无锁机制,类似于数据库中的乐观锁,支持读写并发,很乐观认为读取时没人修改,假如被修改再实现为悲观读模式。
//乐观读 //我们通过刚开始获得的版本号开判断是不是有人动过这个数据 //然后如果有人修改过,再进行锁升级 public void tryOptimisticRead() { //先获取一个乐观读标志位 long stamp = stampedLock.tryOptimisticRead(); int result = number; //间隔4秒钟,我们很乐观的认为没有其他线程修改过number值,实际靠判断。 System.out.println("4秒前stampedLock.validate值(true无修改,false有修改)" + "\t" + stampedLock.validate(stamp)); for (int i = 1; i < 4; i++) { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + "\t 正在读取中......" + i + "秒后stampedLock.validate值(true无修改,false有修改)" + "\t" + stampedLock.validate(stamp)); } if (!stampedLock.validate(stamp)) { System.out.println("有人动过--------存在写操作!"); stamp = stampedLock.readLock(); try { System.out.println("从乐观读 升级为 悲观读"); result = number; System.out.println("重新悲观读锁通过获取到的成员变量值result:" + result); } catch (Exception e) { e.printStackTrace(); } finally { stampedLock.unlockRead(stamp); } } System.out.println(Thread.currentThread().getName() + "\t finally value: " + result); }

【JAVA并发编程——ReentrantReadWriteLock锁降级和StampedLock】 4.总结
这次我们学习的锁的演变,每一种锁都有各自的优缺点,再上一下上面那个表格。
无锁 synchronized, reentrantlock ReentrantReadWriteLock StampedLock
优点 执行效率高 串行化保证了数据一致性 读读共享,读写互斥,提升了大面积的共享性能 读的过程中也允许获取写锁介入,效率更高
缺点 多线程无序抢夺导致错误数据 所有操作互斥,执行效率低 读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿) 不支持重入,不支持Condition,也不支持中断

    推荐阅读