轉者注:作者圖文並茂的講解讀寫鎖,講的很清晰,也可以加深對AQS的理解。而併發相關的Semaphore CountDownLatch ReentrantLock都是基於AQS實現的。
概述
本文主要分析JCU包中讀寫鎖接口(ReadWriteLock
)的重要實現類ReentrantReadWriteLock
。主要實現讀共享,寫互斥功能,對比單純的互斥鎖在共享資源使用場景爲頻繁讀取及少量修改的情況下可以較好的提高性能。
ReadWriteLock接口簡單說明
ReadWriteLock接口只定義了兩個方法:
public interface ReadWriteLock {
/**
* Returns the lock used for reading.
*
* @return the lock used for reading
*/
Lock readLock();
/**
* Returns the lock used for writing.
*
* @return the lock used for writing
*/
Lock writeLock();
}
通過調用相應方法獲取讀鎖或寫鎖,獲取的讀鎖及寫鎖都是Lock
接口的實現,可以如同使用Lock
接口一樣使用(其實也有一些特性是不支持的)。
ReentrantReadWriteLock使用示例
讀寫鎖的使用並不複雜,可以參考以下使用示例:
class RWDictionary {
private final Map<String, Data> m = new TreeMap<String, Data>();
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
public Data get(String key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
public String[] allKeys() {
r.lock();
try { return m.keySet().toArray(); }
finally { r.unlock(); }
}
public Data put(String key, Data value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
public void clear() {
w.lock();
try { m.clear(); }
finally { w.unlock(); }
}
}
與普通重入鎖使用的主要區別在於需要使用不同的鎖對象引用讀寫鎖,並且在讀寫時分別調用對應的鎖。
ReentrantReadWriteLock鎖實現分析
本節通過學習源碼分析可重入讀寫鎖的實現。
圖解重要函數及對象關係
根據示例代碼可以發現,讀寫鎖需要關注的重點函數爲獲取讀鎖及寫鎖的函數,對於讀鎖及寫鎖對象則主要關注加鎖和解鎖函數,這幾個函數及對象關係如下圖:
從圖中可見讀寫鎖的加鎖解鎖操作最終都是調用ReentrantReadWriteLock
類的內部類Sync
提供的方法。與{% post_link 細談重入鎖ReentrantLock %}一文中描述相似,Sync
對象通過繼承AbstractQueuedSynchronizer
進行實現,故後續分析主要基於Sync
類進行。
讀寫鎖Sync
結構分析
Sync
繼承於AbstractQueuedSynchronizer
,其中主要功能均在AbstractQueuedSynchronizer
中完成,其中最重要功能爲控制線程獲取鎖失敗後轉換爲等待狀態及在滿足一定條件後喚醒等待狀態的線程。先對AbstractQueuedSynchronizer
進行觀察。
AbstractQueuedSynchronizer
圖解
爲了更好理解AbstractQueuedSynchronizer
的運行機制,可以首先研究其內部數據結構,如下圖:
圖中展示AQS類較爲重要的數據結構,包括int
類型變量state
用於記錄鎖的狀態,繼承自AbstractOwnableSynchronizer
類的Thread
類型變量exclusiveOwnerThread
用於指向當前排他的獲取鎖的線程,AbstractQueuedSynchronizer.Node
類型的變量head
及tail
。
其中Node
對象表示當前等待鎖的節點,Node
中thread
變量指向等待的線程,waitStatus
表示當前等待節點狀態,mode
爲節點類型。多個節點之間使用prev
及next
組成雙向鏈表,參考CLH鎖隊列的方式進行鎖的獲取,但其中與CLH隊列的重要區別在於CLH隊列中後續節點需要自旋輪詢前節點狀態以確定前置節點是否已經釋放鎖,期間不釋放CPU資源,而AQS
中Node
節點指向的線程在獲取鎖失敗後調用LockSupport.park
函數使其進入阻塞狀態,讓出CPU資源,故在前置節點釋放鎖時需要調用unparkSuccessor
函數喚醒後繼節點。
根據以上說明可得知此上圖圖主要表現當前thread0
線程獲取了鎖,thread1
線程正在等待。
讀寫鎖Sync
對於AQS
使用
讀寫鎖中Sync
類是繼承於AQS
,並且主要使用上文介紹的數據結構中的state
及waitStatus
變量進行實現。
實現讀寫鎖與實現普通互斥鎖的主要區別在於需要分別記錄讀鎖狀態及寫鎖狀態,並且等待隊列中需要區別處理兩種加鎖操作。 Sync
使用state
變量同時記錄讀鎖與寫鎖狀態,將int
類型的state
變量分爲高16位與第16位,高16位記錄讀鎖狀態,低16位記錄寫鎖狀態,如下圖所示:
Sync
使用不同的mode
描述等待隊列中的節點以區分讀鎖等待節點和寫鎖等待節點。mode
取值包括SHARED
及EXCLUSIVE
兩種,分別代表當前等待節點爲讀鎖和寫鎖。
讀寫鎖Sync
代碼過程分析
寫鎖加鎖
通過對於重要函數關係的分析,寫鎖加鎖最終調用Sync
類的acquire
函數(繼承自AQS
)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
現在分情況圖解分析
無鎖狀態
無鎖狀態AQS
內部數據結構如下圖所示:
其中state
變量爲0,表示高位地位地位均爲0,沒有任何鎖,且等待節點的首尾均指向空(此處特指head節點沒有初始化時),鎖的所有者線程也爲空。
在無鎖狀態進行加鎖操作,線程調用acquire
函數,首先使用tryAcquire
函數判斷鎖是否可獲取成功,由於當前是無鎖狀態必然成功獲取鎖(如果多個線程同時進入此函數,則有且只有一個線程可調用compareAndSetState
成功,其他線程轉入獲取鎖失敗的流程)。獲取鎖成功後AQS
狀態爲:
有鎖狀態
在加寫鎖時如果當前AQS
已經是有鎖狀態,則需要進一步處理。有鎖狀態主要分爲已有寫鎖和已有讀鎖狀態,並且根據最終當前線程是否可直接獲取鎖分爲兩種情況:
- 非重入:如果滿足一下兩個條件之一,當前線程必須加入等待隊列(暫不考慮非公平鎖搶佔情況)
a. 已有讀鎖;
b. 有寫鎖且獲取寫鎖的線程不爲當前請求鎖的線程。 - 重入:有寫鎖且當前獲取寫鎖的線程與當前請求鎖的線程爲同一線程,則直接獲取鎖並將寫鎖狀態值加1。
寫鎖重入狀態如圖:
寫鎖非重入等待狀態如圖:
在非重入狀態,當前線程創建等待節點追加到等待隊列隊尾,如果當前頭結點爲空,則需要創建一個默認的頭結點。
之後再當前獲取鎖的線程釋放鎖後,會喚醒等待中的節點,即爲thread1
。如果當前等待隊列存在多個等待節點,由於thread1
等待節點爲EXCLUSIVE
模式,則只會喚醒當前一個節點,不會傳播喚醒信號。
讀鎖加鎖
通過對於重要函數關係的分析,寫鎖加鎖最終調用Sync
類的acquireShared
函數(繼承自AQS
):
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
同上文,現在分情況圖解分析
無鎖狀態
無所狀態AQS
內部數據狀態圖與寫加鎖是無鎖狀態一致:
在無鎖狀態進行加鎖操作,線程調用acquireShared
函數,首先使用tryAcquireShared
函數判斷共享鎖是否可獲取成功,由於當前爲無鎖狀態則獲取鎖一定成功(如果同時多個線程在讀鎖進行競爭,則只有一個線程能夠直接獲取讀鎖,其他線程需要進入fullTryAcquireShared
函數繼續進行鎖的獲取,該函數在後文說明)。當前線程獲取讀鎖成功後,AQS
內部結構如圖所示:
其中有兩個新的變量:firstReader
及firstReaderHoldCount
。firstReader
指向在無鎖狀態下第一個獲取讀鎖的線程,firstReaderHoldCount
記錄第一個獲取讀鎖的線程持有當前鎖的計數(主要用於重入)。
有鎖狀態
無鎖狀態獲取讀鎖比較簡單,在有鎖狀態則需要分情況討論。其中需要分當前被持有的鎖是讀鎖還是寫鎖,並且每種情況需要區分等待隊列中是否有等待節點。
已有讀鎖且等待隊列爲空
此狀態比較簡單,圖示如:此時線程申請讀鎖,首先調用readerShouldBlock
函數進行判斷,該函數根據當前鎖是否爲公平鎖判斷規則稍有不同。如果爲非公平鎖,則只需要當前第一個等待節點不是寫鎖就可以嘗試獲取鎖(考慮第一點爲寫鎖主要爲了方式寫鎖“餓死”);如果是公平鎖則只要有等待節點且當前鎖不爲重入就需要等待。
由於本節的前提是等待隊列爲空的情況,故readerShouldBlock
函數一定返回false
,則當前線程使用CAS
對讀鎖計數進行增加(同上文,如果同時多個線程在讀鎖進行競爭,則只有一個線程能夠直接獲取讀鎖,其他線程需要進入fullTryAcquireShared
函數繼續進行鎖的獲取)。
在成功對讀鎖計數器進行增加後,當前線程需要繼續對當前線程持有讀鎖的計數進行增加。此時分爲兩種情況:
- 當前線程是第一個獲取讀鎖的線程,此時由於第一個獲取讀鎖的線程已經通過
firstReader
及firstReaderHoldCount
兩個變量進行存儲,則僅僅需要將firstReaderHoldCount
加1即可; - 當前線程不是第一個獲取讀鎖的線程,則需要使用
readHolds
進行存儲,readHolds
是ThreadLocal
的子類,通過readHolds
可獲取當前線程對應的HoldCounter
類的對象,該對象保存了當前線程獲取讀鎖的計數。考慮程序的局部性原理,又使用cachedHoldCounter
緩存最近使用的HoldCounter
類的對象,如在一段時間內只有一個線程請求讀鎖則可加速對讀鎖獲取的計數。
第一個讀鎖線程重入如圖:
非首節點獲取讀鎖
根據上圖所示,thread0
爲首節點,thread1
線程繼續申請讀鎖,獲取成功後使用ThreadLocal
鏈接的方式進行存儲計數對象,並且由於其爲最近獲取讀鎖的線程,則cachedHoldCounter
對象設置指向thread1
對應的計數對象。
已有讀鎖且等待隊列不爲空
在當前鎖已經被讀鎖獲取,且等待隊列不爲空的情況下 ,可知等待隊列的頭結點一定爲寫鎖獲取等待,這是由於在讀寫鎖實現過程中,如果某線程獲取了讀鎖,則會喚醒當前等到節點之後的所有等待模式爲SHARED
的節點,直到隊尾或遇到EXCLUSIVE
模式的等待節點(具體實現函數爲setHeadAndPropagate
後續還會遇到)。所以可以確定當前爲讀鎖狀態其有等待節點情況下,首節點一定是寫鎖等待。如圖所示:
上圖展示當前thread0
與thread1
線程獲取讀鎖,thread0
爲首個獲取讀鎖的節點,並且thread2
線程在等待獲取寫鎖。
在上圖顯示的狀態下,無論公平鎖還是非公平鎖的實現,新的讀鎖加鎖一定會進行排隊,添加等待節點在寫鎖等待節點之後,這樣可以防止寫操作的餓死。申請讀鎖後的狀態如圖所示:
如圖所示,在當前鎖被爲讀鎖且有等待隊列情況下,thread3
及thread4
線程申請讀鎖,則被封裝爲等待節點追加到當前等待隊列後,節點模式爲SHARED
,線程使用LockSupport.park
函數進入阻塞狀態,讓出CPU資源,直到前驅的等待節點完成鎖的獲取和釋放後進行喚醒。
已有寫鎖被獲取
當前線程申請讀鎖時發現寫鎖已經被獲取,則無論等待隊列是否爲空,線程一定會需要加入等待隊列(注意在非公平鎖實現且前序沒有寫鎖申請的等待,線程有機會搶佔獲取鎖而不進入等待隊列)。寫鎖被獲取的情況下,AQS
狀態爲如下狀態
在兩種情況下,讀鎖獲取都會進入等待隊列等待前序節點喚醒,這裏不再贅述。
讀等待節點被喚醒
讀寫鎖與單純的排他鎖主要區別在於讀鎖的共享性,在讀寫鎖實現中保證讀鎖能夠共享的其中一個機制就在於,如果一個讀鎖等待節點被喚醒後其會繼續喚醒拍在當前喚醒節點之後的SHARED
模式等待節點。查看源碼:
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED);
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg);
if (r >= 0) {
//注意看這裏
setHeadAndPropagate(node, r);
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
在for
循環中,線程如果獲取讀鎖成功後,需要調用setHeadAndPropagate
方法。查看其源碼:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
在滿足傳播條件情況下,獲取讀鎖後繼續喚醒後續節點,所以如果當前鎖是讀鎖狀態則等待節點第一個節點一定是寫鎖等待節點。
鎖降級
鎖降級算是獲取讀鎖的特例,如在t0
線程已經獲取寫鎖的情況下,再調取讀鎖加鎖函數則可以直接獲取讀鎖,但此時其他線程仍然無法獲取讀鎖或寫鎖,在t0
線程釋放寫鎖後,如果有節點等待則會喚醒後續節點,後續節點可見的狀態爲目前有t0
線程獲取了讀鎖。
所降級有什麼應用場景呢?引用讀寫鎖中使用示例代碼
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();
}
}
}
其中針對變量cacheValid
的使用主要過程爲加讀鎖、讀取、釋放讀鎖、加寫鎖、修改值、加讀鎖、釋放寫鎖、使用數據、釋放讀鎖。其中後續幾步(加寫鎖、修改值、加讀鎖、釋放寫鎖、使用數據、釋放讀鎖)爲典型的鎖降級。如果不使用鎖降級,則過程可能有三種情況:
- 第一種:加寫鎖、修改值、釋放寫鎖、使用數據,即使用寫鎖修改數據後直接使用剛修改的數據,這樣可能有數據的不一致,如當前線程釋放寫鎖的同時其他線程(如
t0
)獲取寫鎖準備修改(還沒有改)cacheValid
變量,而當前線程卻繼續運行,則當前線程讀到的cacheValid
變量的值爲t0
修改前的老數據; - 第二種:加寫鎖、修改值、使用數據、釋放寫鎖,即將修改數據與再次使用數據合二爲一,這樣不會有數據的不一致,但是由於混用了讀寫兩個過程,以排它鎖的方式使用讀寫鎖,減弱了讀寫鎖讀共享的優勢,增加了寫鎖(獨佔鎖)的佔用時間;
- 第三種:加寫鎖、修改值、釋放寫鎖、加讀鎖、使用數據、釋放讀鎖,即使用寫鎖修改數據後再請求讀鎖來使用數據,這是時數據的一致性是可以得到保證的,但是由於釋放寫鎖和獲取讀鎖之間存在時間差,則當前想成可能會需要進入等待隊列進行等待,可能造成線程的阻塞降低吞吐量。
因此針對以上情況提供了鎖的降級功能,可以在完成數據修改後儘快讀取最新的值,且能夠減少寫鎖佔用時間。
最後注意,讀寫鎖不支持鎖升級,即獲取讀鎖、讀數據、獲取寫鎖、釋放讀鎖、釋放寫鎖這個過程,因爲讀鎖爲共享鎖,如同時有多個線程獲取了讀鎖後有一個線程進行鎖升級獲取了寫鎖,這會造成同時有讀鎖(其他線程)和寫鎖的情況,造成其他線程可能無法感知新修改的數據(此爲邏輯性錯誤),並且在JAVA讀寫鎖實現上由於當前線程獲取了讀鎖,再次請求寫鎖時必然會阻塞而導致後續釋放讀鎖的方法無法執行,這回造成死鎖(此爲功能性錯誤)。
寫鎖釋放鎖過程
瞭解了加鎖過程後解鎖過程就非常簡單,每次調用解鎖方法都會減少重入計數次數,直到減爲0則喚醒後續第一個等待節點,如喚醒的後續節點爲讀等待節點,則後續節點會繼續傳播喚醒狀態。
讀鎖釋放過程
讀鎖釋放過比寫鎖稍微複雜,因爲是共享鎖,所以可能會有多個線程同時獲取讀鎖,故在解鎖時需要做兩件事:
- 獲取當前線程對應的重入計數,並進行減1,此處天生爲線程安全的,不需要特殊處理;
- 當前讀鎖獲取次數減1,此處由於可能存在多線程競爭,故使用自旋
CAS
進行設置。
完成以上兩步後,如讀狀態爲0,則喚醒後續等待節點。
總結
根據以上分析,本文主要展示了讀寫鎖的場景及方式,並分析讀寫鎖核心功能(加解鎖)的代碼實現。Java讀寫鎖同時附帶了更多其他方法,包括鎖狀態監控和帶超時機制的加鎖方法等,本文不在贅述。並且讀寫鎖中寫鎖可使用Conditon
機制也不在詳細說明。