1. 程式人生 > >【Java併發工具類】ReadWriteLock

【Java併發工具類】ReadWriteLock

前言

前面介紹過ReentrantLock,它實現的是一種標準的互斥鎖:每次最多隻有一個執行緒能持有ReentrantLock。這是一種強硬的加鎖規則,在某些場景下會限制併發性導致不必要的抑制效能。互斥是一種保守的加鎖策略,雖然可以避免“寫/寫”衝突和“寫/讀”衝突,但是同樣也避免了“讀/讀”衝突。

在讀多寫少的情況下,如果能夠放寬加鎖需求,允許多個執行讀操作的執行緒同時訪問資料結構,那麼將提升程式的效能。只要每個執行緒都能確保讀到最新的資料,並且在讀取資料時不會有其他的執行緒修改資料,那麼就不會發生問題。在這種情況下,就可以使用讀寫鎖:一個資源可以被多個讀操作訪問,或者被一個寫操作訪問,但兩者不能同時進行。

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])

如果源頭資料量非常大,那麼就需要按需載入,按需載入也叫做懶載入。指的是隻有當應用查詢快取,並且資料不在快取裡的時候,才觸發載入源頭相關資料進行快取的操作。可參考下圖(圖來自參考[1])

實現快取的按需載入

下面程式碼實現了按需載入的功能(程式碼來自參考[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; 
    }
}

當快取中不存在目標物件時,需要查詢資料庫,在上述程式碼中,我們在執行真正的查庫之前,又查看了快取中是否已經存在目標物件,這樣做的好處是可以避免重複查詢提升效率。我們舉例說明這樣做的益處。

在高併發的場景下,有可能會有多執行緒競爭寫鎖。假設快取是空的,沒有快取任何東西,如果此時有三個執行緒 T1、T2 和 T3 同時呼叫get()方法,並且引數 key也是相同的。那麼它們會同時執行到程式碼①處,但此時只有一個執行緒能夠獲得寫鎖。
假設是執行緒 T1,執行緒 T1 獲取寫鎖之後查詢資料庫並更新快取,最終釋放寫鎖。
此時執行緒 T2 和 T3 會再有一個執行緒能夠獲取寫鎖,假設是 T2,如果不採用再次驗證的方式,此時 T2 會再次查詢資料庫。T2 釋放寫鎖之後,T3 也會再次查詢一次資料庫。
而實際上執行緒 T1 已經把快取的值設定好了,T2、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].北京:機械工業出版社,2