1.锁的一路演变

2.ReentrantReadWriteLock锁降级

3.比读写锁更快的锁————邮戳锁

4.总结

1.锁的一路演变
当我们在学习java的锁的时候,经历了以下四个阶段的锁演变:无锁→独占锁→读写锁→邮戳锁

无锁:
我们一开始学会编写代码的时候,肯定写的都是无锁的代码。
优点:执行效率高
缺点:多线程无序抢夺导致错误数据

然后我们发现其中的问题,就学会了synchronized, reentrantlock
优点:串行化保证了数据一致性
缺点:所有操作互斥,执行效率低

接着我们发现这样子效率太低,如果读线程占大多数,写线程占少数,就又去学习了ReentrantReadWriteLock
优点:读读共享,读写互斥,提升了大面积的共享性能
缺点:读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿)

然后其实还有比读写锁更快的锁StampedLock(我们会在下文进行讲解)
优点:读的过程中也允许获取写锁介入,效率更高
缺点:不支持重入,不支持Condition,也不支持中断

这样就有了以下这样的表格:

无锁synchronized, reentrantlockReentrantReadWriteLockStampedLock
优点执行效率高串行化保证了数据一致性读读共享,读写互斥,提升了大面积的共享性能读的过程中也允许获取写锁介入,效率更高
缺点多线程无序抢夺导致错误数据所有操作互斥,执行效率低读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿)不支持重入,不支持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();

    }
}

image.png

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

以下摘自ReentrantReadWriteLock的源码:
image.png
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);
    }

4.总结
这次我们学习的锁的演变,每一种锁都有各自的优缺点,再上一下上面那个表格。

无锁synchronized, reentrantlockReentrantReadWriteLockStampedLock
优点执行效率高串行化保证了数据一致性读读共享,读写互斥,提升了大面积的共享性能读的过程中也允许获取写锁介入,效率更高
缺点多线程无序抢夺导致错误数据所有操作互斥,执行效率低读线程还没结束永,写线程永远不可能会获得锁(造成锁饥饿)不支持重入,不支持Condition,也不支持中断

苏凌峰
73 声望38 粉丝

你的迷惑在于想得太多而书读的太少。