Java并发编程——互斥3——读写锁——基于ReadWriteLock开发高性能缓存

🌈hello,你好鸭,我是Ethan,西安电子科技大学大三在读,很高兴你能来阅读。

✔️目前博客主要更新Java系列、项目案例、计算机必学四件套等。
🏃人生之义,在于追求,不在成败,勤通大道。加油呀!

🔥个人主页:Ethan Yankang
🔥推荐:史上最强八股文||一分钟看完我的几百篇博客

🔥温馨提示:划到文末发现专栏彩蛋   点击这里直接传送

🔥本篇概览:详细讲解了Java并发编程——互斥3——读写锁,基于ReadWriteLock开发高性能缓存🌈⭕🔥


【计算机领域一切迷惑的源头都是基本概念的模糊,算法除外】


🌈序言:

JAVA并发编程一直是难点,痛点,但又是进阶之重点,此关必过。今日得《冰河技术》之良品辅助,应按本系列学之习之,时时复习,长此以往必能穿魂入脉,习得大功。

记住——别违背科学发展的客观规律。别一味地赶进度以满足自己学的都么快的虚荣心,自欺欺人,要老老实实的走好每一步。

 【并发编程全貌】


🔥Java并发编程全集

🔥 所有JAVA基础一键查阅(含习题集)-CSDN博客


🌈引出

        在实际工作中,有一种非常普遍的并发场景:那就是读多写少的场景。在这种场景下,为了优化程序的性能,我们经常使用缓存
        来提高应用的访问性能。因为缓存非常适合使用在读多写少的场景中。而在并发场景中,Java SDK 中提供了 ReadWriteLock 来满足读多写少的场景。本文我们就来说说使用ReadWriteLock 如何实现一个通用的缓存中心。
本文涉及的知识点有:

读写锁

        说起读写锁,相信小伙伴们并不陌生。总体来说,读写锁需要遵循以下原则:
  • 一个共享变量允许同时被多个读线程读取到。
  • 一个共享变量在同一时刻只能被一个写线程进行写操作。
  • 一个共享变量在被写线程执行写操作时,此时这个共享变量不能被读线程执行读操作。[反之亦然]
    [但是如果是自身可以在持有写锁的情况下,自身可以获取读锁,因为这叫锁重入与降级,保证一致性]
【读共享、写排他、读写互斥】
       
         这里,需要小伙伴们注意的是:读写锁和互斥锁的一个重要的区别就是:读写锁允许多个线程同时读共享变量,而互斥锁不允 。所以,在高并发场景下,读写锁的性能要高于互斥锁。但是,读写锁的写操作是互斥的,也就是说,使用读写锁时,一个共 享变量在被写线程执行写操作时,此时这个共享变量不能被读线程执行读操作。[也就是对写锁互斥嘛,针对同一变量只能存在一个]
        读写锁支持公平模式和非公平模式,具体是在 ReentrantReadWriteLock 的构造方法中传递一个 boolean 类型的变量来控制。
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

另外,需要注意的一点是:在读写锁中,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常,也就是 说:读写锁不支持条件变量。

缓存实现

这里,我们使用 ReadWriteLock 快速实现一个缓存的通用工具类,总体代码如下所示:

public class ReadWriteLockCache<K, V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl = new ReentrantReadWriteLock();
    // 读锁
    private final Lock r = rwl.readLock();
    // 写锁
    private final Lock w = rwl.writeLock();

    // 读缓存
    public V get(K key) {
        r.lock();
        try {
            return m.get(key);
        } finally {
            r.unlock();
        }
    }

    // 写缓存
    public V put(K key, V value) {
        w.lock();
        try {
            return m.put(key, value);
        } finally {
            w.unlock();
        }
    }
}
        可以看到,在ReadWriteLockCache 中,我们定义了两个泛型类型, K 代表缓存的 Key V 代表缓存的 value 。在ReadWriteLockCache类的内部,我们使用 Map 来缓存相应的数据,小伙伴都知道 HashMap 并不是线程安全的类,所以,这里使用了读写锁来保证线程的安全性,例如,我们在get() 方法中使用了读锁, get() 方法可以被多个线程同时执行读操作【读写锁专有特点】 put() 方法内部使用写锁,也就是说,put() 方法在同一时刻只能有一个线程对缓存进行写操作。
        这里需要注意的是:无论是读锁还是写锁,锁的释放操作都需要放到 finally{} 代码块中。
在以往的经验中,有两种向缓存中加载数据的方式——
一种是:项目启动时,将数据全量加载到缓存中; 一种是在项目运行期间, 按需加载所需要的缓存数据。

接下来,我们就分别来看看全量加载缓存和按需加载缓存的方式。

全量加载缓存

        全量加载缓存相对来说比较简单,就是在项目启动的时候,将数据一次性加载到缓存中,这种情况适用于缓存数据量不大,数据变动不频繁的场景,例如:可以缓存一些系统中的数据字典等信息。整个缓存加载的大体流程如下所示。

将数据全量加载到缓存后,后续就可以直接从缓存中读取相应的数据了。
全量加载缓存的代码实现比较简单,这里,我就直接使用如下代码进行演示。

public class ReadWriteLockCache<K, V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl = new ReentrantReadWriteLock();
    // 读锁
    private final Lock r = rwl.readLock();
    // 写锁
    private final Lock w = rwl.writeLock();

public ReadWriteLockCache(){
//查询数据库
    List<Field<K, V>> list = .....;
    if(!CollectionUtils.isEmpty(list)){
    list.parallelStream().forEach((f) ->{
        m.put(f.getK(), f.getV);});
        }
    }

    // 读缓存
    public V get(K key) {
        r.lock();
        try {
            return m.get(key);
        } finally {
            r.unlock();
        }
    }

    // 写缓存
    public V put(K key, V value) {
        w.lock();
        try {
            return m.put(key, value);
        } finally {
            w.unlock();
        }
    }
}

按需加载缓存

按需加载缓存也可以叫作懒加载,就是说:需要加载的时候才会将数据加载到缓存。具体来说:就是程序启动的时候,不会将数据加载到缓存,当运行时,需要查询某些数据,首先检测缓存中是否存在需要的数据,如果存在,则直接读取缓存中的数据,如 果不存在,则到数据库中查询数据,并将数据写入缓存。后续的读取操作,因为缓存中已经存在了相应的数据,直接返回缓存的
数据即可。

这种查询缓存的方式适用于大多数缓存数据的场景。
我们可以使用如下代码来表示按需查询缓存的业务。

class ReadWriteLockCache<K, V> {
    private final Map<K, V> m = new HashMap<>();
    private final ReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();

    V get(K key) {
        V v = null;
//读缓存
        r.lock();
        try {
            v = m.get(key);
        } finally{
            r.unlock();
        }
//缓存中存在,返回
        if(v != null) {
            return v;
        }
//缓存中不存在,查询数据库
        w.lock();
        try {
//再次验证缓存中是否存在数据,可能有线程已经写入
            v = m.get(key);
            if(v == null){
//查询数据库
                v=从数据库中查询出来的数据
                m.put(key, v);
            }
        } finally{
            w.unlock();
        }
        return v;
    }
}

        这里,在get() 方法中,首先从缓存中读取数据,此时,我们对查询缓存的操作添加了读锁,查询返回后,进行解锁操作。判断缓存中返回的数据是否为空,不为空,则直接返回数据;如果为空,则获取写锁,之后再次从缓存中读取数据,如果缓存中不存在数据,则查询数据库,将结果数据写入缓存,释放写锁。最终返回结果数据。
        这里,有小伙伴可能会问:为啥程序都已经添加写锁了,在写锁内部为啥还要查询一次缓存呢?
        这是因为在高并发的场景下,可能会存在多个线程来竞争写锁的现象。例如:第一次执行get() 方法时,缓存中的数据为空。如果此时有三个线程同时调用get() 方法,同时运行到 w.lock() 代码处,由于写锁的排他性。此时只有一个线程会获取到写锁,其他 两个线程则阻塞在 w.lock() 处。获取到写锁的线程继续往下执行查询数据库,将数据写入缓存,之后释放写锁。 此时,另外两个线程竞争写锁,某个线程会获取到锁,继续往下执行,如果在 w.lock() 后没有 v = m.get(key) ; 再次查询缓存 的数据,则这个线程会直接查询数据库,将数据写入缓存后释放写锁。最后一个线程同样会按照这个流程执行。
        这里,实际上第一个线程已经查询过数据库,并且将数据写入缓存了,其他两个线程就没必要再次查询数据库了,直接从缓存中 查询出相应的数据即可。所以,在 w.lock() 后添加 v = m.get(key) ; 再次查询缓存的数据,能够有效的减少高并发场景下重复 查询数据库的问题,提升系统的性能。

读写锁的升降级


升级是指:读锁升级为写锁。降级是指写锁降级为读锁。

[可不,写互斥,读共享,当然互斥的级别高]

读写锁的策略是——不支持升级,但支持降级


        关于锁的升降级,小伙伴们需要注意的是:在ReadWriteLock 中,锁是不支持升级的,因为读锁还未释放时,此时若允许升级[即读锁变为写锁],高并发情形下那就会有无数线程的读锁都会升级为写锁, 就会导致真正有处理需求的线程的写锁可能会无线推迟,永久等待,相应的线程也会被阻塞而无法唤醒。
实际上这里有专业说法——“写锁饥饿”
写锁饥饿指的是真正需要写锁的线程因为其他线程升级锁的操作,而一直无法获得写锁,导致可能永久等待,甚至发生死锁的风险
        虽然不支持锁升级,但是ReadWriteLock 支持锁降级,例如,我们来看看官方的 ReentrantReadWriteLock 示例,如下所示。

class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
// Must release read lock before acquiring write lock
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
                if (!cacheValid) {
                    data = ...
                    cacheValid = true;
                }
// Downgrade by acquiring read lock before releasing write lock
// 通过在释放写入锁之前获取读锁来降低等级,等级降低的含义就是由排他锁[写]变为了共享锁[读]
                rwl.readLock().lock();
            } finally {
                rwl.writeLock().unlock(); // Unlock write, still hold read
            }
        }
        try {
            use(data);
        } finally {
            rwl.readLock().unlock();
        }
    }
  }
}

这里的代码块中,这个片段很有讲究:

rwl.readLock().lock();    // 1. 获取读锁
} finally {
    rwl.writeLock().unlock();  // 2. 释放写锁,但仍持有读锁
}

        这是在本线程还未释放写锁的情况下,获取了自身的读锁,这成为锁重入。之后在通过释放了写锁完成了锁降级——从写—>读。

锁降级的用途

一般这种用法在于线程想要修改共享资源,修改了后可以确保立即对其他线程可见,但是希望该共享资源依然受锁保护,而且本线程仍然可以实现对共享资源的监督与保护的情景。

锁降级的步骤

结合上面的情景,共有四大步:

获取写锁——>[重入]获取读锁——>释放写锁——>释放写锁

[读写读写!]

锁降级的目的

        这是为了保证在自身修改完数据后,防止其他线程来修改数据,所以在这期间一直未有释放写锁,直至获取了读锁之后,确保自身就算释放了锁,也不会被其他线程修改数据,确保了数据的一致性。同时,降级后本线程依然有锁,这样实现了释放读锁后的共享资源监控与保护,本线程仍然可以感知共享资源的并保证其一致性


数据同步问题【双写一致问题】

        首先,这里说的数据同步指的是数据源和数据缓存之间的数据同步,说的再直接一点,就是数据库和缓存之间的数据同步。【双写一致】
        这里,我们可以采取三种方案来解决数据同步的问题,如下图所示:

超时机制
这个比较好理解,就是在向缓存写入数据的时候,给一个超时时间,当缓存超时后,缓存的数据会自动从缓存中移除,此时程序再次访问缓存时,由于缓存中不存在相应的数据,查询数据库得到数据后,再将数据写入缓存。
采用这种方案需要注意缓存的穿透问题,有关缓存穿透、击穿、雪崩的知识,小伙伴们可以参见《 【高并发】面试官:讲讲什么
是缓存穿透?击穿?雪崩?如何解决?
定时更新缓存
这种方案是超时机制的增强版,在向缓存中写入数据的时候,同样给一个超时时间。与超时机制不同的是,在程序后台单独启动 一个线程,定时查询数据库中的数据,然后将数据写入缓存中【这是最牛逼的,之前网课也讲过,这样能够在一定程度上避免缓存的穿透问题。
实时更新缓存
这种方案能够做到数据库中的数据与缓存的数据是实时同步的,可以使用阿里开源的 Canal框架实现MySQL数据库与缓存数据的实时同步。



💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖💖

热门专栏推荐

🌈🌈计算机科学入门系列                     关注走一波💕💕

🌈🌈CSAPP深入理解计算机原理        关注走一波💕💕

🌈🌈微服务项目之黑马头条                 关注走一波💕💕

🌈🌈redis深度项目之黑马点评            关注走一波💕💕

🌈🌈JAVA面试八股文系列专栏           关注走一波💕💕

🌈🌈JAVA基础试题集精讲                  关注走一波💕💕   

🌈🌈代码随想录精讲200题                  关注走一波💕💕


总栏

🌈🌈JAVA基础要夯牢                         关注走一波💕💕  

🌈🌈​​​​​​JAVA后端技术栈                          关注走一波💕💕  

🌈🌈JAVA面试八股文​​​​​​                          关注走一波💕💕  

🌈🌈JAVA项目(含源码深度剖析)    关注走一波💕💕  

🌈🌈计算机四件套                               关注走一波💕💕  

🌈🌈数据结构与算法                           ​关注走一波💕💕  

🌈🌈必知必会工具集                           关注走一波💕💕

🌈🌈书籍网课笔记汇总                       关注走一波💕💕         



📣非常感谢你阅读到这里,如果这篇文章对你有帮助,希望能留下你的点赞👍 关注❤收藏✅ 评论💬,大佬三连必回哦!thanks!!!
📚愿大家都能学有所得,功不唐捐!

  • 5
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值