最強幹貨:Java併發之AQS原理詳解

陝西優就業發表於2019-12-03

每一個 Java 的高階程式設計師在體驗過多執行緒程式開發之後,都需要問自己一個問題,Java 內建的鎖是如何實現的?最常用的最簡單的鎖要數 ReentrantLock,使用它加鎖時如果沒有立即加成功,就會阻塞當前的執行緒等待其它執行緒釋放鎖之後再重新嘗試加鎖,那執行緒是如何實現阻塞自己的?

其它執行緒釋放鎖之後又是如果喚醒當前執行緒的?當前執行緒是如何得出自己沒有加鎖成功這一結論的? 陝西優就業小編今天給大家分享的這篇內容將會從根源上回答上面提到的所有問題。

1、執行緒阻塞原語

Java 的執行緒阻塞和喚醒是通過 Unsafe 類的 park 和 unpark 方法做到的。

這兩個方法都是 native 方法,它們本身是由 C 語言來實現的核心功能。park 的意思是停車,讓當前執行的執行緒 Thread.currentThread() 休眠,unpark 的意思是解除停車,喚醒指定執行緒。

這兩個方法在底層是使用作業系統提供的訊號量機制來實現的。具體實現過程要深究 C 程式碼,這裡暫時不去具體分析。park 方法的兩個引數用來控制休眠多長時間,第一個引數 isAbsolute 表示第二個引數是絕對時間還是相對時間,單位是毫秒。

執行緒從啟動開始就會一直跑,除了作業系統的任務排程策略外,它只有在呼叫 park 的時候才會暫停執行。鎖可以暫停執行緒的奧祕所在正是因為鎖在底層呼叫了 park 方法。

2、parkBlocker

執行緒物件 Thread 裡面有一個重要的屬性 parkBlocker,它儲存當前執行緒因為什麼而 park。就好比停車場上停了很多車,這些車主都是來參加一場拍賣會的,等拍下自己想要的物品後,就把車開走。那麼這裡的 parkBlocker 大約就是指這場「拍賣會」。它是一系列衝突執行緒的管理者協調者,哪個執行緒該休眠該喚醒都是由它來控制的。

當執行緒被 unpark 喚醒後,這個屬性會被置為 null。Unsafe.park 和 unpark 並不會幫我們設定 parkBlocker 屬性,負責管理這個屬性的工具類是 LockSupport,它對 Unsafe 這兩個方法進行了簡單的包裝。

Java 的鎖資料結構正是通過呼叫 LockSupport 來實現休眠與喚醒的。執行緒物件裡面的 parkBlocker 欄位的值就是下面我們要講的「排隊管理器」。

3、排隊管理器

當多個執行緒爭用同一把鎖時,必須有排隊機制將那些沒能拿到鎖的執行緒串在一起。當鎖釋放時,鎖管理器就會挑選一個合適的執行緒來佔有這個剛剛釋放的鎖。

每一把鎖內部都會有這樣一個佇列管理器,管理器裡面會維護一個等待的執行緒佇列。ReentrantLock 裡面的佇列管理器是 AbstractQueuedSynchronizer,它內部的等待佇列是一個雙向列表結構。

加鎖不成功時,當前的執行緒就會把自己納入到等待連結串列的尾部,然後呼叫 LockSupport.park 將自己休眠。其它執行緒解鎖時,會從連結串列的表頭取一個節點,呼叫 LockSupport.unpark 喚醒它。

AbstractQueuedSynchronizer 類是一個抽象類,它是所有的鎖佇列管理器的父類,JDK 中的各種形式的鎖其內部的佇列管理器都繼承了這個類,它是 Java 併發世界的核心基石。

比如 ReentrantLock、ReadWriteLock、CountDownLatch、Semaphore、ThreadPoolExecutor 內部的佇列管理器都是它的子類。這個抽象類暴露了一些抽象方法,每一種鎖都需要對這個管理器進行定製。而 JDK 內建的所有併發資料結構都是在這些鎖的保護下完成的,它是JDK 多執行緒高樓大廈的地基。

鎖管理器維護的只是一個普通的雙向列表形式的佇列,這個資料結構很簡單,但是仔細維護起來卻相當複雜,因為它需要精細考慮多執行緒併發問題,每一行程式碼都寫的無比小心。

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

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

4、公平鎖與非公平鎖

公平鎖會確保請求鎖和獲得鎖的順序,如果在某個點鎖正處於自由狀態,這時有一個執行緒要嘗試加鎖,公平鎖還必須檢視當前有沒有其它執行緒排在排隊,而非公平鎖可以直接插隊。聯想一下在肯德基買漢堡時的排隊場景。

也許你會問,如果某個鎖處於自由狀態,那它怎麼會有排隊的執行緒呢?我們假設此刻持有鎖的執行緒剛剛釋放了鎖,它喚醒了等待佇列中第一個節點執行緒,這時候被喚醒的執行緒剛剛從 park 方法返回,接下來它就會嘗試去加鎖,那麼從 park 返回到加鎖之間的狀態就是鎖的自由態,這很短暫,而這短暫的時間內還可能有其它執行緒也在嘗試加鎖。

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

①其它執行緒 unpark 了當前執行緒;

②時間到了自然醒(park 有時間引數);

③其它執行緒 interrupt 了當前執行緒;

④其它未知原因導致的「假醒」;

文件中沒有明確說明何種未知原因會導致假醒,它倒是說明了當 park 方法返回時並不意味著鎖自由了,醒過來的執行緒在重新嘗試獲取鎖失敗後將會再次 park 自己。所以加鎖的過程需要寫在一個迴圈裡,在成功拿到鎖之前可能會進行多次嘗試。

計算機世界非公平鎖的服務效率要高於公平鎖,所以 Java 預設的鎖都使用了非公平鎖。不過現實世界似乎非公平鎖的效率會差一點,比如在肯德基如果可以不停插隊,你可以想象現場肯定一片混亂。為什麼計算機世界和現實世界會有差異,大概是因為在計算機世界裡某個執行緒插隊並不會導致其它執行緒抱怨。

5、共享鎖與排他鎖

ReentrantLock 的鎖是排他鎖,一個執行緒持有,其它執行緒都必須等待。而 ReadWriteLock 裡面的讀鎖不是排他鎖,它允許多執行緒同時持有讀鎖,這是共享鎖。共享鎖和排他鎖是通過 Node 類裡面的 nextWaiter 欄位區分的。

那為什麼這個欄位沒有命名成 mode 或者 type 或者乾脆直接叫 shared?這是因為 nextWaiter 在其它場景還有不一樣的用途,它就像 C 語言聯合型別的欄位一樣隨機應變,只不過 Java 語言沒有聯合型別。

6、條件變數

關於條件變數,需要提出的第一個問題是為什麼需要條件變數,只有鎖還不夠麼?考慮下面的虛擬碼,當某個條件滿足時,才去幹某件事

當條件不滿足時,就迴圈重試(其它執行緒會通過加鎖來修改條件),但是需要間隔 sleep,不然 CPU 就會因為空轉而飆高。這裡存在一個問題,那就是 sleep 多久不好控制。間隔太久,會拖慢整體效率,甚至會錯過時機(條件瞬間滿足了又立即被重置了),間隔太短,又回導致 CPU 空轉。有了條件變數,這個問題就可以解決了

await() 方法會一直阻塞在 cond 條件變數上直到被另外一個執行緒呼叫了 cond.signal() 或者 cond.signalAll() 方法後才會返回,await() 阻塞時會自動釋放當前執行緒持有的鎖,await() 被喚醒後會再次嘗試持有鎖(可能又需要排隊),拿到鎖成功之後 await() 方法才能成功返回。

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

await() 方法必須立即釋放鎖,否則臨界區狀態就不能被其它執行緒修改,condition_is_true() 返回的結果也就不會改變。 這也是為什麼條件變數必須由鎖物件來建立,條件變數需要持有鎖物件的引用這樣才可以釋放鎖以及被 signal 喚醒後重新加鎖。

建立條件變數的鎖必須是排他鎖,如果是共享鎖被 await() 方法釋放了並不能保證臨界區的狀態可以被其它執行緒來修改,可以修改臨界區狀態的只能是排他鎖。

有了條件變數,sleep 不好控制的問題就解決了。當條件滿足時,呼叫 signal() 或者 signalAll() 方法,阻塞的執行緒可以立即被喚醒,幾乎沒有任何延遲。

7、條件等待佇列

當多個執行緒 await() 在同一個條件變數上時,會形成一個條件等待佇列。同一個鎖可以建立多個條件變數,就會存在多個條件等待佇列。這個佇列和 AQS 的佇列結構很接近,只不過它不是雙向佇列,而是單向佇列。佇列中的節點和 AQS 等待佇列的節點是同一個類,但是節點指標不是 prev 和 next,而是 nextWaiter。

ConditionObject 是 AQS 的內部類,這個物件裡會有一個隱藏的指標 this$0 指向外部的 AQS 物件,ConditionObject 可以直接訪問 AQS 物件的所有屬性和方法(加鎖解鎖)。位於條件等待佇列裡的所有節點的 waitStatus 狀態都被標記為 CONDITION,表示節點是因為條件變數而等待。

8、佇列轉移

當條件變數的 signal() 方法被呼叫時,條件等待佇列的頭節點執行緒會被喚醒,該節點從條件等待佇列中被摘走,然後被轉移到 AQS 的等待佇列中,準備排隊嘗試重新獲取鎖。這時節點的狀態從 CONDITION 轉為 SIGNAL,表示當前節點是被條件變數喚醒轉移過來的。

被轉移的節點的 nextWaiter 欄位的含義也發生了變更,在條件佇列裡它是下一個節點的指標,在 AQS 等待佇列裡它是共享鎖還是互斥鎖的標誌。

9、讀寫鎖

讀寫鎖分為兩個鎖物件 ReadLock 和 WriteLock,這兩個鎖物件共享同一個 AQS。AQS 的鎖計數變數 state 將分為兩個部分,前 16bit 為共享鎖 ReadLock 計數,後 16bit 為互斥鎖 WriteLock 計數。互斥鎖記錄的是當前寫鎖重入的次數,共享鎖記錄的是所有當前持有共享讀鎖的執行緒重入總次數。

讀寫鎖同樣也需要考慮公平鎖和非公平鎖。共享鎖和互斥鎖的公平鎖策略和 ReentrantLock 一樣,就是看看當前還有沒有其它執行緒在排隊,自己會乖乖排到隊尾。非公平鎖策略不一樣,它會比較偏向於給寫鎖提供更多的機會。

如果當前 AQS 佇列裡有任何讀寫請求的執行緒在排隊,那麼寫鎖可以直接去爭搶,但是如果隊頭是寫鎖請求,那麼讀鎖需要將機會讓給寫鎖,去隊尾排隊。 畢竟讀寫鎖適合讀多寫少的場合,對於偶爾出現一個寫鎖請求就應該得到更高的優先順序去處理。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69902581/viewspace-2666640/,如需轉載,請註明出處,否則將追究法律責任。

相關文章