Java 多線程(五)- 理解 Condition 和 條件變量

在關于 ReentrantLock 的文章中,提到 Lock 接口作為內置 Monitor 鎖的補充,提供了更靈活的接口,其中 lock / unlock 對于內置鎖的 synchronized,那么內置鎖的 監控條件 對應 Lock 的什么呢?就是 newCondition 返回的 Condition。Condition 和 內置鎖的監控條件都被叫做 條件變量

條件變量

作用

條件變量最主要的作用是用來管理線程執行對某些狀態的依賴性。想象一下:一個線程是某個隊列的消費者,它必須要等到隊列中有數據時才能執行,如果隊列為空,則會一直等待掛起,直到另外一個線程在隊列中存入數據,并通知先前掛起的線程,該線程才會喚醒重新開始執行。

這個例子中,隊列是否空/滿 是線程執行所依賴的狀態,而這個狀態是多個線程需要訪問的,所以需要加鎖互斥訪問,這種加鎖模式與其他同步加鎖略有不同,鎖在操作的執行過程中需要被釋放與重新獲取的。管理依賴共享變量的線程執行通常用如下的編程模式:

獲取鎖;
while (條件狀態不滿足) {
    釋放鎖;
    線程掛起等待,直到條件滿足通知;
    重新獲取鎖;
}

臨界區操作;
釋放鎖;

條件變量為了管理這種依賴性,需要做兩件事情:

  1. 提供 await / wait 接口,掛起當前線程,并將線程放入條件隊列 管理,同時釋放鎖;
  2. 提供 signal / notify 接口,喚醒等待的線程,重新搶鎖運行;

在編程模式里為什么要使用 while 而不是 if,已經在之前的 Monitor 內置鎖中有所闡述。

條件隊列

條件隊列來源于:它使得一組線程(等待線程集合)能夠通過某種方式來等待特條件變成真。傳統隊列的元素是一個個數據,而與之不同的是,條件隊列中的元素是一個個正在等待相關條件的線程

內置鎖中的條件隊列

前面的文章說過,每個 Java 對象都是一個 Monitor Object 模式的對象,可以當作一個 Monitor 鎖。每個對象同樣可以作為一個條件隊列,提供了 wait / notify / notifyAll 方法構成內部條件隊列的 API。

Object.wait 會自動釋放內置鎖,并請求操作系統掛起當前線程,從而使其他線程能夠獲得內置鎖,修改依賴的對象狀態。當掛起的線程醒來時,它將在返回之前重新獲取鎖。

使用 wait / notify 組合接口管理狀態依賴性比“輪詢和休眠”更加簡單和高效。

輪詢是指在 while 循環里不斷檢查條件狀態,如果條件狀態滿足,則進行以下處理,這會浪費很多 CPU 時鐘進行判斷。

休眠是指在 while 循環里檢查條件狀態,如果狀態不滿足,則 sleep 一段時間,線程醒來后則再次判斷。它比輪詢節約 CPU 時間片,但比條件變量低效,而且 sleep 的時間間隔難以把握,會依賴狀態改變后也不會立即醒來,響應性也比條件隊列差。

但是在功能實現上,這幾種方式并沒有差別,也就是說:

如果某個功能無法通過“輪詢和休眠”來實現,那么條件隊列也無法實現

條件謂詞

要想正確使用條件隊列,關鍵是找出對象在哪個條件謂詞上等待。條件謂詞并不依賴于條件變量的接口,它是使某個操作稱為狀態依賴操作的前提條件。如下圖的代碼塊:

圖1 使用內置鎖條件變量

其中2處的 isFull 函數就是一個條件謂詞,表示“隊列已滿”時,需要等待。

三元關系

在條件等待中存在一種重要的三元關系,包括加鎖,wait 方法和一個條件謂詞。

在條件謂詞中包含一個或多個線程共享的狀態變量,需要一個鎖來保護。因此在測試條件謂詞之前必須要先持有鎖。鎖對象與條件隊列對象必須是同一個對象,他們之間的三元關系如下:

每一次 wait 調用都會隱式地與特定的條件謂詞關聯起來;

當調用某個特定條件謂詞的 wait 時,調用著必須已經持有與條件隊列相關的鎖;

并且這個鎖必須保護著構成條件謂詞的狀態變量。

內置 Monitor 條件變量缺陷
過早喚醒

雖然鎖 / 條件謂詞 / 條件隊列之間的三元關系不是很復雜,但 wait 方法的返回并不一定意味著線程正在等待的條件謂詞已經成真。考慮圖 2 的阻塞隊列代碼段:

圖2 某阻塞隊列代碼片段

假設有 A,B 兩條線程阻塞在 put 函數,C 線程調用 take,獲取并推出隊列中一個數據,同時調用 notifyAll 喚醒 A,B 線程;若 A 線程獲取內置鎖,B 阻塞在鎖獲取中,A 又向隊列壓入一個數據,此時隊列又滿了;A 釋放鎖后,B 獲取鎖,但是隊列已滿,條件謂詞判斷失敗,再次 wait 阻塞。

信號丟失

這里的信號丟失是指:線程正在等待一個已經(或者本應該)發生過的喚醒信號。錯誤的編程模式通常會造成信號丟失。考慮圖 3 的阻塞隊列代碼段:

圖2 某阻塞隊列代碼片段

假設有 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(具體可查看上篇文章),不同的:

  1. waitStatus 值為 CONDITION(-2), 表示該節點在條件隊列上。
  2. nextWaiter 指向條件隊列的下一個節點。

同時在 ConditionObject 內保存有隊列的首尾指針:

  1. firstWaiter,指向隊列的第一個Node
  2. lastWaiter,指向隊列最后一個Node

下文為了區分兩個不同的隊列,使用以下名詞:

  1. 同步隊列:AQS 中的鎖等待隊列
  2. 條件隊列:ConditionObject 中的條件隊列
await

簡單起見,我們分析方法 awaitUninterruptibly,代碼片段如下圖所示

圖4 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,該方法如下圖所示:

圖5 doSignal
  • 1874行,為轉移線程節點做準備,將 nextWaiter 設置為 null,在同步隊列,該字段無用,設置為 null 后,利于以后垃圾回收;
  • 1875行,關鍵是 transferForSignal,它主要干以下這些事:
    1. 使用 CAS 設置節點狀態為 0;
    2. 調用 AQS.enq 將 node 重新壓入同步隊列;
    3. 修改 node 的前繼節點狀態為 SIGNAL;
    4. 如果前繼節點已經取消等待,恢復該 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 并發編程實戰

http://blog.csdn.net/ghsau/article/details/7481142

http://blog.csdn.net/vernonzheng/article/details/8288251

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,119評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,382評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,038評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,853評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,616評論 6 408
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,112評論 1 323
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,192評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,355評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,869評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,727評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,928評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,467評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,165評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,570評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,813評論 1 282
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,585評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,892評論 2 372

推薦閱讀更多精彩內容