【Java併發工具類】ReadWriteLock

前言

前面介紹過ReentrantLock,它實現的是一種標準的互斥鎖:每次最多隻有一個線程能持有ReentrantLock。這是一種強硬的加鎖規則,在某些場景下會限制併發性致使沒必要要的抑制性能。互斥是一種保守的加鎖策略,雖然能夠避免「寫/寫」衝突和「寫/讀」衝突,可是一樣也避免了「讀/讀」衝突。java

在讀多寫少的狀況下,若是可以放寬加鎖需求,容許多個執行讀操做的線程同時訪問數據結構,那麼將提高程序的性能。只要每一個線程都能確保讀到最新的數據,而且在讀取數據時不會有其餘的線程修改數據,那麼就不會發生問題。在這種狀況下,就可使用讀寫鎖一個資源能夠被多個讀操做訪問,或者被一個寫操做訪問,但二者不能同時進行。數據庫

Java中讀寫鎖的實現是ReadWriteLock。下面咱們先介紹什麼是讀寫鎖,而後利用讀寫鎖快速實現一個緩存,最後咱們再來介紹讀寫鎖的升級與降級。編程

什麼是讀寫鎖

讀寫鎖是一種性能優化措施,在讀多寫少場景下,能實現更高的併發性。讀寫鎖的實現須要遵循如下三項基本原則:緩存

  1. 容許多個線程同時讀共享變量;
  2. 只容許一個線程寫共享變量;
  3. 若是一個線程正在執行寫操做,此時禁止讀線程讀共享便利。

讀寫鎖與互斥鎖的一個重要區別就是:讀寫鎖容許多個線程同時讀共享變量,而互斥鎖是不容許的。讀寫鎖的寫操做時互斥的。安全

下面是ReadWriteLock接口:性能優化

public interface ReadWriteLock{
    Lock readLock();
    Lock writeLock();
}

其中,暴露了兩個Lock對象,一個用於讀操做,一個用於寫操做。要讀取由ReadWriteLock保護的數據,必須首先得到讀取鎖,當須要修改由ReadWriteLock保護的數據時,必須首先得到寫入鎖。儘管這兩個鎖看上去是彼此獨立的,但讀取鎖和寫入鎖只是讀寫鎖對象的不一樣視圖。數據結構

與Lock同樣,ReadWriteLock能夠採用多種不一樣的實現方式,這些方式在性能、調度保證、獲取優先性、公平性以及加鎖語義等方面可能有些不一樣。讀取鎖與寫入鎖之間的交互方式也能夠採用多種方式實現。多線程

ReadWriteLock中有一些可選實現包括:併發

  • 釋放優先:當一個寫入操做釋放寫入鎖時,而且隊列中同時存在讀線程和寫線程,那麼應該優先選擇讀線程,寫線程,仍是最早發出請求的線程?
  • 讀線程插隊:若是鎖是由讀線程持有,但有寫線程正在等待,那麼新到達的讀線程可否當即得到訪問權,仍是應該在寫線程後面等待?若是容許讀線程插隊到寫線程以前,那麼將提升併發性,但卻可能形成寫線程發生飢餓問題。
  • 重入性:讀取鎖和寫入鎖是不是可重入的?
  • 降級:若是一個線程持有寫入鎖,那麼它可否在不釋放該鎖的狀況下得到讀取鎖?這可能會使得寫入鎖被「降級」爲讀取鎖,同時不容許其餘寫線程修改被保護的資源。
  • 升級:讀取鎖可否優先於其餘正在等待的讀線程和寫線程而升級爲一個寫入鎖?在大多數的讀-寫鎖實現中並不支持升級,由於若是沒有顯式的升級操做,那麼很容易形成死鎖。(若是兩個讀線程試圖同時升級爲讀寫鎖,那麼兩者都不會釋放讀取鎖。)

ReentrantReadWriteLock

ReentrantReadWriteLock是ReadWriteLock的一個實現,它爲讀取鎖和寫入鎖都提供了可重入的加鎖語義。與ReentrantLock類似,ReentrantReadWriteLock在構造時也能夠選擇是一個非公平的鎖(默認)仍是一個公平的鎖。高併發

在公平的鎖中,等待時間最長的線程將優先得到鎖。若是這個線程是由讀線程持有,而另外一個線程請求寫入鎖,那麼其餘讀線程都不能得到讀取鎖,直到寫線程使用完而且釋放了寫入鎖。

在非公平的鎖中,線程得到訪問許可的順序是不肯定的。寫線程降級爲讀線程是能夠的,但從讀線程升級爲寫線程則是不能夠的(容易致使死鎖)。

實現一個快速緩存

下面使用ReentrantReadWriteLock來實現一個通用的緩存工具類。

實現一個Cache<K,V>類,類型參數K表明緩存中key類型,V表明緩存裏的value類型。咱們將緩存數據存儲在Cache類中的HashMap中,可是HashMap不是線程安全的,因此咱們使用讀寫鎖來保證其線程安全
Cache工具類提供了兩個方法,讀緩存方法get()和寫緩存方法put()。讀緩存須要用到讀取鎖,讀取鎖的使用方法同Lock使用方式一致,都須要使用try{}finally{}編程範式。寫緩存須要用到寫入鎖,寫入鎖和讀取鎖使用相似。

代碼參考以下:(代碼來自參考[1])

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 讀取鎖
    final Lock w = rwl.writeLock(); // 寫入鎖
    
    // 讀緩存
    V get(K key) {
        r.lock(); // 獲取讀取鎖
        try { 
            return m.get(key); 
        }finally { 
            r.unlock();  // 釋放讀取鎖
        }
    }
    
    // 寫緩存
    V put(K key, V value) {
        w.lock(); // 獲取寫入鎖
        try { 
            return m.put(key, v); 
        }finally { 
            w.unlock(); // 釋放寫入鎖 
        }
    }
}

緩存數據的初始化

使用緩存首先要解決緩存數據的初始化問題。緩存數據初始化,能夠採用一次性加載的方式,也可使用按需加載的方式。

若是源頭數據的數據量不大,就能夠採用一次性加載的方式,這種方式也最簡單。只須要在應用啓動的時候把源頭數據查詢出來,依次調用相似上面代碼的put()方式就能夠了。可參考下圖(圖來自參考[1])

image-20200217192615172

若是源頭數據量很是大,那麼就須要按需加載,按需加載也叫作懶加載。指的是隻有當應用查詢緩存,而且數據不在緩存裏的時候,才觸發加載源頭相關數據進行緩存的操做。可參考下圖(圖來自參考[1])

image-20200217192844702

實現緩存的按需加載

下面代碼實現了按需加載的功能(代碼來自參考[1])。
這裏假設緩存的源頭時數據庫。若是緩存中沒有緩存目標對象,那麼就須要從數據庫中加載,而後寫入緩存,寫緩存是須要獲取寫入鎖。

class Cache<K,V> {
    final Map<K, V> m = new HashMap<>();
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 讀取鎖
    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; 
    }
}

當緩存中不存在目標對象時,須要查詢數據庫,在上述代碼中,咱們在執行真正的查庫以前,又查看了緩存中是否已經存在目標對象,這樣作的好處是能夠避免重複查詢提高效率。咱們舉例說明這樣作的益處。

在高併發的場景下,有可能會有多線程競爭寫鎖。假設緩存是空的,沒有緩存任何東西,若是此時有三個線程 T一、T2 和 T3 同時調用get()方法,而且參數 key也是相同的。那麼它們會同時執行到代碼①處,但此時只有一個線程可以得到寫鎖。
假設是線程 T1,線程 T1 獲取寫鎖以後查詢數據庫並更新緩存,最終釋放寫鎖。
此時線程 T2 和 T3 會再有一個線程可以獲取寫鎖,假設是 T2,若是不採用再次驗證的方式,此時 T2 會再次查詢數據庫。T2 釋放寫鎖以後,T3 也會再次查詢一次數據庫。
而實際上線程 T1 已經把緩存的值設置好了,T二、T3 徹底沒有必要再次查詢數據庫。

讀寫鎖的升級與降級

上面讀取鎖的獲取釋放與寫入鎖的讀取和釋放是沒有嵌套的。若是咱們改一改代碼,將再次驗證並更新緩存的邏輯換個位置放置:

//讀緩存
r.lock(); // 獲取讀取鎖      
try {
    v = m.get(key); 
    if (v == null) {
        w.lock(); // 獲取寫入鎖
        try {
            //再次驗證並更新緩存
            //省略詳細代碼
         } finally{
            w.unlock(); // 釋放寫入鎖
         }
    }
} finally{
  r.unlock(); // 釋放讀取鎖
}

上述代碼,在獲取讀取鎖後,又試圖獲取寫入鎖,即咱們前面介紹的鎖的升級。可是,ReadWriteLock是不支持這種升級,在代碼中,讀取鎖尚未釋放,又嘗試獲取寫入鎖,將致使相關線程被阻塞(讀取鎖和寫入鎖只是讀寫鎖對象的不一樣視圖),永遠沒有機會被喚醒。

雖然鎖的升級不被容許,可是鎖的降級倒是被容許的。(下例代碼來自參考[1])

class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReadWriteLock rwl = new ReentrantReadWriteLock();
    final Lock r = rwl.readLock(); // 讀取鎖
    final Lock w = rwl.writeLock(); //寫入鎖
  
    void processCachedData() {
        // 獲取讀取鎖
        r.lock();
        if (!cacheValid) {
            r.unlock(); // 釋放讀取鎖,由於不容許讀取鎖的升級
            w.lock(); // 獲取寫入鎖
            try {
                // 再次檢查狀態  
                if (!cacheValid) {
                    data = ...
                    cacheValid = true;
                }
                // 釋放寫入鎖前,降級爲讀取鎖 降級是能夠的
                r.lock();
            } finally {
                w.unlock(); // 釋放寫入鎖
            }
        }
        // 此處仍然持有讀取鎖,要記得釋放讀取鎖
        try {
            use(data);
        } finally {
            r.unlock();
        }
    }
}

小結

讀寫鎖的讀取鎖和寫入鎖都實現了java.util.concurrent.locks.Lock接口,因此除了支持lock()方法外,tryLock()lockInterruptibly()等方法也都是支持的。可是須要注意,只有寫入鎖支持條件變量,讀取是不支持條件變量的,讀取鎖調用newCondition()會泡池UnsupporteOperationException異常。

咱們實現的簡單緩存是沒有解決緩存數據與源頭數據同步的,即保持與源頭數據的一致性。解決這個問題的一個簡單方案是超時機制:當緩存的數據超過期效後,這條數據在緩存中就失效了;訪問緩存中失效的數據,會觸發緩存從新從源頭把數據加載進緩存。也能夠在源頭數據發生變化時,快速反饋給緩存。

雖然說讀寫鎖在讀多寫少場景下性能優於互斥鎖(獨佔鎖),可是在其餘狀況下,性能可能要略差於互斥鎖,由於讀寫鎖的複雜性更高。因此,咱們要根據場景來具體考慮使用哪種同步方案。

參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016

相關文章
相關標籤/搜索