前言
前面介紹過ReentrantLock
,它實現的是一種標准的互斥鎖:每次最多只有一個線程能持有ReentrantLock。這是一種強硬的加鎖規則,在某些場景下會限制並發性導致不必要的抑制性能。互斥是一種保守的加鎖策略,雖然可以避免“寫/寫”沖突和“寫/讀”沖突,但是同樣也避免了“讀/讀”沖突。
在讀多寫少的情況下,如果能夠放寬加鎖需求,允許多個執行讀操作的線程同時訪問數據結構,那么將提升程序的性能。只要每個線程都能確保讀到最新的數據,並且在讀取數據時不會有其他的線程修改數據,那么就不會發生問題。在這種情況下,就可以使用讀寫鎖:一個資源可以被多個讀操作訪問,或者被一個寫操作訪問,但兩者不能同時進行。
Java中讀寫鎖的實現是ReadWriteLock
。下面我們先介紹什么是讀寫鎖,然后利用讀寫鎖快速實現一個緩存,最后我們再來介紹讀寫鎖的升級與降級。
什么是讀寫鎖
讀寫鎖是一種性能優化措施,在讀多寫少場景下,能實現更高的並發性。讀寫鎖的實現需要遵循以下三項基本原則:
- 允許多個線程同時讀共享變量;
- 只允許一個線程寫共享變量;
- 如果一個線程正在執行寫操作,此時禁止讀線程讀共享便利。
讀寫鎖與互斥鎖的一個重要區別就是:讀寫鎖允許多個線程同時讀共享變量,而互斥鎖是不允許的。讀寫鎖的寫操作時互斥的。
下面是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].北京:機械工業出版社,2016