心心念的讀寫鎖:ReentrantReadWriteLock
今天本來是想去戶外穿越看楓葉的,但報名晚了又加上今天天氣不太好,未去成。
無所事事,於是,就想把之前未曾細看過的讀寫鎖看一下。
1、前言
讀完了以上兩篇文章,先看一下ReentrantReadWriteLock的程式碼路徑:
package java.util.concurrent.locks;
來先猜一下ReentrantReadWriteLock會如何實現?
都在java.util.concurrent包下,那麼可以明確一點,那就是關於鎖的實現,應該
用的就是AQS,那麼,讀鎖、寫鎖會不會對應的就是AQS中的共享模式與獨佔模式?
哈哈 我也不清楚 來一起看看吧。
2、讀寫鎖使用場景
讀是多於寫(比如cache)
一般情況下,讀寫鎖的效能都會比排它鎖好,因為大多數場景讀是多於寫的。在讀多於寫的情況下,讀寫鎖能夠提供比排它鎖更好的併發性和吞吐量。
3、讀寫鎖介面: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(); }
4、讀寫鎖的介面與示例
ReadWriteLock僅定義了獲取讀鎖和寫鎖的兩個方法,即readLock()方法和writeLock()方法,而其實現: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使用讀寫鎖提升讀操作的併發性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了程式設計方式。
5、ReentrantReadWriteLock脈絡梳理
先看一下繼承結構:
再看一下程式碼結構:
圖中可以看出ReentrantReadWriteLock的實現還是比較複雜的,所以接下來主要分析ReentrantReadWriteLock實現關鍵點,包括:
- 讀寫狀態的設計
- 寫鎖的獲取與釋放
- 讀鎖的獲取與釋放
- 鎖降級
5.1 讀寫狀態的設計
讀寫鎖同樣依賴自定義同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態。回想ReentrantLock中自定義同步器的實現,同步狀態表示鎖被一個執行緒重複獲取的次數,而讀寫鎖的自定義同步器需要在同步狀態(一個整型變數)上維護多個讀執行緒和一個寫執行緒的狀態,使得該狀態的設計成為讀寫鎖實現的關鍵。
如果在一個整型變數上維護多種狀態,就一定需要“按位切割使用”這個變數,讀寫鎖將變數切分成了兩個部分,高16位表示讀,低16位表示寫,劃分方式如下圖所示。
當前同步狀態表示一個執行緒已經獲取了寫鎖,且重進入了兩次,同時也連續獲取了兩次讀鎖。讀寫鎖是如何迅速確定讀和寫各自的狀態呢?
答案是通過位運算。假設當前同步狀態值為S,寫狀態等於S&0x0000FFFF(將高16位全部抹去),讀狀態等於S>>>16(無符號補0右移16位)。當寫狀態增加1時,等於S+1,當讀狀態增加1時,等於S+(1<<16),也就是S+0x00010000。
1、0x0000FFFF=00000000000000001111111111111111(16個0 16個1)
2、>>>: 無符號右移,忽略符號位,空位都以0補齊
3、0x00010000=10000000000000000(1個1 16個0)
根據狀態的劃分能得出一個推論:S不等於0時,當寫狀態(S&0x0000FFFF)等於0時,則讀狀態(S>>>16)大於0,即讀鎖已被獲取。
5.2 寫鎖的獲取與釋放
寫鎖是一個支援重進入的排它鎖。如果當前執行緒已經獲取了寫鎖,則增加寫狀態。如果當前執行緒在獲取寫鎖時,讀鎖已經被獲取(讀狀態不為0)或者該執行緒不是已經獲取寫鎖的執行緒,則當前執行緒進入等待狀態,獲取寫鎖的程式碼如程式碼如下:
protected final boolean tryAcquire(int acquires) {
/*
* Walkthrough:
* 1. If read count nonzero or write count nonzero
* and owner is a different thread, fail.
* 2. If count would saturate, fail. (This can only
* happen if count is already nonzero.)
* 3. Otherwise, this thread is eligible for lock if
* it is either a reentrant acquire or
* queue policy allows it. If so, update state
* and set owner.
*/
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// 存在讀鎖或者當前獲取執行緒不是已經獲取寫鎖的執行緒
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
該方法除了重入條件(當前執行緒為獲取了寫鎖的執行緒)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在於:讀寫鎖要確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那麼正在執行的其他讀執行緒就無法感知到當前寫執行緒的操作。因此,只有等待其他讀執行緒都釋放了讀鎖,寫鎖才能被當前執行緒獲取,而寫鎖一旦被獲取,則其他讀寫執行緒的後續訪問均被阻塞。
寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態為0時表示寫鎖已被釋放,從而等待的讀寫執行緒能夠繼續訪問讀寫鎖,同時前次寫執行緒的修改對後續讀寫執行緒可見。
5.3 讀鎖的獲取與釋放
讀鎖是一個支援重進入的共享鎖,它能夠被多個執行緒同時獲取,在沒有其他寫執行緒訪問(或者寫狀態為0)時,讀鎖總會被成功地獲取,而所做的也只是(執行緒安全的)增加讀狀態。如果當前執行緒已經獲取了讀鎖,則增加讀狀態。如果當前執行緒在獲取讀鎖時,寫鎖已被其他執行緒獲取,則進入等待狀態。獲取讀鎖的實現從Java 5到Java 6變得複雜許多,主要原因是新增了一些功能,例如getReadHoldCount()方法,作用是返回當前執行緒獲取讀鎖的次數。讀狀態是所有執行緒獲取讀鎖次數的總和,而每個執行緒各自獲取讀鎖的次數只能選擇儲存在ThreadLocal中,由執行緒自身維護,這使獲取讀鎖的實現變得複雜。因此,這裡將獲取讀鎖的程式碼做了刪減,保留必要的部分,如程式碼如下:
protected final int tryAcquireShared(int unused) {
for (;;) {
int c = getState();
int nextc = c + (1 << 16);
if (nextc < c)
throw new Error("Maximum lock count exceeded");
if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
return -1;
if (compareAndSetState(c, nextc))
return 1;
}
}
在tryAcquireShared(int unused)方法中,如果其他執行緒已經獲取了寫鎖,則當前執行緒獲取讀鎖失敗,進入等待狀態。如果當前執行緒獲取了寫鎖或者寫鎖未被獲取,則當前執行緒(執行緒安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。
讀鎖的每次釋放(執行緒安全的,可能有多個讀執行緒同時釋放讀鎖)均減少讀狀態,減少的值是(1<<16)。
5.4 鎖降級
鎖降級指的是寫鎖降級成為讀鎖。如果當前執行緒擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之為鎖降級。鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程。
接下來看一個鎖降級的示例。因為資料不常變化,所以多個執行緒可以併發地進行資料處理,當資料變更後,如果當前執行緒感知到資料變化,則進行資料的準備工作,同時其他處理執行緒被阻塞,直到當前執行緒完成資料的準備工作,如程式碼如下所示:
public void processData() {
readLock.lock();
if (!update) {
// 必須先釋放讀鎖
readLock.unlock();
// 鎖降級從寫鎖獲取到開始
writeLock.lock();
try {
if (!update) {
// 準備資料的流程(略)
update = true;
}
readLock.lock();
} finally {
writeLock.unlock();
}
// 鎖降級完成,寫鎖降級為讀鎖
}
try {
// 使用資料的流程(略)
} finally {
readLock.unlock();
}
}
上述示例中,當資料發生變更後,update變數(布林型別且volatile修飾)被設定為false,此時所有訪問processData()方法的執行緒都能夠感知到變化,但只有一個執行緒能夠獲取到寫鎖,其他執行緒會被阻塞在讀鎖和寫鎖的lock()方法上。當前執行緒獲取寫鎖完成資料準備之後,再獲取讀鎖,隨後釋放寫鎖,完成鎖降級。
鎖降級中讀鎖的獲取是否必要呢?答案是必要的。主要是為了保證資料的可見性,如果當前執行緒不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個執行緒(記作執行緒T)獲取了寫鎖並修改了資料,那麼當前執行緒無法感知執行緒T的資料更新。如果當前執行緒獲取讀鎖,即遵循鎖降級的步驟,則執行緒T將會被阻塞,直到當前執行緒使用資料並釋放讀鎖之後,執行緒T才能獲取寫鎖進行資料更新。
RentrantReadWriteLock不支援鎖升級(把持讀鎖、獲取寫鎖,最後釋放讀鎖的過程)。目的也是保證資料可見性,如果讀鎖已被多個執行緒獲取,其中任意執行緒成功獲取了寫鎖並更新了資料,則其更新對其他獲取到讀鎖的執行緒是不可見的。
6、小結
RentrantReadWriteLock的具體流程梳理完了,回過頭來想一下前言的問題,好像並沒有得到答案,那麼來到ReentrantReadWriteLock程式碼中,此處主要看一下讀鎖的獲取、釋放是否對應AQS中的共享模式。
6.1 讀鎖的獲取、釋放
public void lock() {
//看到這裡是不是就明白了,我們的猜想是正確的
sync.acquireShared(1);
}
public void unlock() {
//看到這裡是不是就明白了,我們的猜想是正確的
sync.releaseShared(1);
}
先來看一下ReadLock的具體實現,在ReentrantReadWriteLock初始化的時候,會在建構函式中初始化ReadLock、WriteLock,具體程式碼如下:
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
從ReentrantReadWriteLock建構函式的程式碼中,可以看到ReadLock初始化的引數是ReentrantReadWriteLock,那麼ReadLock需要ReentrantReadWriteLock來做什麼呢?
來看一下ReadLock:
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
從ReadLock的建構函式中,可以看出,ReadLock需要獲取到Sync,那麼Sync是誰,又是用來做什麼的?
其實,如果看過JUC下面程式碼的話,看到Sync,就明白它應該就是AQS的實現類,通過它來實現相關鎖的操作。
來看一下程式碼驗證一下:
/**
* Synchronization implementation for ReentrantReadWriteLock.
* Subclassed into fair and nonfair versions.
*/
abstract static class Sync extends AbstractQueuedSynchronizer {
//具體程式碼略
}
看到這裡可以大體得出這麼一個結果:ReadLock獲取鎖的時候,是通過ReentrantReadWriteLock 內部Sync類來獲取的共享鎖,也就是讀鎖的獲取是對應AQS中的共享模式。
點進 sync.acquireShared(1)方法,可以看到是呼叫Sync的父類AQS中方法:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
看到這裡,也就明白為啥AQS子類需要重寫:
tryAcquire
tryRelease
tryReleaseShared
isHeldExclusively
等方法了。
心心念的讀寫鎖寫完了,願在讀文章的你,度過一個愉快的雙十一。
個人微信公眾號: