前言
- 文章 Java AQS佇列同步器以及ReentrantLock的應用 介紹了AQS獨佔式獲取同步狀態的實現,並以 ReentrantLock 為例說明其是如何自定義同步器實現互斥鎖的
- 文章 Java AQS共享式獲取同步狀態及Semaphore的應用分析 介紹 AQS 共享式獲取同步狀態的實現,並說明了 Semaphore 是如何自定義同步器實現簡單限流作用的
有了以上兩篇文章的鋪墊,來理解本文要介紹的既有獨佔式,又有共享式獲取同步狀態的 ReadWriteLock
,就非常輕鬆了
ReadWriteLock
ReadWriteLock
直譯過來為【讀寫鎖】。現實中,讀多寫少的業務場景是非常普遍的,比如應用快取
一個執行緒將資料寫入快取,其他執行緒可以直接讀取快取中的資料,提高資料查詢效率
之前提到的互斥鎖都是排他鎖,也就是說同一時刻只允許一個執行緒進行訪問,當面對可共享讀的業務場景,互斥鎖顯然是比較低效的一種處理方式。為了提高效率,讀寫鎖模型就誕生了
效率提升是一方面,但併發程式設計更重要的是在保證準確性的前提下提高效率
一個寫執行緒改變了快取中的值,其他讀執行緒一定是可以 “感知” 到的,否則可能導致查詢到的值不準確
所以關於讀寫鎖模型就了下面這 3 條規定:
- 允許多個執行緒同時讀共享變數
- 只允許一個執行緒寫共享變數
- 如果寫執行緒正在執行寫操作,此時則禁止其他讀執行緒讀共享變數
ReadWriteLock
是一個介面,其內部只有兩個方法:
public interface ReadWriteLock {
// 返回用於讀的鎖
Lock readLock();
// 返回用於寫的鎖
Lock writeLock();
}
所以要了解整個讀/寫鎖的整個應用過程,需要從它的實現類 ReentrantReadWriteLock
說起
ReentrantReadWriteLock 類結構
直接對比ReentrantReadWriteLock 與 ReentrantLock的類結構
他們又很相似吧,根據類名稱以及類結構,按照我們們前序文章的分析,你也就能看出 ReentrantReadWriteLock 的基本特性:
其中黃顏色標記的的 鎖降級 是看不出來的, 這裡先有個印象,下面會單獨說明
另外,不知道你是否還記得,Java AQS佇列同步器以及ReentrantLock的應用 說過,Lock 和 AQS 同步器是一種組合形式的存在,既然這裡是讀/寫兩種鎖,他們的組合模式也就分成了兩種:
- 讀鎖與自定義同步器的聚合
- 寫鎖與自定義同步器的聚合
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
這裡只是提醒大家,模式沒有變,不要被讀/寫兩種鎖迷惑
基本示例
說了這麼多,如果你忘了前序知識,整體理解感覺應該是有斷檔的,所以先來看個示例(模擬使用快取)讓大家對 ReentrantReadWriteLock 有個直觀的使用印象
public class ReentrantReadWriteLockCache {
// 定義一個非執行緒安全的 HashMap 用於快取物件
static Map<String, Object> map = new HashMap<String, Object>();
// 建立讀寫鎖物件
static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 構建讀鎖
static Lock rl = readWriteLock.readLock();
// 構建寫鎖
static Lock wl = readWriteLock.writeLock();
public static final Object get(String key) {
rl.lock();
try{
return map.get(key);
}finally {
rl.unlock();
}
}
public static final Object put(String key, Object value){
wl.lock();
try{
return map.put(key, value);
}finally {
wl.unlock();
}
}
}
你瞧,使用就是這麼簡單。但是你知道的,AQS 的核心是鎖的實現,即控制同步狀態 state 的值,ReentrantReadWriteLock 也是應用AQS的 state 來控制同步狀態的,那麼問題來了:
一個 int 型別的 state 怎麼既控制讀的同步狀態,又可以控制寫的同步狀態呢?
顯然需要一點設計了
讀寫狀態設計
如果要在一個 int 型別變數上維護多個狀態,那肯定就需要拆分了。我們知道 int 型別資料佔32位,所以我們就有機會按位切割使用state了。我們將其切割成兩部分:
- 高16位表示讀
- 低16位表示寫
所以,要想準確的計算讀/寫各自的狀態值,肯定就要應用位運算了,下面程式碼是 JDK1.8,ReentrantReadWriteLock 自定義同步器 Sync 的位操作
abstract static class Sync extends AbstractQueuedSynchronizer {
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
}
static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
}
乍一看真是有些複雜的可怕,別慌,我們們通過幾道小小數學題就可以搞定整個位運算過程
整個 ReentrantReadWriteLock 中 讀/寫狀態的計算就是反覆應用這幾道數學題,所以,在閱讀下面內容之前,希望你搞懂這簡單的運算
基礎鋪墊足夠了,我們進入原始碼分析吧
原始碼分析
寫鎖分析
由於寫鎖是排他的,所以肯定是要重寫 AQS 中 tryAcquire
方法
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 獲取 state 整體的值
int c = getState();
// 獲取寫狀態的值
int w = exclusiveCount(c);
if (c != 0) {
// w=0: 根據推理二,整體狀態不等於零,寫狀態等於零,所以,讀狀態大於0,即存在讀鎖
// 或者當前執行緒不是已獲取寫鎖的執行緒
// 二者之一條件成真,則獲取寫狀態失敗
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 根據推理一第 1 條,更新寫狀態值
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
上述程式碼 第 19 行 writerShouldBlock 也並沒有什麼神祕的,只不過是公平/非公平獲取鎖方式的判斷(是否有前驅節點來判斷)
你瞧,寫鎖獲取方式就是這麼簡單
讀鎖分析
由於讀鎖是共享式的,所以肯定是要重寫 AQS 中 tryAcquireShared
方法
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 寫狀態不等於0,並且鎖的持有者不是當前執行緒,根據約定 3,則獲取讀鎖失敗
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 獲取讀狀態值
int r = sharedCount(c);
// 這個地方有點不一樣,我們單獨說明
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
和 writerShouldBlock
在公平鎖的實現上都是判斷是否有前驅節點,但是在非公平鎖的實現上,前者是這樣的:
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null &&
// 等待佇列頭節點的下一個節點
(s = h.next) != null &&
// 如果是排他式的節點
!s.isShared() &&
s.thread != null;
}
簡單來說,如果請求讀鎖的當前執行緒發現同步佇列的 head 節點的下一個節點為排他式節點,那麼就說明有一個執行緒在等待獲取寫鎖(爭搶寫鎖失敗,被放入到同步佇列中),那麼請求讀鎖的執行緒就要阻塞,畢竟讀多寫少,如果還沒有這點判斷機制,寫鎖可能會發生【飢餓】
上述條件都滿足了,也就會進入 tryAcquireShared
程式碼的第 14 行到第 25 行,這段程式碼主要是為了記錄執行緒持有鎖的次數。讀鎖是共享式的,還想記錄每個執行緒持有讀鎖的次數,就要用到 ThreadLocal 了,因為這不影響同步狀態 state 的值,所以就不分析了, 只把關係放在這吧
到這裡讀鎖的獲取也就結束了,比寫鎖稍稍複雜那麼一丟丟,接下來就說明一下那個可能讓你迷惑的鎖升級/降級問題吧
讀寫鎖的升級與降級
個人理解:讀鎖是可以被多執行緒共享的,寫鎖是單執行緒獨佔的,也就是說寫鎖的併發限制比讀鎖高,所以
在真正瞭解讀寫鎖的升級與降級之前,我們需要完善一下本文開頭 ReentrantReadWriteLock 的例子
public static final Object get(String key) {
Object obj = null;
rl.lock();
try{
// 獲取快取中的值
obj = map.get(key);
}finally {
rl.unlock();
}
// 快取中值不為空,直接返回
if (obj!= null) {
return obj;
}
// 快取中值為空,則通過寫鎖查詢DB,並將其寫入到快取中
wl.lock();
try{
// 再次嘗試獲取快取中的值
obj = map.get(key);
// 再次獲取快取中值還是為空
if (obj == null) {
// 查詢DB
obj = getDataFromDB(key); // 虛擬碼:getDataFromDB
// 將其放入到快取中
map.put(key, obj);
}
}finally {
wl.unlock();
}
return obj;
}
有童鞋可能會有疑問
在寫鎖裡面,為什麼程式碼第19行還要再次獲取快取中的值呢?不是多此一舉嗎?
其實這裡再次嘗試獲取快取中的值是很有必要的,因為可能存在多個執行緒同時執行 get 方法,並且引數 key 也是相同的,執行到程式碼第 16 行 wl.lock()
,比如這樣:
執行緒 A,B,C 同時執行到臨界區 wl.lock(), 只有執行緒 A 獲取寫鎖成功,執行緒B,C只能阻塞,直到執行緒A 釋放寫鎖。這時,當執行緒B 或者 C 再次進入臨界區時,執行緒 A 已經將值更新到快取中了,所以執行緒B,C沒必要再查詢一次DB,而是再次嘗試查詢快取中的值
既然再次獲取快取很有必要,我能否在讀鎖裡直接判斷,如果快取中沒有值,那就再次獲取寫鎖來查詢DB不就可以了嘛,就像這樣:
public static final Object getLockUpgrade(String key) {
Object obj = null;
rl.lock();
try{
obj = map.get(key);
if (obj == null){
wl.lock();
try{
obj = map.get(key);
if (obj == null) {
obj = getDataFromDB(key); // 虛擬碼:getDataFromDB
map.put(key, obj);
}
}finally {
wl.unlock();
}
}
}finally {
rl.unlock();
}
return obj;
}
這還真是不可以的,因為獲取一個寫入鎖需要先釋放所有的讀取鎖,如果有兩個讀取鎖試圖獲取寫入鎖,且都不釋放讀取鎖時,就會發生死鎖,所以在這裡,鎖的升級是不被允許的
讀寫鎖的升級是不可以的,那麼鎖的降級是可以的嘛?這個是 Oracle 官閘道器於鎖降級的示例 ,我將程式碼貼上在此處,大家有興趣可以點進去連線看更多內容
class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 必須在獲取寫鎖之前釋放讀鎖,因為鎖的升級是不被允許的
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 再次檢查,原因可能是其他執行緒已經更新過快取
if (!cacheValid) {
data = ...
cacheValid = true;
}
//在釋放寫鎖前,降級為讀鎖
rwl.readLock().lock();
} finally {
//釋放寫鎖,此時持有讀鎖
rwl.writeLock().unlock();
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}
程式碼中宣告瞭一個 volatile 型別的 cacheValid 變數,保證其可見性。
- 首先獲取讀鎖,如果cache不可用,則釋放讀鎖
- 然後獲取寫鎖
- 在更改資料之前,再檢查一次cacheValid的值,然後修改資料,將cacheValid置為true
- 然後在釋放寫鎖前獲取讀鎖 此時
- cache中資料可用,處理cache中資料,最後釋放讀鎖
這個過程就是一個完整的鎖降級的過程,目的是保證資料可見性,聽起來很有道理的樣子,那麼問題來了:
上述程式碼為什麼在釋放寫鎖之前要獲取讀鎖呢?
如果當前的執行緒A在修改完cache中的資料後,沒有獲取讀鎖而是直接釋放了寫鎖;假設此時另一個執行緒B 獲取了寫鎖並修改了資料,那麼執行緒A無法感知到資料已被修改,但執行緒A還應用了快取資料,所以就可能出現資料錯誤
如果遵循鎖降級的步驟,執行緒A 在釋放寫鎖之前獲取讀鎖,那麼執行緒B在獲取寫鎖時將被阻塞,直到執行緒A完成資料處理過程,釋放讀鎖,從而保證資料的可見性
那問題又來了:
使用寫鎖一定要降級嗎?
如果你理解了上面的問題,相信這個問題已經有了答案。假如執行緒A修改完資料之後, 經過耗時操作後想要再使用資料時,希望使用的是自己修改後的資料,而不是其他執行緒修改後的資料,這樣的話確實是需要鎖降級;如果只是希望最後使用資料的時候,拿到的是最新的資料,而不一定是自己剛修改過的資料,那麼先釋放寫鎖,再獲取讀鎖,然後使用資料也無妨
在這裡我要額外說明一下你可能存在的誤解:
- 如果已經釋放了讀鎖再獲取寫鎖不叫鎖的升級
- 如果已經釋放了寫鎖在獲取讀鎖也不叫鎖的降級
相信你到這裡也理解了鎖的升級與降級過程,以及他們被允許或被禁止的原因了
總結
本文主要說明了 ReentrantReadWriteLock 是如何應用 state 做位拆分實現讀/寫兩種同步狀態的,另外也通過原始碼分析了讀/寫鎖獲取同步狀態的過程,最後又瞭解了讀寫鎖的升級/降級機制,相信到這裡你對讀寫鎖已經有了一定的理解。如果你對文中的哪些地方覺得理解有些困難,強烈建議你回看本文開頭的兩篇文章,那裡鋪墊了非常多的內容。接下來我們就看看在應用AQS的最後一個併發工具類 CountDownLatch 吧
靈魂追問
- 讀鎖也沒修改資料,還允許共享式獲取,那還有必要設定讀鎖嗎?
- 在分散式環境中,你是如何保證快取資料一致性的呢?
- 當你開啟看ReentrantReadWriteLock原始碼時,你會發現,WriteLock 中可以使用 Condition,但是ReadLock 使用Condition卻會丟擲UnsupportedOperationException,這是為什麼呢?
// WriteLock
public Condition newCondition() {
return sync.newCondition();
}
// ReadLock
public Condition newCondition() {
throw new UnsupportedOperationException();
}
參考
- Java 併發實戰
- Java 併發程式設計的藝術
- https://www.jianshu.com/p/586...
日拱一兵 | 原創