在關于 ReentrantLock 的文章中,提到 Lock 接口作為內置 Monitor 鎖的補充,提供了更靈活的接口,其中 lock / unlock 對于內置鎖的 synchronized,那么內置鎖的 監控條件 對應 Lock 的什么呢?就是 newCondition 返回的 Condition。Condition 和 內置鎖的監控條件都被叫做 條件變量。
條件變量
作用
條件變量最主要的作用是用來管理線程執行對某些狀態的依賴性。想象一下:一個線程是某個隊列的消費者,它必須要等到隊列中有數據時才能執行,如果隊列為空,則會一直等待掛起,直到另外一個線程在隊列中存入數據,并通知先前掛起的線程,該線程才會喚醒重新開始執行。
這個例子中,隊列是否空/滿 是線程執行所依賴的狀態,而這個狀態是多個線程需要訪問的,所以需要加鎖互斥訪問,這種加鎖模式與其他同步加鎖略有不同,鎖在操作的執行過程中需要被釋放與重新獲取的。管理依賴共享變量的線程執行通常用如下的編程模式:
獲取鎖;
while (條件狀態不滿足) {
釋放鎖;
線程掛起等待,直到條件滿足通知;
重新獲取鎖;
}
臨界區操作;
釋放鎖;
條件變量為了管理這種依賴性,需要做兩件事情:
- 提供 await / wait 接口,掛起當前線程,并將線程放入條件隊列 管理,同時釋放鎖;
- 提供 signal / notify 接口,喚醒等待的線程,重新搶鎖運行;
在編程模式里為什么要使用 while 而不是 if,已經在之前的 Monitor 內置鎖中有所闡述。
條件隊列
條件隊列來源于:它使得一組線程(等待線程集合)能夠通過某種方式來等待特條件變成真。傳統隊列的元素是一個個數據,而與之不同的是,條件隊列中的元素是一個個正在等待相關條件的線程。
內置鎖中的條件隊列
前面的文章說過,每個 Java 對象都是一個 Monitor Object 模式的對象,可以當作一個 Monitor 鎖。每個對象同樣可以作為一個條件隊列,提供了 wait / notify / notifyAll 方法構成內部條件隊列的 API。
Object.wait 會自動釋放內置鎖,并請求操作系統掛起當前線程,從而使其他線程能夠獲得內置鎖,修改依賴的對象狀態。當掛起的線程醒來時,它將在返回之前重新獲取鎖。
使用 wait / notify 組合接口管理狀態依賴性比“輪詢和休眠”更加簡單和高效。
輪詢是指在 while 循環里不斷檢查條件狀態,如果條件狀態滿足,則進行以下處理,這會浪費很多 CPU 時鐘進行判斷。
休眠是指在 while 循環里檢查條件狀態,如果狀態不滿足,則 sleep 一段時間,線程醒來后則再次判斷。它比輪詢節約 CPU 時間片,但比條件變量低效,而且 sleep 的時間間隔難以把握,會依賴狀態改變后也不會立即醒來,響應性也比條件隊列差。
但是在功能實現上,這幾種方式并沒有差別,也就是說:
如果某個功能無法通過“輪詢和休眠”來實現,那么條件隊列也無法實現
條件謂詞
要想正確使用條件隊列,關鍵是找出對象在哪個條件謂詞上等待。條件謂詞并不依賴于條件變量的接口,它是使某個操作稱為狀態依賴操作的前提條件。如下圖的代碼塊:
其中2處的 isFull 函數就是一個條件謂詞,表示“隊列已滿”時,需要等待。
三元關系
在條件等待中存在一種重要的三元關系,包括加鎖,wait 方法和一個條件謂詞。
在條件謂詞中包含一個或多個線程共享的狀態變量,需要一個鎖來保護。因此在測試條件謂詞之前必須要先持有鎖。鎖對象與條件隊列對象必須是同一個對象,他們之間的三元關系如下:
每一次 wait 調用都會隱式地與特定的條件謂詞關聯起來;
當調用某個特定條件謂詞的 wait 時,調用著必須已經持有與條件隊列相關的鎖;
并且這個鎖必須保護著構成條件謂詞的狀態變量。
內置 Monitor 條件變量缺陷
過早喚醒
雖然鎖 / 條件謂詞 / 條件隊列之間的三元關系不是很復雜,但 wait 方法的返回并不一定意味著線程正在等待的條件謂詞已經成真。考慮圖 2 的阻塞隊列代碼段:
假設有 A,B 兩條線程阻塞在 put 函數,C 線程調用 take,獲取并推出隊列中一個數據,同時調用 notifyAll 喚醒 A,B 線程;若 A 線程獲取內置鎖,B 阻塞在鎖獲取中,A 又向隊列壓入一個數據,此時隊列又滿了;A 釋放鎖后,B 獲取鎖,但是隊列已滿,條件謂詞判斷失敗,再次 wait 阻塞。
信號丟失
這里的信號丟失是指:線程正在等待一個已經(或者本應該)發生過的喚醒信號。錯誤的編程模式通常會造成信號丟失。考慮圖 3 的阻塞隊列代碼段:
假設有 A 線程阻塞在 put 函數,B 線程阻塞在 take 函數,C 線程調用 take,然后使用 notify 接口喚醒其中一個線程;不巧的是 B 線程被喚醒,B 檢查隊列仍然為空,繼續等待阻塞,此時應該被喚醒的 A 只能等待下一個喚醒。
Condition
Condition VS Monitor 條件變量
分析內置 Monitor 條件變量的過早喚醒和信號丟失,它們其實有一個共同的原因:多個線程在同一個條件隊列上等待不同的條件謂詞。如果想編寫一個帶有多個條件謂詞的并發對象,或者想除了條件隊列可見性意外的更多控制權,就可以使用顯示的 Lock 和 Condition 而不是內置鎖和條件隊列。
與內置條件隊列不同,對于每一個 Lock,可以有任意數量的 Condition 對象,因此對于不同的條件謂詞,對于同一個鎖,可以用不同的 Condition 對象來控制。
同時類似于 Lock 和內置鎖的差異,Condition 也提供了豐富的接口等待掛起(可輪詢,可中斷,可超時等)。接口如下所示:
// wait 接口
void await() throws InterruptedException;
void awaitUninterruptibly();
long awaitNanos(long nanosTimeout) throws InterruptedException;
boolean await(long time, TimeUnit unit) throws InterruptedException;
// notify
void signal();
void signalAll();
Condition 對象會繼承相關的 Lock 公平性,對于公平的鎖,線程會依照 FIFO 順序從 await 中釋放。
特別注意:在 Condition 對象中,與 wait,notify, notifyAll 方法對應的分別是 await,signal 和 signalAll。但是實現 Condition 的類必然繼承自 Object,因為它也包含了 wait 和 notify 方法。所以使用時一定要確保正確的版本。
分析代碼
深究下,Condition 是如何管理隊列的,它為什么會繼承 Lock 的公平性,Condition 是如何阻塞擁有鎖的線程。介紹完 Condition 后,可能會冒出更多的問題,為了學習 Condition,不妨以 AQS 的 ConditionObject 作為代碼分析對象理解理解。
Node 隊列節點
ConditionObject 復用了和 AQS 的隊列節點 Node(具體可查看上篇文章),不同的:
- waitStatus 值為 CONDITION(-2), 表示該節點在條件隊列上。
- nextWaiter 指向條件隊列的下一個節點。
同時在 ConditionObject 內保存有隊列的首尾指針:
- firstWaiter,指向隊列的第一個Node
- lastWaiter,指向隊列最后一個Node
下文為了區分兩個不同的隊列,使用以下名詞:
- 同步隊列:AQS 中的鎖等待隊列
- 條件隊列:ConditionObject 中的條件隊列
await
簡單起見,我們分析方法 awaitUninterruptibly,代碼片段如下圖所示
- 1972行,addConditionWaiter 方法會在 ConditionObject 隊列尾部插入一個代表當前線程的 Node,狀態為 CONDITION;
- 1973行,因為要調用 await 接口之前一定已經獲得鎖,所以當前線程在同步隊列中一定是首節點,AQS.fullyRelease 釋放當前鎖,恢復同步隊列后續節點執行,返回當前的許可狀態用于重新申請鎖;
- 1975行,AQS.OnSyncQueue 用來判斷當前線程節點是否在同步隊列中。為了防止被誤喚醒,此處采用 while 進行輪詢判斷;
- 1976行,使用 LockSupport.park 掛起當前線程;
- 1980行,被其他線程喚醒后,調用 AQS.acquireQueued 重新嘗試獲取鎖,如果獲取失敗則被加入同步隊列,AQS.acquireQueued 會調用 AQS.tryAcquire 獲取準入許可,所以 ConditonObject 繼承了 AQS 的公平性。
signal
ConditionObject 的 signal 方法比較簡單,主要代碼被封裝在 doSignal,該方法如下圖所示:
- 1874行,為轉移線程節點做準備,將 nextWaiter 設置為 null,在同步隊列,該字段無用,設置為 null 后,利于以后垃圾回收;
- 1875行,關鍵是 transferForSignal,它主要干以下這些事:
- 使用 CAS 設置節點狀態為 0;
- 調用 AQS.enq 將 node 重新壓入同步隊列;
- 修改 node 的前繼節點狀態為 SIGNAL;
- 如果前繼節點已經取消等待,恢復該 node 代表的線程
編程實踐
以下代碼是結合 Lock 和 Condition 實現容量為100的阻塞線程:
class BoundedBuffer<V> {
final Lock lock = new ReentrantLock();//鎖對象
final Condition notFull = lock.newCondition();//寫線程條件變量
final Condition notEmpty = lock.newCondition();//讀線程條件變量
final LinkedList<V> items = new LinkedList<V>();
final int totalCount = 100;
public void put(V x) throws InterruptedException {
lock.lock();
try {
while (totalCount >= items.size())//如果隊列滿了
notFull.await();//阻塞寫線程
items.addLast(x);
notEmpty.signal();//喚醒讀線程
} finally {
lock.unlock();
}
}
public V take() throws InterruptedException {
lock.lock();
try {
while (items.size() == 0)//如果隊列為空
notEmpty.await();//阻塞讀線程
V x = items.removeFirst();
notFull.signal();//喚醒寫線程
return x;
} finally {
lock.unlock();
}
}
}
代碼中 notFull 代表了寫線程條件變量,notEmpty 代表了讀線程條件變量,在 put 的時候寫入數據,signal 只會喚醒等待在 notEmpty 的線程;對應的 take 取出數據后,喚醒的也只會是等待在 notFull 的線程。
Condition 比內置鎖的條件隊列做的更加細致,能夠很好的解決過早喚醒和信號丟失的問題。
內容來源
Java 并發編程實戰