[Java併發-10] ReadWriteLock:快速實現一個完備的緩存

你們知道了Java中使用管程同步原語,理論上能夠解決全部的併發問題。那 Java SDK 併發包裏爲何還有不少其餘的工具類呢?緣由很簡單:分場景優化性能,提高易用性java

今天咱們就介紹一種很是廣泛的併發場景:讀多寫少場景。實際工做中,爲了優化性能,咱們常常會使用緩存,例如緩存元數據、緩存基礎數據等,這就是一種典型的讀多寫少應用場景。緩存之因此能提高性能,一個重要的條件就是緩存的數據必定是讀多寫少的.數據庫

針對讀多寫少這種併發場景,Java SDK 併發包提供了讀寫鎖——ReadWriteLock,很是容易使用,而且性能很好。編程

什麼是讀寫鎖

讀寫鎖,並非 Java 語言特有的,而是一個廣爲使用的通用技術,全部的讀寫鎖都遵照如下三條基本原則:緩存

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

讀寫鎖與互斥鎖的一個重要區別就是讀寫鎖容許多個線程同時讀共享變量,而互斥鎖是不容許的,這是讀寫鎖在讀多寫少場景下性能優於互斥鎖的關鍵。但讀寫鎖的寫操做是互斥的,當一個線程在寫共享變量的時候,是不容許其餘線程執行讀操做和寫操做的。安全

快速實現一個緩存

在下面的代碼中,咱們聲明瞭一個 Cache<K, V> 類,其中類型參數 K 表明緩存裏 key 的類型,V 表明緩存裏 value 的類型。緩存的數據保存在 Cache 類內部的 HashMap 裏面,HashMap 不是線程安全的,這裏咱們使用讀寫鎖 ReadWriteLock 來保證其線程安全。ReadWriteLock 是一個接口,它的實現類是 ReentrantReadWriteLock,經過名字你應該就能判斷出來,它是支持可重入的。下面咱們經過 rwl 建立了一把讀鎖和一把寫鎖。多線程

Cache 這個工具類,咱們提供了兩個方法,一個是讀緩存方法 get(),另外一個是寫緩存方法 put()。讀緩存須要用到讀鎖,讀鎖的使用和前面咱們介紹的 Lock 的使用是相同的,都是 try{}finally{}這個編程範式。寫緩存則須要用到寫鎖,寫鎖的使用和讀鎖是相似的。併發

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(String key, Data v) {
    w.lock();
    try { return m.put(key, v); }
    finally { w.unlock(); }
  }
}

實現緩存的按需加載

設計封裝緩存類時,咱們須要在當應用查詢緩存,而且數據不在緩存裏的時候,觸發加載源頭相關數據進緩存的操做,這也是咱們須要實現的最基本的功能。下面看下利用 ReadWriteLock 來實現緩存的按需加載。高併發

這裏咱們假設緩存的源頭是數據庫。須要注意的是,若是緩存中沒有緩存目標對象,那麼就須要從數據庫中加載,而後寫入緩存,寫緩存須要用到寫鎖,因此在代碼中的⑤處,咱們調用了w.lock() 來獲取寫鎖。工具

另外,還須要注意的是,在獲取寫鎖以後,咱們並無直接去查詢數據庫,而是在代碼⑥⑦處,從新驗證了一次緩存中是否存在,再次驗證若是仍是不存在,咱們纔去查詢數據庫並更新本地緩存。爲何咱們要再次驗證呢?性能

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 並不支持這種升級。在上面的代碼示例中,讀鎖尚未釋放,此時獲取寫鎖,會致使寫鎖永久等待,最終致使相關線程都被阻塞,永遠也沒有機會被喚醒。

小結

讀寫鎖相似於 ReentrantLock,也支持公平模式和非公平模式。讀鎖和寫鎖都實現了 java.util.concurrent.locks.Lock 接口,因此除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。可是有一點須要注意,那就是隻有寫鎖支持條件變量,讀鎖是不支持條件變量的,讀鎖調用 newCondition() 會拋出 UnsupportedOperationException 異常。

相關文章
相關標籤/搜索