對于高頻讀/低頻寫的應(yīng)用場景,使用Lock或者使用synchronized來做同步顯然是不太合理的,那么有其他的方式來提高并發(fā)性能嗎?
在Java的并發(fā)包中有許多功能不同的類,今天我們介紹其中的一個,讀寫鎖ReadWriteLock。這種鎖在工作中應(yīng)用場景非常廣泛,普遍的使用場景是:對于讀多寫少的場景。經(jīng)常用到的例如存儲元數(shù)據(jù),緩存基礎(chǔ)數(shù)據(jù)等等,這些都是典型的讀多寫少的應(yīng)用場景。使用緩存可以極大提升應(yīng)用程序的處理能力。
讀寫鎖有下面這幾個特征:
兩個或多個線程可以同時進行讀操作
線程正在進行讀操作,另一個線程想要進行寫操作,另一個線程將會被阻塞直到所有讀操作結(jié)束
線程正在進行寫操作,另一個線程想要進行操作(讀或?qū)懀硪粋€線程將會阻塞直到寫入方完成操作
讀寫鎖與互斥鎖一個重要的區(qū)別就是讀寫鎖允許多個線程共享臨界段,而互斥鎖是不允許的。這是在讀多寫少場景下讀寫鎖性能優(yōu)于互斥鎖的原因。
但是讀寫鎖在讀寫操作時是互斥的,當(dāng)一個線程在進行寫操作時,其他的讀寫線程都是是處于阻塞狀態(tài)的
讀寫鎖實現(xiàn)緩存
下面我們會來實踐,用ReadWriteLock將非線程安全的Map包裝為一個簡單的緩存工具類
在下面的代碼中,我們聲明了一個 ICache<K, V> 類,其中類型參數(shù) K 代表緩存里 key 的類型,V 代表緩存里 value 的類型。緩存的數(shù)據(jù)保存在 ICache類內(nèi)部的 TreeMap里面,TreeMap不是線程安全的,這里我們使用讀寫鎖 ReadWriteLock 來保證其線程安全。ReadWriteLock 是一個接口,它的實現(xiàn)類是 ReentrantReadWriteLock,通過名字你應(yīng)該就能判斷出來,它是支持可重入的。下面我們通過 rwl 創(chuàng)建了一把讀鎖和一把寫鎖。
ICache這個工具類,我們提供了幾種簡單常用的方法,其中有讀緩存方法 get(),寫緩存方法 put()。讀緩存需要用到讀鎖,讀鎖的使用和前面我們介紹的 Lock 的使用是相同的,都是 try{}finally{}這個編程范式。寫緩存則需要用到寫鎖,寫鎖的使用和讀鎖是類似的。這樣看來,讀寫鎖的使用還是非常簡單的。
class ICache<K, V> {
private final Map<K, V> m = new TreeMap<>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public V get(K k) {
r.lock();
try {
return m.get(k);
} finally {
r.unlock();
}
}
public Object[] allKeys() {
r.lock();
try {
return m.keySet().toArray();
} finally {
r.unlock();
}
}
public V put(K key, V value) {
w.lock();
try {
return m.put(key, value);
} finally {
w.unlock();
}
}
public void clear() {
w.lock();
try {
m.clear();
} finally {
w.unlock();
}
}
}
如果你曾經(jīng)使用過緩存的話,你應(yīng)該知道使用緩存首先要解決緩存數(shù)據(jù)的初始化問題。緩存數(shù)據(jù)的初始化,可以采用一次性加載的方式,也可以使用按需加載的方式。
如果源頭數(shù)據(jù)的數(shù)據(jù)量不大,就可以采用一次性加載的方式,這種方式最簡單,只需在應(yīng)用啟動的時候把源頭數(shù)據(jù)查詢出來,依次調(diào)用類似上面示例代碼中的 put() 方法就可以了。
如果源頭數(shù)據(jù)量非常大,那么就需要按需加載了,按需加載也叫懶加載,指的是只有當(dāng)應(yīng)用查詢緩存,并且數(shù)據(jù)不在緩存里的時候,才觸發(fā)加載源頭相關(guān)數(shù)據(jù)進緩存的操作。下面你可以結(jié)合文中示意圖看看如何利用 ReadWriteLock 來實現(xiàn)緩存的按需加載。
緩存按需加載
文中下面的這段代碼實現(xiàn)了按需加載的功能,這里我們假設(shè)緩存的源頭是數(shù)據(jù)庫。需要注意的是,如果緩存中沒有緩存目標對象,那么就需要從數(shù)據(jù)庫中加載,然后寫入緩存,寫緩存需要用到寫鎖,所以在代碼注釋中的5處,我們調(diào)用了 w.lock() 來獲取寫鎖。
另外,還需要注意的是,在獲取寫鎖之后,我們并沒有直接去查詢數(shù)據(jù)庫,而是在代碼注釋6、7處,重新驗證了一次緩存中是否存在,再次驗證如果還是不存在,我們才去查詢數(shù)據(jù)庫并更新本地緩存。為什么我們要再次驗證呢?
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 cache(K key) {
V v = null;
// 讀緩存
r.lock(); //1
try {
v = m.get(key); //2
} finally {
r.unlock(); //3
}
// 緩存中存在,返回
if (v != null) { //4
return v;
}
// 緩存中不存在,查詢數(shù)據(jù)庫
w.lock(); //5
try {
// 再次驗證
// 其他線程可能已經(jīng)查詢過數(shù)據(jù)庫
v = m.get(key); //6
if (v == null) { //7
// 查詢數(shù)據(jù)庫
v = null;//省略代碼無數(shù)
m.put(key, v);
}
} finally {
w.unlock();
}
return v;
}
}
在5處寫緩存,需要寫鎖,在代碼6和7處,為什么要重新判斷是否存在?
原因是在高并發(fā)的場景下,有可能會有多線程競爭寫鎖。
假設(shè)緩存是空的,沒有緩存任何東西,如果此時有三個線程 T1、T2 和 T3 同時調(diào)用 get() 方法,并且參數(shù) key 也是相同的。那么它們會同時執(zhí)行到代碼注釋5處,但此時只有一個線程能夠獲得寫鎖,假設(shè)是線程 T1,線程 T1 獲取寫鎖之后查詢數(shù)據(jù)庫并更新緩存,最終釋放寫鎖。此時線程 T2 和 T3 會再有一個線程能夠獲取寫鎖,假設(shè)是 T2,如果不采用再次驗證的方式,此時 T2 會再次查詢數(shù)據(jù)庫。T2 釋放寫鎖之后,T3 也會再次查詢一次數(shù)據(jù)庫。而實際上線程 T1 已經(jīng)把緩存的值設(shè)置好了,T2、T3 完全沒有必要再次查詢數(shù)據(jù)庫。所以,再次驗證的方式,能夠避免高并發(fā)場景下重復(fù)查詢數(shù)據(jù)的問題。
讀寫鎖的升級與降級
上面按需加載的示例代碼中,在1處獲取讀鎖,在3處釋放讀鎖,那是否可以在2處的下面增加驗證緩存并更新緩存的邏輯呢?詳細的代碼如下。
// 讀緩存
r.lock(); //1
try {
v = m.get(key); //2
if (v == null) {
w.lock();
try {
// 再次驗證并更新緩存
// 省略詳細代碼
} finally{
w.unlock();
}
}
} finally{
r.unlock(); //3
}
先是獲取讀鎖,然后再升級為寫鎖,這叫鎖的升級。問題:讀鎖還沒有釋放,此時獲取寫鎖,會導(dǎo)致寫鎖永久等待,最終導(dǎo)致相關(guān)線程都被阻塞,永遠也沒有機會被喚醒。
不過,雖然鎖的升級是不允許的,但是鎖的降級卻是允許的。
以下代碼來源自 ReentrantReadWriteLock 的官方示例,略做了改動。你會發(fā)現(xiàn)在代碼注釋1處,獲取讀鎖的時候線程還是持有寫鎖的,這種鎖的降級是支持的。
class CachedData {
private Object data;
private volatile boolean cacheValid; // 緩存無效 true:有效 false 無效
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {// 在獲取寫鎖之前必須釋放讀鎖
rwl.readLock().unlock(); //釋放讀鎖
rwl.writeLock().lock(); //獲取寫鎖
try {
// 重新檢查狀態(tài),因為另一個線程可能已經(jīng)獲得寫鎖并在我們之前更改了狀態(tài)。
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 降級通過獲取讀鎖之前釋放寫鎖
rwl.readLock().lock(); // 1
} finally {
rwl.writeLock().unlock(); // 釋放寫鎖,仍然保持讀
}
}
try {
use(data); // 此處仍然持有讀鎖
} finally {
rwl.readLock().unlock();
}
}
private void use(Object data) {
System.out.println(data.toString());
}
}
總結(jié)
讀寫鎖類似于 ReentrantLock,也支持公平模式和非公平模式。讀鎖和寫鎖都實現(xiàn)了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。
但是有一點需要注意,那就是只有寫鎖支持條件變量,讀鎖是不支持條件變量的,讀鎖調(diào)用 newCondition() 會拋出 UnsupportedOperationException 異常。另外,官方文檔中還提到了,讀寫鎖支持最多65535個遞歸寫鎖和65535個讀鎖。如果超過這個限制會導(dǎo)致鎖定方法拋出錯誤。
如果文章的內(nèi)容對你有幫助,歡迎關(guān)注公眾號:優(yōu)享JAVA(ID:YouXiangJAVA),那里有更多的技術(shù)干貨,并精心準備了一份程序員書單。期待你的到來!