【高併發】ReadWriteLock怎麼和緩存扯上關係了?!

寫在前面

在實際工做中,有一種很是廣泛的併發場景:那就是讀多寫少的場景。在這種場景下,爲了優化程序的性能,咱們常用緩存來提升應用的訪問性能。由於緩存很是適合使用在讀多寫少的場景中。而在併發場景中,Java SDK中提供了ReadWriteLock來知足讀多寫少的場景。本文咱們就來講說使用ReadWriteLock如何實現一個通用的緩存中心。java

本文涉及的知識點有:git

文章已收錄到:github

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框架哦(推薦使用)~~

推薦閱讀

mykit-data開源地址:

好了,今天就到這兒吧,我是冰河,你們有啥問題能夠在下方留言,也能夠加我微信:sun_shine_lyz,我拉你進羣,一塊兒交流技術,一塊兒進階,一塊兒牛逼~~

相關文章
相關標籤/搜索