摘要:在并發場景中,Java SDK中提供了ReadWriteLock來滿足讀多寫少的場景,
本文分享自華為云社區《【高并發】基于ReadWriteLock開了個一款高性能快取》,作者:冰 河,
寫在前面
在實際作業中,有一種非常普遍的并發場景:那就是讀多寫少的場景,在這種場景下,為了優化程式的性能,我們經常使用快取來提高應用的訪問性能,因為快取非常適合使用在讀多寫少的場景中,而在并發場景中,Java SDK中提供了ReadWriteLock來滿足讀多寫少的場景,本文我們就來說說使用ReadWriteLock如何實作一個通用的快取中心,
本文涉及的知識點有:
讀寫鎖
說起讀寫鎖,相信小伙伴們并不陌生,總體來說,讀寫鎖需要遵循以下原則:
- 一個共享變數允許同時被多個讀執行緒讀取到,
- 一個共享變數在同一時刻只能被一個寫執行緒進行寫操作,
- 一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作,
這里,需要小伙伴們注意的是:讀寫鎖和互斥鎖的一個重要的區別就是:讀寫鎖允許多個執行緒同時讀共享變數,而互斥鎖不允許,所以,在高并發場景下,讀寫鎖的性能要高于互斥鎖,但是,讀寫鎖的寫操作是互斥的,也就是說,使用讀寫鎖時,一個共享變數在被寫執行緒執行寫操作時,此時這個共享變數不能被讀執行緒執行讀操作,
讀寫鎖支持公平模式和非公平模式,具體是在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(); } } }}
資料同步問題
首先,這里說的資料同步指的是資料源和資料快取之間的資料同步,說的再直接一點,就是資料庫和快取之間的資料同步,
這里,我們可以采取三種方案來解決資料同步的問題,如下圖所示
超時機制
這個比較好理解,就是在向快取寫入資料的時候,給一個超時時間,當快取超時后,快取的資料會自動從快取中移除,此時程式再次訪問快取時,由于快取中不存在相應的資料,查詢資料庫得到資料后,再將資料寫入快取,
定時更新快取
這種方案是超時機制的增強版,在向快取中寫入資料的時候,同樣給一個超時時間,與超時機制不同的是,在程式后臺單獨啟動一個執行緒,定時查詢資料庫中的資料,然后將資料寫入快取中,這樣能夠在一定程度上避免快取的穿透問題,
點擊關注,第一時間了解華為云新鮮技術~
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/553818.html
標籤:其他
上一篇:【爬蟲+資料清洗+可視化】用Python分析“淄博燒烤“的評論資料
下一篇:返回列表
