前面介紹過ReentrantLock
,它實現的是一種標準的互斥鎖:每次最多隻有一個線程能持有ReentrantLock。這是一種強硬的加鎖規則,在某些場景下會限制併發性致使沒必要要的抑制性能。互斥是一種保守的加鎖策略,雖然能夠避免「寫/寫」衝突和「寫/讀」衝突,可是一樣也避免了「讀/讀」衝突。java
在讀多寫少的狀況下,若是可以放寬加鎖需求,容許多個執行讀操做的線程同時訪問數據結構,那麼將提高程序的性能。只要每一個線程都能確保讀到最新的數據,而且在讀取數據時不會有其餘的線程修改數據,那麼就不會發生問題。在這種狀況下,就可使用讀寫鎖:一個資源能夠被多個讀操做訪問,或者被一個寫操做訪問,但二者不能同時進行。數據庫
Java中讀寫鎖的實現是ReadWriteLock
。下面咱們先介紹什麼是讀寫鎖,而後利用讀寫鎖快速實現一個緩存,最後咱們再來介紹讀寫鎖的升級與降級。編程
讀寫鎖是一種性能優化措施,在讀多寫少場景下,能實現更高的併發性。讀寫鎖的實現須要遵循如下三項基本原則:緩存
讀寫鎖與互斥鎖的一個重要區別就是:讀寫鎖容許多個線程同時讀共享變量,而互斥鎖是不容許的。讀寫鎖的寫操做時互斥的。安全
下面是ReadWriteLock
接口:性能優化
public interface ReadWriteLock{ Lock readLock(); Lock writeLock(); }
其中,暴露了兩個Lock對象,一個用於讀操做,一個用於寫操做。要讀取由ReadWriteLock保護的數據,必須首先得到讀取鎖,當須要修改由ReadWriteLock保護的數據時,必須首先得到寫入鎖。儘管這兩個鎖看上去是彼此獨立的,但讀取鎖和寫入鎖只是讀寫鎖對象的不一樣視圖。數據結構
與Lock同樣,ReadWriteLock能夠採用多種不一樣的實現方式,這些方式在性能、調度保證、獲取優先性、公平性以及加鎖語義等方面可能有些不一樣。讀取鎖與寫入鎖之間的交互方式也能夠採用多種方式實現。多線程
ReadWriteLock中有一些可選實現包括:併發
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; } }
當緩存中不存在目標對象時,須要查詢數據庫,在上述代碼中,咱們在執行真正的查庫以前,又查看了緩存中是否已經存在目標對象,這樣作的好處是能夠避免重複查詢提高效率。咱們舉例說明這樣作的益處。
在高併發的場景下,有可能會有多線程競爭寫鎖。假設緩存是空的,沒有緩存任何東西,若是此時有三個線程 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