讀寫鎖
現實中有這樣一種場景:對共享資源有讀和寫的操作,且寫操作沒有讀操作那麼頻繁(讀多寫少)。在沒有寫操作的時候,多個線程同時讀一個資源沒有任何問題,所以應該允許多個線程同時讀取共享資源(讀讀可以併發);但是如果一個線程想去寫這些共享資源,就不應該允許其他線程對該資源進行讀和寫操作了(讀寫,寫讀,寫寫互斥)。在讀多於寫的情況下,讀寫鎖能夠提供比排它鎖更好的併發性和吞吐量。
針對這種場景,JAVA的併發包提供了讀寫鎖ReentrantReadWriteLock,它內部,維護了一對相關的鎖,一個用於只讀操作,稱爲讀鎖;一個用於寫入操作,稱爲寫鎖,描述如下:
線程進入讀鎖的前提條件:
- 沒有其他線程的寫鎖
- 沒有寫請求或者有寫請求,但調用線程和持有鎖的線程是同一個。
線程進入寫鎖的前提條件:
- 沒有其他線程的讀鎖
- 沒有其他線程的寫鎖
而讀寫鎖有以下三個重要的特性:
- 公平選擇性:支持非公平(默認)和公平的鎖獲取方式,吞吐量還是非公平優於公平。
- 可重入:讀鎖和寫鎖都支持線程重入。以讀寫線程爲例:讀線程獲取讀鎖後,能夠再次獲取讀鎖。寫線程在獲取寫鎖之後能夠再次獲取寫鎖,同時也可以獲取讀鎖。
- 鎖降級:遵循獲取寫鎖、再獲取讀鎖最後釋放寫鎖的次序,寫鎖能夠降級成爲讀鎖。
ReentrantReadWriteLock
讀寫鎖接口ReadWriteLock
一對方法,分別獲得讀鎖和寫鎖 Lock 對象。
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();
}
ReentrantReadWriteLock類結構
ReentrantReadWriteLock是可重入的讀寫鎖實現類。在它內部,維護了一對相關的鎖,一個用於只讀操作,另一個用於寫入操作。只要沒有 Writer 線程,讀鎖可以由多個 Reader 線程同時持有。也就是說,寫鎖是獨佔的,讀鎖是共享的。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
private static final long serialVersionUID = -6992448646407690164L;
private final ReentrantReadWriteLock.ReadLock readerLock;
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock readLock() { return readerLock; }
如何使用讀寫鎖
private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
private Lock r = readWriteLock.readLock();
private Lock w = readWriteLock.writeLock();
// 讀操作上讀鎖
public Data get(String key) {
r.lock();
try {
// TODO 業務邏輯
}finally {
r.unlock();
}
}
// 寫操作上寫鎖
public Data put(String key, Data value) {
w.lock();
try {
// TODO 業務邏輯
}finally {
w.unlock();
}
}
注意事項
- 讀鎖不支持條件變量
- 重入時升級不支持:持有讀鎖的情況下去獲取寫鎖,會導致獲取永久等待
- 重入時支持降級: 持有寫鎖的情況下可以去獲取讀鎖
應用場景
ReentrantReadWriteLock適合讀多寫少的場景
public class Cache {
static Map<String, Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
// 獲取一個key對應的value
public static final Object get(String key) {
r.lock();
try {
return map.get(key);
} finally {
r.unlock();
}
}
// 設置key對應的value,並返回舊的value
public static final Object put(String key, Object value) {
w.lock();
try {
return map.put(key, value);
} finally {
w.unlock();
}
}
// 清空所有的內容
public static final void clear() {
w.lock();
try {
map.clear();
} finally {
w.unlock();
}
}
}
上述示例中,Cache組合一個非線程安全的HashMap作爲緩存的實現,同時使用讀寫鎖的讀鎖和寫鎖來保證Cache是線程安全的。在讀操作get(String key)方法中,需要獲取讀鎖,這使得併發訪問該方法時不會被阻塞。寫操作put(String key,Object value)方法和clear()方法,在更新 HashMap時必須提前獲取寫鎖,當獲取寫鎖後,其他線程對於讀鎖和寫鎖的獲取均被阻塞,而 只有寫鎖被釋放之後,其他讀寫操作才能繼續。Cache使用讀寫鎖提升讀操作的併發性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了編程方式
鎖降級
鎖降級指的是寫鎖降級成爲讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程。鎖降級可以幫助我們拿到當前線程修改後的結果而不被其他線程所破壞,防止更新丟失。
鎖降級的使用示例
因爲數據不常變化,所以多個線程可以併發地進行數據處理,當數據變更後,如果當前線程感知到數據變化,則進行數據的準備工作,同時其他處理線程被阻塞,直到當前線程完成數據的準備工作。
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private final Lock w = rwl.writeLock();
private volatile boolean update = false;
public void processData() {
readLock.lock();
if (!update) {
// 必須先釋放讀鎖
readLock.unlock();
// 鎖降級從寫鎖獲取到開始
writeLock.lock();
try {
if (!update) {
// TODO 準備數據的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 鎖降級完成,寫鎖降級爲讀鎖
}
try {
//TODO 使用數據的流程(略)
} finally {
readLock.unlock();
}
}
鎖降級中讀鎖的獲取是否必要呢?答案是必要的。主要是爲了保證數據的可見性,如果當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程(記作線程T)獲取了寫鎖並修改了數據,那麼當前線程無法感知線程T的數據更新。如果當前線程獲取讀鎖,即遵循鎖降級的步驟,則線程T將會被阻塞,直到當前線程使用數據並釋放讀鎖之後,線程T才能獲取寫鎖進行數據更新。
RentrantReadWriteLock不支持鎖升級(把持讀鎖、獲取寫鎖,最後釋放讀鎖的過程)。目的也是保證數據可見性,如果讀鎖已被多個線程獲取,其中任意線程成功獲取了寫鎖並更新了數據,則其更新對其他獲取到讀鎖的線程是不可見的。
ReentrantReadWriteLock源碼分析
思考:
-
讀寫鎖是怎樣實現分別記錄讀寫狀態的?
-
寫鎖是怎樣獲取和釋放的?
-
讀鎖是怎樣獲取和釋放的?
ReentrantReadWriteLock結構
讀寫狀態的設計
用一個變量如何維護多種狀態
在 ReentrantLock 中,使用 Sync ( 實際是 AQS )的 int 類型的 state 來表示同步狀態,表示鎖被一個線程重複獲取的次數。但是,讀寫鎖 ReentrantReadWriteLock 內部維護着一對讀寫鎖,如果要用一個變量維護多種狀態,需要採用“按位切割使用”的方式來維護這個變量,將其切分爲兩部分:高16爲表示讀,低16爲表示寫。
分割之後,讀寫鎖是如何迅速確定讀鎖和寫鎖的狀態呢?通過位運算。假如當前同步狀態爲S,那麼:
- 寫狀態,等於 S & 0x0000FFFF(將高 16 位全部抹去)。 當寫狀態加1,等於S+1.
- 讀狀態,等於 S >>> 16 (無符號補 0 右移 16 位)。當讀狀態加1,等於S+(1<<16),也就是S+0x00010000
根據狀態的劃分能得出一個推論:S不等於0時,當寫狀態(S&0x0000FFFF)等於0時,則讀狀態(S>>>16)大於0,即讀鎖已被獲取。
代碼實現:java.util.concurrent.locks.ReentrantReadWriteLock.Sync
- exclusiveCount(int c) 靜態方法,獲得持有寫狀態的鎖的次數。
- sharedCount(int c) 靜態方法,獲得持有讀狀態的鎖的線程數量。不同於寫鎖,讀鎖可以同時被多個線程持有。而每個線程持有的讀鎖支持重入的特性,所以需要對每個線程持有的讀鎖的數量單獨計數,這就需要用到 HoldCounter 計數器
HoldCounter 計數器
讀鎖的內在機制其實就是一個共享鎖。一次共享鎖的操作就相當於對HoldCounter 計數器的操作。獲取共享鎖,則該計數器 + 1,釋放共享鎖,該計數器 - 1。只有當線程獲取共享鎖後才能對共享鎖進行釋放、重入操作。
通過 ThreadLocalHoldCounter 類,HoldCounter 與線程進行綁定。HoldCounter 是綁定線程的一個計數器,而 ThreadLocalHoldCounter 則是線程綁定的 ThreadLocal。
- HoldCounter是用來記錄讀鎖重入數的對象
- ThreadLocalHoldCounter是ThreadLocal變量,用來存放不是第一個獲取讀鎖的線程的其他線程的讀鎖重入數對象
寫鎖的獲取
寫鎖是一個支持重進入的排它鎖。如果當前線程已經獲取了寫鎖,則增加寫狀態。如果當前線程在獲取寫鎖時,讀鎖已經被獲取(讀狀態不爲0)或者該線程不是已經獲取寫鎖的線程, 則當前線程進入等待狀態。
寫鎖的獲取是通過重寫AQS中的tryAcquire方法實現的。
protected final boolean tryAcquire(int acquires) {
//當前線程
Thread current = Thread.currentThread();
//獲取state狀態 存在讀鎖或者寫鎖,狀態就不爲0
int c = getState();
//獲取寫鎖的重入數
int w = exclusiveCount(c);
//當前同步狀態state != 0,說明已經有其他線程獲取了讀鎖或寫鎖
if (c != 0) {
// c!=0 && w==0 表示存在讀鎖
// 當前存在讀鎖或者寫鎖已經被其他寫線程獲取,則寫鎖獲取失敗
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 超出最大範圍 65535
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
//同步state狀態
setState(c + acquires);
return true;
}
// writerShouldBlock有公平與非公平的實現, 非公平返回false,會嘗試通過cas加鎖
//c==0 寫鎖未被任何線程獲取,當前線程是否阻塞或者cas嘗試獲取鎖
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
//設置寫鎖爲當前線程所有
setExclusiveOwnerThread(current);
return true;
}
通過源碼我們可以知道:
- 讀寫互斥
- 寫寫互斥
- 寫鎖支持同一個線程重入
- writerShouldBlock寫鎖是否阻塞實現取決公平與非公平的策略(FairSync和NonfairSync)
思考:有線程讀的過程中不允許寫,這種設計有什麼問題?
寫鎖的釋放
寫鎖釋放通過重寫AQS的tryRelease方法實現
protected final boolean tryRelease(int releases) {
//若鎖的持有者不是當前線程,拋出異常
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
int nextc = getState() - releases;
//當前寫狀態是否爲0,爲0則釋放寫鎖
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}
讀鎖的獲取
實現共享式同步組件的同步語義需要通過重寫AQS的tryAcquireShared方法和tryReleaseShared方法。讀鎖的獲取實現方法爲:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 如果寫鎖已經被獲取並且獲取寫鎖的線程不是當前線程,當前線程獲取讀鎖失敗返回-1 判斷鎖降級
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//計算出讀鎖的數量
int r = sharedCount(c);
/**
* 讀鎖是否阻塞 readerShouldBlock()公平與非公平的實現
* r < MAX_COUNT: 持有讀鎖的線程小於最大數(65535)
* compareAndSetState(c, c + SHARED_UNIT) cas設置獲取讀鎖線程的數量
*/
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) { //當前線程獲取讀鎖
if (r == 0) { //設置第一個獲取讀鎖的線程
firstReader = current;
firstReaderHoldCount = 1; //設置第一個獲取讀鎖線程的重入數
} else if (firstReader == current) { // 表示第一個獲取讀鎖的線程重入
firstReaderHoldCount++;
} else { // 非第一個獲取讀鎖的線程
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++; //記錄其他獲取讀鎖的線程的重入次數
}
return 1;
}
// 嘗試通過自旋的方式獲取讀鎖,實現了重入邏輯
return fullTryAcquireShared(current);
}
- 讀鎖共享,讀讀不互斥
- 讀鎖可重入,每個獲取讀鎖的線程都會記錄對應的重入數
- 讀寫互斥,鎖降級場景除外
- 支持鎖降級,持有寫鎖的線程,可以獲取讀鎖,但是後續要記得把讀鎖和寫鎖讀釋放
- readerShouldBlock讀鎖是否阻塞實現取決公平與非公平的策略(FairSync和NonfairSync)
讀鎖的釋放
獲取到讀鎖,執行完臨界區後,要記得釋放讀鎖(如果重入多次要釋放對應的次數),不然會阻塞其他線程的寫操作。
讀鎖釋放的實現主要通過方法tryReleaseShared:
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//如果當前線程是第一個獲取讀鎖的線程
if (firstReader == current) {
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null;
else
firstReaderHoldCount--; //重入次數減1
} else { //不是第一個獲取讀鎖的線程
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
readHolds.remove();
if (count <= 0)
throw unmatchedUnlockException();
}
--rh.count; //重入次數減1
}
for (;;) { //cas更新同步狀態
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
return nextc == 0;
}
}