1. 程式人生 > >Java併發資料結構的基礎

Java併發資料結構的基礎

Java的併發能力的基礎是Park()和unPark()方法、易失性變數、同步化、CAS操作和AQS佇列。進入這些知識點並不容易。本節中提到的與鎖相關的知識並不特別完整,還有許多細節我還沒有完全理解,因此讓我們稍後討論關於鎖的更多細節。

執行緒阻塞原語

Java的執行緒阻塞和喚醒是通過不安全的類公園和不停機方法實現的。

這些方法都是本機方法,是C語言實現的核心功能。Park意味著停車,它允許當前執行的執行緒執行緒。currentThread().。unpark意味著解除安裝停車位並喚醒指定的執行緒。在底部,這兩個方法是使用作業系統提供的訊號量機制實現的。具體的實現過程應該深入研究C程式碼,這裡暫時不去具體分析。Park方法的兩個引數用於控制睡眠持續時間。第一個引數isAbsolute指示第二個引數是以毫秒為單位的絕對時間還是相對時間。

因為讀取鎖需要使用CAS操作來修改底層鎖的總讀取計數值,所以可以獲得成功的讀取鎖。CAS操作獲取讀鎖的失敗僅僅意味著CAS操作在讀鎖之間存在競爭,並不意味著鎖被其他人佔用,此時無法獲得。再嘗試幾次肯定會成功鎖定,這就是自旋發生的原因。類似地,當釋放讀鎖時,存在CAS操作的迴圈重試過程。

執行緒從啟動開始一直執行,除了作業系統的任務排程策略,該策略僅在呼叫park時暫停。鎖可以暫停執行緒的祕密正是因為鎖呼叫底部的park方法。

protected final boolean tryReleaseShared(int unused) {
   ...
   for (;;) {
       int c = getState();
       int nextc = c - SHARED_UNIT;
       if (compareAndSetState(c, nextc)) {
         return nextc == 0;
       }
   }
   ...
}

parkBlocker

當執行緒被unpark喚醒時,此屬性設定為空。不安全的。Park和unpark不能幫助我們設定parkBlocker屬性。負責管理此屬性的工具類是Lock.,它簡單地包裝Unsafe兩個方法。

執行緒物件有一個重要的屬性parkBlocker,它儲存當前執行緒停靠的用途。這就像在停車場裡停很多車。這些業主來參加拍賣會,當他們拍下他們想要的照片時,就會開車離開。所以parkBlocker指的是拍賣。它是一系列衝突執行緒的管理員協調器,它控制哪些執行緒應該休眠和喚醒。

Java的鎖資料結構通過呼叫鎖支援來實現休眠和喚醒。執行緒物件中的parkBlocker欄位的值是我們將在下面討論的佇列管理器。

排隊管理器

當鎖不成功時,當前執行緒將自己放在等待列表的末尾,然後呼叫Lock.。停車睡覺。當其他執行緒解鎖時,它們從列表頭獲取節點並呼叫Lock.。開啟方舟叫醒它。

當多個執行緒競爭同一個鎖時,必須有排隊機制來串聯無法將鎖組合在一起的執行緒。當釋放鎖時,鎖管理器選擇合適的執行緒來佔用新釋放的鎖。每個鎖都有一個佇列管理器,在其中維護等待的執行緒佇列。ReentrantLock中的佇列管理器是AbstractQueued Synchronizer。ReentrantLock中的等待佇列是雙向列表結構。列表中每個節點的結構如下。

圖片

JDK 鎖管理器的實現者是 Douglas S. Lea,Java 併發包幾乎全是他單槍匹馬寫出來的,在演算法的世界裡越是精巧的東西越是適合一個人來做。

圖片

鎖管理器僅以通用雙向列表的形式維護佇列。資料結構很簡單,但是仔細維護是相當複雜的,因為它需要仔細考慮多執行緒併發性,並且每行程式碼都非常小心地編寫。

AbstractQueuedSynchronizer 類是一個抽象類,它是所有鎖佇列管理器的父類,由JDK中各種形式的鎖佇列管理器繼承。它是Java併發世界的核心基石。比如 ReentrantLock、ReadWriteLock、CountDownLatch、Semaphone、ThreadPoolExecutor 內部的佇列管理器都是它的子類。這個抽象類公開了一些抽象方法,每個鎖都需要定製給管理器。而 JDK 內建的所有併發資料結構都是在這些鎖的保護下完成的,它是JDK 多執行緒高樓大廈的地基。

後面我們將 AbstractQueuedSynchronizer 簡寫成 AQS。我必須提醒各位讀者,AQS 太複雜了,如果在理解它的路上遇到了挫折,這很正常。目前市場上並不存在一本可以輕鬆理解 AQS 的書籍,能夠吃透 AQS 的人太少太少,我自己也不算。

公平鎖與非公平鎖

也許你會問,如果鎖處於空閒狀態,那麼它如何能夠具有排隊執行緒?假設當前持有鎖的執行緒剛剛釋放了鎖,並且它喚醒了等待佇列中的第一個節點執行緒。此時,喚醒的執行緒只是從park方法返回,然後嘗試鎖定。返回鎖和鎖之間的狀態是鎖的空閒狀態,非常短,可能是其他執行緒也在短時間內試圖新增。

公平的鎖確保請求和獲取的順序。如果在某個點上鎖處於空閒狀態,則執行緒將嘗試鎖定。公平鎖還必須檢查其他執行緒當前是否排隊,但不能直接排隊。想象一下,在肯德基排隊買漢堡包。

其次還有一點需要注意,執行了 Lock.park 方法的執行緒自我休眠後,並不是非要等到其它執行緒 unpark 了自己才會醒來,它可能隨時會以某種未知的原因醒來。我們看原始碼註釋,park 返回的原因有四種

  1. 其它執行緒 unpark 了當前執行緒
  2. 時間到了自然醒(park 有時間引數)
  3. 其它執行緒 interrupt 了當前執行緒
  4. 其它未知原因導致的「假醒」

文件沒有指定未知引起錯誤喚醒的原因,而是顯示當park方法返回時,並不意味著鎖是空閒的。在嘗試檢索鎖失敗後,被喚醒的執行緒將再次停駐自身。因此,鎖定過程需要在迴圈中寫入,並且可以在成功獲得鎖定之前進行多次嘗試。

在計算機世界中,不公平的鎖比公平的鎖更有效,因此Java預設鎖使用不公平的鎖。但在現實世界中,不公平的鎖似乎效率較低。例如,如果你能在肯德基一直排隊,你可以想象場景會很混亂。為什麼計算機世界和現實世界有區別?可能是因為在計算機世界中,執行緒佇列不會導致其他執行緒抱怨。

共享鎖與排他鎖

ReadWriteLock中的讀鎖不是獨佔鎖。它允許多個執行緒同時持有讀鎖。這是一個共享鎖。Node類中的nextWaiter欄位區分共享鎖和獨佔鎖。ReentrantLock的鎖是獨佔鎖,一個執行緒持有它們,所有其他執行緒必須等待。

那麼為什麼這個欄位沒有被命名為模型或型別或共享呢?這是因為NextWaiter在其他場景中具有不同的用途。它作為C語言聯合型別的欄位,但Java語言沒有聯合型別。

條件變數

至於條件變數,需要提出的第一個問題是為什麼需要條件變數。鎖不夠?考慮以下虛擬碼在滿足條件時執行某些操作

 void doSomething() {
   locker.lock();
   while(!condition_is_true()) {  // 先看能不能搞事
     locker.unlock();  // 搞不了就歇會再看看能不能搞
     sleep(1);
     locker.lock(); // 搞事需要加鎖,判斷能不能搞事也需要加鎖
   }
   justdoit();  // 搞事
   locker.unlock();
 }

當條件不滿足時,它將在迴圈中重試(其他執行緒將通過鎖定來修改條件),但是需要間隔休眠,否則由於空閒,CPU將急劇上升。這裡有個問題,那就是睡眠時間不能控制。如果間隔太長,則會降低整體效率,甚至錯過機會(條件立即得到滿足並立即復位)。如果間隔太短,將導致CPU再次空閒。利用條件變數,這個問題可以得到解決。

waiit()方法將阻塞cond條件變數,直到被另一個執行緒cond呼叫。訊號()或cond..All()方法。當waiit()塊時,當前執行緒持有的鎖將自動釋放。當await()被喚醒時,它將再次嘗試保持鎖(並且可能需要排隊),並且await()方法在鎖成功之前不會返回。

 

圖片

 

waiit()方法必須立即釋放鎖,否則其他執行緒不能修改臨界狀態,._is_true()返回的結果也不會改變。這就是為什麼條件變數必須由鎖物件建立,鎖物件需要儲存對鎖物件的引用,以便在訊號喚醒後釋放和重新鎖定鎖。建立條件變數的鎖必須是獨佔鎖。如果通過await()方法釋放共享鎖,則不能保證關鍵區域的狀態可以由其他執行緒修改。唯一可以修改關鍵區域狀態的是獨佔鎖。這就是為什麼ReadWriteLock的新條件方法。ReadLock類的定義如下

阻塞在條件變數上的執行緒可以有多個,這些阻塞執行緒會被串聯成一個條件等待佇列。當 signalAll() 被呼叫時,會喚醒所有的阻塞執行緒,讓所有的阻塞執行緒重新開始爭搶鎖。如果呼叫的是 signal() 只會喚醒佇列頭部的執行緒,這樣可以避免「驚群問題」。

利用條件變數,解決了睡眠不易控制的問題。當滿足條件時,將呼叫.()或.All()方法,並且可以立即喚醒阻塞的執行緒,幾乎沒有延遲。

ReentrantLock 加鎖過程

下面我們精細分析加鎖過程,深入理解鎖邏輯控制。我必須肯定 Dough Lea 的程式碼寫成下面這樣的極簡形式,閱讀起來還是挺難以理解的。

如果判決書的取得分為三部分。tryAcquire方法指示當前執行緒試圖鎖定。如果鎖不成功,則需要排隊。此時,將呼叫addWaiter方法來對當前執行緒進行排隊。然後呼叫.dQueued方法啟動停車、喚醒和重試鎖的過程。如果鎖失敗,Park的迴圈將重試鎖。獲取方法在鎖成功之前不會返回。

如果在迴圈重試鎖定期間被其他執行緒中斷,則獲取的Queued方法返回true。此時,執行緒需要呼叫selfInter.()方法來設定當前執行緒的中斷識別符號位。