1.锁的一路演变
2.ReentrantReadWriteLock锁降级
3.比读写锁更快的锁————邮戳锁
4.总结
1.锁的一路演变
当我们在学习java的锁的时候,经历了以下四个阶段的锁演变:无锁→独占锁→读写锁→邮戳锁。
无锁:
我们一开始学会编写代码的时候,肯定写的都是无锁的代码。
优点:执行效率高
缺点:多线程无序抢夺导致错误数据
然后我们发现其中的问题,就学会了synchronized, reentrantlock。
优点:串行化保证了数据一致性
缺点:所有操作互斥,执行效率低
接着我们发现这样子效率太低,如果读线程占大多数,写线程占少数,就又去学习了ReentrantReadWriteLock:
优点:读读共享,读写互斥,提升了大面积的共享性能
缺点:读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿)
然后其实还有比读写锁更快的锁StampedLock(我们会在下文进行讲解)
优点:读的过程中也允许获取写锁介入,效率更高
缺点:不支持重入,不支持Condition,也不支持中断
这样就有了以下这样的表格:
无锁 | synchronized, reentrantlock | ReentrantReadWriteLock | StampedLock | |
---|---|---|---|---|
优点 | 执行效率高 | 串行化保证了数据一致性 | 读读共享,读写互斥,提升了大面积的共享性能 | 读的过程中也允许获取写锁介入,效率更高 |
缺点 | 多线程无序抢夺导致错误数据 | 所有操作互斥,执行效率低 | 读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿) | 不支持重入,不支持Condition,也不支持中断 |
我们前面刚学习了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();
}
}
文章图片
读写锁降级的目的:
在高并发的情况下,我们为了让程序感知到我们修改了内容,就先用读锁锁住这个结果,不让其它写线程进来,因为读读是可以共享的,保证了该次变化的数据可见性。
以下摘自ReentrantReadWriteLock的源码:
文章图片
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,也不支持中断 |