1. 程式人生 > >深入瞭解Java併發——《Java Concurrency in Practice》14.構建自定義的同步工具

深入瞭解Java併發——《Java Concurrency in Practice》14.構建自定義的同步工具

雖然章節的目的是介紹如何基於AQS等基類來構建自定義的同步工具,但詳細的介紹了AQS的原理,並且詳細的講解了java.util.concurrent類庫中許多基於AQS的常用同步工具對AQS的實現及原理。瞭解AQS之後對ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWritLock、FutureTask都會有一個全新的認識和豁然開朗的感覺。

14.1 狀態依賴性的管理

對於併發物件上依賴狀態的方法,雖然有時候在前提條件不滿足的條件下不會失敗,但通常有一種更好的選擇:等待前提條件變為真。

依賴狀態的操作可以一直阻塞到可以繼續執行,這比使它們先失敗再實現起來要更為方便且不更不易出錯。內建的條件佇列可以使執行緒一直阻塞,知道物件進入某個程序可以繼續執行的狀態,並且當被阻塞的執行緒可以執行時再喚醒它們。

14.1.3 條件佇列

條件佇列使得一組執行緒(等待執行緒集合)能夠通過某種方式來等待特定的條件變成真。條件佇列中的元素時一個個正在等待相關條件的執行緒。

每個物件都可以作為一個條件佇列,並且Object中的wait、notify和notifyAll方法構成了內部條件佇列的API。物件的內建鎖與其內部條件佇列是相互關聯的,要呼叫物件X中條件佇列的任何一個方法,必須持有物件X上的鎖。因為“等待由狀態構成的條件”與“維護狀態一致性”這兩種機制必須被緊密的繫結在一起:只有能對狀態進行檢查時,才能在某個條件上等待,並且只有能修改狀態時,才能從條件等待中釋放另一個執行緒

Object.wat會自動釋放鎖,並請求作業系統掛起當前執行緒。

14.2 使用條件佇列

條件佇列很容易被錯誤使用,編譯器或系統平臺並沒有對其正確使用進行約束。

14.2.1 條件謂詞

正確使用條件佇列的關鍵是找出物件在哪個條件謂詞上等待。條件謂詞是使某個操作成為狀態依賴操作的前提條件

將與條件佇列相關聯的條件謂詞以及在這些條件謂詞上等待的操作都寫入文件

條件等待中存在一種重要的三元關係,包括加鎖、wait方法和一個條件謂詞。在條件謂詞中包含多個狀態變數,而狀態變數由一個鎖來保護,因此在測試條件謂詞之前必須現持有這個鎖。鎖物件與條件佇列物件(即呼叫wait和notify等方法所在的物件)必須是同一個物件。

每一次wait呼叫都會隱式的域特定的條件謂詞關聯起來。當呼叫某個特定條件謂詞的wait時,呼叫者必須已經持有與條件佇列相關的鎖,並且這個鎖必須保護著構成條件謂詞的狀態變數

14.2.2 過早喚醒

內建條件佇列可以與多個條件謂詞一起使用。當一個執行緒由於呼叫notifyAll而醒來時,並不意味該執行緒正在等待的條件謂詞已經變成真了。

每當執行緒從wait中喚醒時,都必須再次測試條件謂詞,如果條件謂詞不為真,那麼就繼續等待(或者失敗)。由於執行緒在條件謂詞不為真的情況下也可以反覆的醒來,因此必須在一個迴圈中呼叫wait,並在每次迭代中都測試條件謂詞。

void stateDependentMethod() throws InterruptedException {
    synchronized(lock) {
        while(!conditionPredicate()) {
            lock.wait();
        }
    }
}

當使用條件等待時 (如Object.wait或Condition.await)
- 通常都有一個條件謂詞——包括一些物件狀態的測試,執行緒在執行前必須首先通過這些測試
- 在呼叫wait之前測試條件謂詞,並且從wait中返回時再次進行測試
- 在一個迴圈中呼叫wait
- 確保使用與條件佇列相關的鎖來保護構成條件謂詞的各個狀態變數
- 當呼叫wait、notify或notifyAll等方法時,一定要持有與條件佇列相關的鎖
- 在檢查條件謂詞之後以及開始執行相應的操作之前,不要釋放鎖

14.2.3 丟失的訊號

在呼叫wait之前檢查條件謂詞,就不會發生訊號丟失的問題。

14.2.4 通知

每當在等待一個條件時,一定要確保在條件謂詞變為真時通過某種方式發出通知

條件佇列API中有notify和notifyAll兩個發出通知的方法,都必須持有與條件佇列物件相關聯的鎖。

呼叫notify時,JVM會從這個條件佇列上等待的多個執行緒中選擇一個來喚醒,而呼叫notifyAll會喚醒所有在這個條件佇列上等待的執行緒。由於在呼叫notify或notifyAll時必須持有條件佇列物件的鎖,而如果這些等待中執行緒此時不能重新獲得鎖,那麼無法從wait返回,因此發出通知的執行緒應該儘快的釋放鎖,從而確保正在等待的執行緒儘可能快的接觸阻塞

大多數情況下應該優先選擇notifyAll而不是notify。只有同時滿足以下兩個條件時,才能使用notify,而不是notifyAll:
1. 所有等待執行緒的型別都相同。 只有一個條件謂詞與條件佇列相關,並且每個執行緒在從wait返回後將執行相同的操作
2. 單進單出 在條件變數上的每次通知,最多隻能喚醒一個執行緒來執行。

14.2.6 子類的安全問題

要想支援子類化,那麼在設計類時需要保證:如果在實施子類化時違背了條件通知或單詞通知的某個需求,那麼在子類中可以增加合適的通知機制來代表基類。

對於狀態依賴的類,要麼將其等待和通知等協議完全向子類公開並且寫入正式文件,要麼完全阻止子類參與到等待和通知等過程中。

14.2.7 封裝條件佇列

通常應該把條件佇列封裝起來,保證除了使用條件佇列的類,就不能在其他地方訪問它,避免呼叫者自以為理解了在等待和通知上使用的協議,從而採用一種違背設計的方式來使用條件佇列。

但這與執行緒安全類最常見的設計模式並不一致,這種模式中建議使用物件的內建鎖來保護物件自身的狀態。

14.2.8 入口協議與出口協議

對於每個依賴狀態的操作,以及每個修改其他操作依賴狀態的操作,都應該定義一個入口協議和出口協議。入口協議就是該操作的條件謂詞,出口協議則包括,檢查該操作修改的所有狀態變數,並確認它們是否使某個其他的條件謂詞變為真,如果是,則通知相關的條件佇列。

在AbstractQueuedSynchronizer中使用出口協議。這個類並不是由同步器類執行自己的通知,而是要求同步器方法返回一個值來表示該類的操作是否已經解除了一個或多個等待執行緒的阻塞。這種明確的API呼叫需求使得更難以“忘記”在某些狀態轉換髮生時進行通知。

14.3 顯式的Condition物件

Condition是一種廣義的內建條件佇列。

內建條件佇列存在一些缺陷,每個內建鎖都只能有一個相關聯的條件佇列,所以多個執行緒可能在同一個條件佇列上等待不同的條件謂詞,並且在最常見的加鎖模式下公開條件佇列物件。如果想編寫一個帶有多個條件謂詞的併發物件,或者想獲得除了條件佇列可見性之外的更多控制權,就可以使用顯式的Lock和Condition而不是內建鎖和條件佇列,這是一種更靈活的選擇。

一個Condition和一個Lock關聯在一起,就像一個條件佇列和一個內建鎖相關聯一樣。要建立一個Condition,可以在相關聯的Lock上呼叫Lock.newCondition方法。

Condition比內建條件佇列提供了更豐富的功能:在每個鎖上可存在多個等待、條件等待可以使可中斷或不可中斷的、基於時限的等待、公平的或非公平的佇列操作。

對於每個Lock可以有任意數量的Condition物件。Condition物件繼承了相關的Lock物件的公平性,對於公平的鎖,執行緒會依照FIFO順序從Condition.await中釋放。

在Condition物件中,與wait、notify、notifyAll對應的是await、signal和signalAll,但Condition作為Object物件同樣具有wait和notify方法,注意要正確的使用await和signal。

使用顯式的Lock和Condition時,也必須滿足鎖、條件謂詞和條件變數之間的三元關係。在條件謂詞中包含的變數必須由Lock來保護,並且在檢查條件謂詞以及呼叫wait和signal時,必須持有Lock物件。

如果需要使用一些高階功能,優先選擇Condition而不是內建條件佇列。

14.4 Synchronizer解析

AQS AbstractQueuedSynchronizer,是許多同步類的基類。是一個用於構建鎖和同步器的框架。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWritLock、SynchronousQueue、FutureTask都是它的子類。

在基於AQS構建的同步器中,只可能在一個時刻發生阻塞,從而降低上下文切換的開銷,提高吞吐量。

14.5 AbstractQueuedSynchronizer

在基於AQS構建的同步器類中,最基本的操作包括各種形式的獲取操作和釋放操作。獲取操作是一種依賴狀態的操作,並且通常會阻塞。釋放 並不是一個可阻塞的操作,當執行釋放操作時,所有在請求時被阻塞的執行緒都會開始執行。

如果一個類想成為狀態依賴的類,那麼它必須擁有一個狀態。AQS負責管理同步器類中的狀態,它管理了一個整數狀態資訊,可以通過getState,setState以及compareAndSetState等protected型別方法來進行操作。這個整數可以用於表示任意狀態。

根據同步器的不同,獲取操作可以使一種獨佔操作(如ReentrantLock),也可以是一個非獨佔操作(如Semaphore和CountDownLatch)。一個獲取操作包括兩部分。首先,同步器判斷當前狀態是否允許獲得操作,如果是則允許執行緒執行,否則獲取操作將阻塞或失敗。其次,就是更新同步器的狀態,獲取同步器的某個縣城可能會對其他執行緒能否也獲取該同步器造成影響。

如果某個同步器支援獨佔的獲取操作,那麼需要實現一些保護方法,包括tryAcquire、tryRelease和isHeldExclusively等。對於支援共享獲取的同步器,應該實現tryAcquireShared、tryReleaseShared等。AQS中的acquire、acuireShared、release和releaseShared等方法都將呼叫這些方法在子類中帶有字首的try的版本來判斷某個操作是否能執行。在同步器的子類中,可以根據其獲取操作和釋放操作的語義,使用getState、setState以及compareAndSetState來檢查和更新狀態,並通過返回的狀態值來告知基類獲取或釋放同步器的操作是否成功。

為了使支援條件佇列的鎖(如ReentrantLock)實現起來更簡單,AQS還提供了一些機制來構造與同步器相關聯的條件變數。

java.util.concurrent中的所有同步器類都沒有直接擴充套件AQS,而是都將它們的相應功能委託給私有的AQS子類來實現。

14.6 java.util.concurrent同步器類中的AQS。

14.6.1 ReentrantLock

ReentrantLock只支援獨佔方式的獲取操作,因此它實現了tryAcquire、tryRelease和isHeldExclusively。ReentrantLock將同步狀態用於儲存鎖獲取操作的次數,並且還維護一個owner變數來儲存當前所有者執行緒的識別符號,只有在當前執行緒剛剛獲取到鎖,或者正要釋放鎖的時候,才會修改這個變數。在tryRelease中檢查owner域,從而確保當前執行緒在執行unlock操作之前已經獲取了鎖:在tryAcquire中將使用這個域來區分獲取操作是重入的還是競爭的。

14.6.2Semaphore與CountDownLatch

Semaphore將AQS的同步狀態用於儲存當前可用許可的數量。tryAcquireShared方法首先計算剩餘許可的雙良,如果沒有足夠的許可,會返回一個值表示獲取操作失敗。如果還有剩餘的許可,那麼tryAcquireShared會通過compareAndSetState以原子方式來降低許可的計數。如果這個操作成功,那麼將返回一個值表示獲取操作成功。在返回值中還包含了表示其他共享獲取操作能否成功的資訊,如果成功,那麼其他等待的執行緒同樣會接觸阻塞。

當沒有足夠的許可,或者當tryAcquireShared可以通過原子方式來更新許可的計數以響應獲取操作時,while迴圈將終止。雖然對compareAndSetState的呼叫可能由於與另一個執行緒發生競爭而失敗,並使其重新嘗試,但在經過了一定次數的充實操作以後,在這兩個結束條件中有一個會變為真。同樣,tryReleaseShared將增加許可計數,這可能會解除等待中執行緒的阻塞狀態,並且不斷地重試知道更新操作成功。tryReleaseShared的返回值表示在這次釋放操作中解除了其他執行緒的阻塞。

CountDownLatch使用AQS的方式與Semaphore很相似:在同步狀態中儲存的是當前的技術值。countDown方法呼叫release,從而導致計數值遞減,並且當計數值為零時,解除所有等待執行緒的阻塞。await呼叫acquire,當計數器為零時,acquire將立即返回,否則將阻塞。

14.6.3 FutureTask

在FutureTask中,AQS同步狀態被用來儲存任務的狀態。FutureTask還維護一些額外的狀態變數,用來儲存計算結果或者爆出的異常。此外,它還維護了一個引用,指向正在執行計算任務的程序,因而如果任務取消,該執行緒就會中斷。

ReentrantReadWritLock

ReadWritLock介面表示存在兩個鎖:讀鎖和寫鎖,但在基於AQS實現的ReentrantReadWritLock中,單個AQS子類同時管理讀鎖和寫鎖。ReentrantReadWritLock使用了一個16位的狀態來表示寫入鎖的技術,並且使用了另一個16位的狀態來表示讀取鎖的計數。在讀取所上的操作將使用共享的獲取方法與釋放方法,在寫如梭上的操作將使用獨佔的獲取方法與釋放方法。

AQS在內部維護一個等待執行緒佇列,其中記錄了某個執行緒請求的是獨佔訪問還是共享訪問。在ReentrantReadWritLock中,當鎖可用時,如果位於佇列頭部的執行緒執行寫入操作,那麼執行緒會得到這個鎖,如果位域佇列同步的執行緒執行讀取方位,那麼佇列中在第一個寫入執行緒之前的所有執行緒都將獲得這個鎖。