[java多執行緒]關於Java和Scala同步機制你不知道的5個真相
實際上,所有的伺服器應用在多執行緒中都需要某種型別的同步機制。框架已經為我們做了大多數的同步的工作,這些框架包括web伺服器、資料庫連線客戶端、訊息框架等。我們可以通過Java和Scala提供的多種元件來寫出可靠的多執行緒應用。這些元件包括,物件池、併發集合類、高階鎖、執行上下文等。
為了更好的理解這個問題,接下來首先要探究最常用的同步模式:物件鎖。這種機制增強了synchronized 關鍵字,使它成為Java程式設計多執行緒的最流行的習慣用法之一。同時,它也是許多常用複雜模式的基礎,包括執行緒池、連線池、併發集合類等。
synchronized 關鍵字主要應用於以下兩種情境:
1. 作為一種改進方法,使被標記的某一方法在同一時間僅僅能被一個執行緒執行。
2. 標記一個程式碼塊為臨界區塊,標記後的程式碼塊在任何時間點僅有一個執行緒可以執行。
鎖介紹
#真相1:同步程式碼塊是通過MonitorEnter和MonitorExit專用位元組碼指令來實現,這兩個位元組碼指令也是官方引數的一部分。這種實現和其他的鎖機制不同,在java.util.concurrent包中的一些方法(在hotspot上的實現)通過結合使用Java程式碼和使用sun.misc.Unsafe的實現本地呼叫。
這些指令執行在使用了sychronized上下文的物件上。對於sychronized方法,“this”變數會自動選擇鎖。對於靜態方法,會將鎖置於Class物件上。
Sychronized方法有時也會帶來比較糟糕的結果,比如當不同的sychronized方法共享一個鎖檔案時,將會造成一些隱性依賴。如果遇到下面這種情況會變得更糟糕:比如在基類(甚至第三方類庫)中聲明瞭sychronized方法,然後又在派生類中增加了一個新的sychronized方法。這將造成整個類的層次結構對同步的隱性依賴,同時也會帶來吞吐量的問題甚至死鎖。為了避免出現這種問題,推薦使用私有物件作為鎖物件,以此來避免意料之外的鎖共享和lock溢位。
編譯器與同步機制的關係
通過兩個位元組碼指令負責完成同步工作是不同尋常的。通常位元組碼指令之間相互獨立的,通過把值放線上程運算元堆疊上完成位元組碼之間的相互“通訊”。被加鎖的物件也預先放在運算元堆疊上,然後通過從變數、欄位中取值或者方法反射的方式來從運算元堆疊上返回一個物件。
#真相2:如果僅僅呼叫兩個位元組碼指令其中之一,而不呼叫另一個會出現什麼情況?僅僅呼叫monitorExit卻不呼叫MonitorEnter的程式碼,Java編譯器不會編譯通過的。即使從JVM角度來看這也是合理的。這種情況的結果就是MonitorExit指令會丟擲一個IllegalMonitorStateException異常。
一種更加危險的情形是:如果一個鎖通過呼叫MonitorEnter被獲取卻不被呼叫MonitorExit被釋放會發生什麼?這種情況下,得到鎖的執行緒將會造成其他需要獲取此鎖的執行緒無限期掛起。這是毫無意義的。因為鎖本身可重入,從某種意義上說,執行緒之間都是平等的。雖然這個執行緒現在快樂地執行著,當這個執行緒下次需要執行這段程式碼的時候,也需要重新獲取這個鎖。(譯註:換句話說,到時候快樂的可是別人了,你在這乾著急吧……)。
解決問題的關鍵就在這裡。為了防止這一切的發生,Java編譯器生成了相互配對的enter和exit指令。使用這種方法,一旦執行了進入synchronized的塊或方法時,它必須傳遞一個相對應的MonitorExit給同一個物件。但是,一旦臨界區域丟擲異常,那麼事情將變得非常糟糕。(譯註:丟擲異常後,程式會退出,monitorExit的位元組碼將不會被呼叫)。
讓我們來分析下位元組碼:
從上面的程式碼分析中可以看出,編譯器解決棧無法釋放monitorExit的方法也非常直接:編譯器會新增一個隱式的try catch宣告來釋放鎖並丟擲異常。
#真相3:另外一個問題是:在進入相應呼叫的enter之後、exit退出之前,被加鎖的物件引用存放在什麼地方。值得注意的是,多執行緒可能並行執行同一個同步塊程式碼,但使用不同的鎖物件。如果鎖物件是一個方法反射後得到的結果,那麼JVM幾乎不大可能會再次執行它,因為這可能會改變物件的狀態,甚至可能返回的不是同一個物件。當一個變數或者欄位在monitor進入之後發生改變時,這種論斷也是正確的。
Monitor變數。
為了克服這個問題,編譯器為方法添加了一個隱性的本地變數來保持鎖物件的這個值。這是一種非常聰明的解決方案。相對於儲存一個鎖物件的引用,這中方法帶來的損耗相對更低,因為沒有使用並行堆結構來為執行緒獲取鎖物件的值(併發結構本身也可能需要加鎖)。我第一次發現這個新變數是在構建Takip堆疊分析演算法時,看到程式碼中pop了一個意料之外的變數。
我們應該意識到,所有的工作都是在Java編譯器級別完成的。JVM非常樂意通過Monitor進入一個臨界區段,但不退出(反之亦然);或者使用不同的物件進入相應的enter和exit的方法。
JVM鎖
接下來我們進一步看看JVM級別,鎖是如何實現的。在這一節中我們將檢視hotspot SE 7的實現。由於鎖機制對程式碼吞吐量有極大的影響,因此JVM做了非常多的優化使得獲取鎖和釋放鎖儘可能高效。
#真相4:JVM最強壯的機制之一就是執行緒的鎖偏移(Locking Biasing)。鎖是每個Java物件都擁有的本質屬性,這就像每個物件都擁有hashcode或者他們類的引用一樣。而且無論物件的型別,上述論斷都是正確的(如果喜歡你甚至可以使用一個原始陣列作為一個鎖)。
這些資料儲存在每個物件的頭部(也稱為類的標記)。有些放在物件頭部的資料段是保留欄位,專門用來儲存物件的鎖狀態。這其中包含著表明物件鎖狀態的位欄位(加鎖/未加鎖)以及當前擁有這個鎖的執行緒:這個物件的偏移就指向這個執行緒。
為了給物件頭部留出空間,Java執行緒物件位於VM堆比較低的位置,這樣就可以壓縮地址的大小,並且節省每個物件的頭部的bit位(JVM32位的對應23bits,64位對應54bits)。
鎖演算法
當JVM試著去獲取一個物件的鎖的時候,它將經歷悲喜兩重天。
#真相5:一旦一個執行緒成功把自己程式設計物件鎖的擁有者,那麼這個執行緒就成功地獲得了鎖。這取決與執行緒物件能否將鎖物件頭部欄位的引用(一個內部Java執行緒物件的指標)指向自己。
獲取鎖。獲取鎖的第一步是使用一個CAS(compare-and-exchange比較並改變)指令。這一步通常非常的高效,因為他一般會被翻譯成一個直接的CPU指令(比如cmpxchg)。CAS操作和作業系統特定執行緒的暫止程式一起為物件同步提供了基礎的功能。
如果鎖當前可用,或者之前已經對這個執行緒進行了偏移,那麼執行緒可以立刻獲取物件上的鎖並且繼續執行執行緒內的程式。如果CAS失敗,JVM會執行一輪自旋鎖定。這時執行緒暫止會將執行緒休眠,直到再次嘗試CAS。如果這些嘗試失敗(意味著更高級別的鎖爭用),執行緒會把自己變為掛起狀態,同時進入一個執行緒爭用佇列開始一系列的自旋鎖定。
釋放鎖。當臨界區通過MonitorExit退出時,擁有鎖的執行緒將會嘗試能否喚醒等待獲取鎖的掛起執行緒。這個過程也被稱作選一個“繼任者”。這能啟用停滯的多個執行緒,同時也能防止出現——鎖已經被釋放但其他執行緒卻仍處在暫止狀態。
除錯伺服器多執行緒問題是非常苦逼、非常困難的,因為它們常常依賴非常極端、非常特殊的時間點以及作業系統演算法。這也是當初我們為Takip工作的原因。
推薦閱讀
1. 如果你有興趣學習JVM鎖是如何實現的,這裡有程式碼和文件。
2. 猛戳這裡可以看到全方位的關於不同Java同步API和技術。
3. Scala併發請戳這兒。(注,此處為連結,請點選”閱讀原文“,並在原文中開啟連結檢視)