写在后面

在理论工作中,有一种十分广泛的并发场景:那就是读多写少的场景。在这种场景下,为了优化程序的性能,咱们常常应用缓存来进步利用的拜访性能。因为缓存非常适合应用在读多写少的场景中。而在并发场景中,Java SDK中提供了ReadWriteLock来满足读多写少的场景。本文咱们就来说说应用ReadWriteLock如何实现一个通用的缓存核心。

本文波及的知识点有:

文章已收录到:

https://github.com/sunshinelyz/technology-binghe

https://gitee.com/binghe001/technology-binghe

读写锁

说起读写锁,置信小伙伴们并不生疏。总体来说,读写锁须要遵循以下准则:

  • 一个共享变量容许同时被多个读线程读取到。
  • 一个共享变量在同一时刻只能被一个写线程进行写操作。
  • 一个共享变量在被写线程执行写操作时,此时这个共享变量不能被读线程执行读操作。

这里,须要小伙伴们留神的是:读写锁和互斥锁的一个重要的区别就是:读写锁容许多个线程同时读共享变量,而互斥锁不容许。所以,在高并发场景下,读写锁的性能要高于互斥锁。然而,读写锁的写操作是互斥的,也就是说,应用读写锁时,一个共享变量在被写线程执行写操作时,此时这个共享变量不能被读线程执行读操作。

读写锁反对偏心模式和非偏心模式,具体是在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();        }    }}}

数据同步问题

首先,这里说的数据同步指的是数据源和数据缓存之间的数据同步,说的再间接一点,就是数据库和缓存之间的数据同步。

这里,咱们能够采取三种计划来解决数据同步的问题,如下图所示

超时机制

这个比拟好了解,就是在向缓存写入数据的时候,给一个超时工夫,当缓存超时后,缓存的数据会主动从缓存中移除,此时程序再次拜访缓存时,因为缓存中不存在相应的数据,查询数据库失去数据后,再将数据写入缓存。

采纳这种计划须要留神缓存的穿透问题,无关缓存穿透、击穿、雪崩的常识,小伙伴们能够参见《【高并发】面试官:讲讲什么是缓存穿透?击穿?雪崩?如何解决?》

定时更新缓存

这种计划是超时机制的增强版,在向缓存中写入数据的时候,同样给一个超时工夫。与超时机制不同的是,在程序后盾独自启动一个线程,定时查询数据库中的数据,而后将数据写入缓存中,这样可能在肯定水平上防止缓存的穿透问题。

实时更新缓存

这种计划可能做到数据库中的数据与缓存的数据是实时同步的,能够应用阿里开源的Canal框架实现MySQL数据库与缓存数据的实时同步。也能够应用我集体开源的mykit-data框架哦(举荐应用)~~

举荐浏览

  • 【高并发】面试官:讲讲什么是缓存穿透?击穿?雪崩?如何解决?
  • 两行代码修复了解析MySQL8.x binlog错位的问题!!

mykit-data开源地址:

  • https://github.com/sunshinelyz/mykit-data
  • https://gitee.com/binghe001/mykit-data

好了,明天就到这儿吧,我是冰河,大家有啥问题能够在下方留言,一起交换技术,一起进阶,一起牛逼~~