1. 程式人生 > >打通 Java 任督二脈 —— 併發資料結構的基石

打通 Java 任督二脈 —— 併發資料結構的基石

每一個 Java 的高階程式設計師在體驗過多執行緒程式開發之後,都需要問自己一個問題,Java 內建的鎖是如何實現的?最常用的最簡單的鎖要數 ReentrantLock,使用它加鎖時如果沒有立即加成功,就會阻塞當前的執行緒等待其它執行緒釋放鎖之後再重新嘗試加鎖,那執行緒是如何實現阻塞自己的?其它執行緒釋放鎖之後又是如果喚醒當前執行緒的?當前執行緒是如何得出自己沒有加鎖成功這一結論的?本篇內容將會從根源上回答上面提到的所有問題

執行緒阻塞原語

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


這兩個方法都是 native 方法,它們本身是由 C 語言來實現的核心功能。park 的意思是停車,讓當前執行的執行緒 Thread.currentThread() 休眠,unpark 的意思是解除停車,喚醒指定執行緒。這兩個方法在底層是使用作業系統提供的訊號量機制來實現的。具體實現過程要深究 C 程式碼,這裡暫時不去具體分析。park 方法的兩個引數用來控制休眠多長時間,第一個引數 isAbsolute 表示第二個引數是絕對時間還是相對時間,單位是毫秒。

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

parkBlocker

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



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


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

排隊管理器

當多個執行緒爭用同一把鎖時,必須有排隊機制將那些沒能拿到鎖的執行緒串在一起。當鎖釋放時,鎖管理器就會挑選一個合適的執行緒來佔有這個剛剛釋放的鎖。每一把鎖內部都會有這樣一個佇列管理器,管理器裡面會維護一個等待的執行緒佇列。ReentrantLock 裡面的佇列管理器是 AbstractQueuedSynchronizer,它內部的等待佇列是一個雙向列表結構,列表中的每個節點的結構如下。


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


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


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

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

Douglas S. Lea是紐約州立大學奧斯威戈分校電腦科學教授和現任計算機科學系主任,專門研究併發程式設計和併發資料結構的設計。他是Java Community Process的執行委員會成員,主持JSR 166,它為Java程式語言添加了併發實用程式。


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

公平鎖與非公平鎖

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

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

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

1.其它執行緒 unpark 了當前執行緒

2.時間到了自然醒(park 有時間引數)

3.其它執行緒 interrupt 了當前執行緒

4.其它未知原因導致的「假醒」

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

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



共享鎖與排他鎖

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

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