1. 程式人生 > >你所不知道的有關Java 和Scala中的同步問題

你所不知道的有關Java 和Scala中的同步問題

在實際應用中所有的服務端程式都需要在多執行緒之間進行某種同步。大多數同步已經有框架完成了,比如我們的web伺服器,DB客戶端和訊息框架。Java和Scala提供了大量的元件用來實現穩定的多執行緒程式。包括物件池,併發集合,高階鎖,執行上下文等。

為了更好的理解這些元件,我們深入瞭解一下最常用的同步原語——物件所。這個是用synchronized 關鍵字來實現的,在Java中它是非常流行的多執行緒原語。這也是其他更復雜模式的基礎,比如執行緒池和連線池,併發集合等。

Synchronized 關鍵字主要用在以下兩個場景:

  1. 作為方法的修飾,此方法在同一個時間只能被一個執行緒執行。
  2. 把一個程式碼塊宣告為臨界區,任何時間只有一個執行緒能訪問。

鎖指令

同步程式碼塊有兩個專門的位元組碼指令,MonitorEnter 和 MoniterExit。這個不同於其它鎖機制,比如java.util.concurrent包是有Java程式碼和本地呼叫來是實現的。

這些作用在物件上的指令是有開發人員在同步塊中顯示說明的。對於同步方法,鎖被加到了“this”物件上。對於靜態方法,鎖被加到了類物件上。

同步方法有時候會引起壞的結果。其實一個例子是在不同的同步方法間會引起隱式依賴,因為它們共享同一把鎖 。更壞的場景是在基類裡(可能是一個第三方庫)申明瞭一個同步方法,在子類裡又增加了新的同步方法。這造成了不同層次之間的隱式同步依賴,會降低吞吐率甚至是死迴圈。這時應該使用私有的鎖物件來防止偶然的共享,或者不使用鎖。

編譯器和同步

有兩個位元組碼的指令用來實現同步原語。這並不常見,因為大多數字節碼指令是互相獨立的,通常通過把值放線上程的運算元堆疊上來互相“通訊”。要加鎖的物件也是從運算元堆疊裝載的,通過引用變數或者方法返回物件把他們放在操作堆疊上。

如果只有其中一個指令被呼叫了,而另外一個沒被呼叫,會發生什麼?Java編譯器不會產生只調用MonitorExit而沒有呼叫MonitorEnter的程式碼。即使Java編譯產生了這樣的程式碼 ,在JVM看來這個程式碼也是非法的。這會讓MonitorExit指令丟擲一個IllegalMonitorStateException 異常。

一個更危險的例子是MonitorEnter加了鎖,但卻沒有被對應的MonitorExit釋放。這種情況下執行緒一直擁有鎖,從而導致其他想獲取這把鎖的執行緒一直被阻塞。

為了防止發生一直被阻塞,Java編譯器會以以下方式產生程式碼:一旦進入同步塊或者方法,一定能執行到MonitorExit。在臨界區丟擲異常可以導致這個問題。

22

編譯器使用的機制非常簡單,當異常發生時如果沒有經過MonitorExit,就增加catch語句來釋放鎖。

另一個問題是在enter和exit之間的鎖物件儲存在何處。注意多個執行緒可以同時執行同步塊,使用不同的鎖物件。如果鎖物件是方法呼叫的結果,那麼JVM極有可能會再次執行它,因為它可能會改變物件狀態,或者不會返回同一個物件。如果是同一個物件,那麼在monitor執行前這個變數和域已經被改變。

監視變數。為了計算,編譯器為方法增加了一個隱式的變數,用來儲存鎖狀態。這個是一個聰明的解決方案,因為它只增加了很小的開銷就維護了鎖物件,而不是使用併發棧把鎖物件隱射到執行緒(這個結構需要同步)。我是在編譯Takipi棧分析演算法時發現這個新變數的。

注意這所有的工作都是有Java的編譯器完成的。JVM可以非常完美處理只調用MonitorEnter來進入臨界區而不用退出(或者相反),或者為方法使用不同的物件。

JVMLevel的鎖

讓我們更深入的看一下鎖在JVM裡是怎麼實現的。為此我們將會檢視HotSpot SE7的實現,因為這個實現每個VM都可能不一樣。 因為加鎖會影響程式碼的吞吐率,JVM加入了很大的優化來提高加鎖解鎖的效率。

其中一個強大機制是執行緒鎖偏向。鎖特性是每個Java物件都具有的,很想系統的hashcode或者定義類的引用。不管類的型別是什麼,這個都成立(甚至你可以使用一個原始陣列作為鎖)。

這些型別的資料都存在物件的頭部(也被稱為物件的標記)。這些資料中的一部分用來描述物件鎖的狀態。這包括描述物件的鎖狀態(加鎖/沒加鎖)的位元標誌位,一個指向現在擁有鎖的執行緒。

為了節省物件頭部的空間,Java的執行緒物件會被分配在VM棧的低位,這可以減少地址長度從而節約物件頭部的位元數(64位的只需要54位,32位的只需要23位)。

64位的位元位分配情況:

1

鎖演算法

當JVM嘗試去獲取一個物件的鎖時,會採用從樂觀到悲觀的步驟來獲取。

當執行緒成為物件鎖的擁有者時,這就算加鎖成功了。執行緒是否把指向自己的引用存入物件的頭部決定著加鎖是否成功。

獲取鎖的步驟。第一步是使用CAS操作來嘗試獲取鎖。這個操作非常高效,因為常常有與之對應的CPU指令(比如 cmpxchg)。 CAS操作和OS執行緒停止程式作為物件的同步原語。

如果鎖是空閒或者說這個所可以被這個執行緒優先獲取,那麼執行緒就立即獲取了這個鎖。如果CAS失敗了,那麼JVM先會自旋一輪,然後執行緒會睡眠,直到下次CAS。如果這些最初的嘗試失敗了,執行緒會把自己放入阻塞狀態,並進入競爭鎖的列表,開始一系列的自旋。

釋放鎖。通過執行MonitorExit指令來退出臨界區,鎖的擁有者會嘗試著檢查是否可以喚醒正在等待這把鎖的執行緒 。這個過程被稱為選擇一個繼承者。這可以增加活躍度,阻止出現當鎖釋放後仍有執行緒在等待這把鎖。

除錯服務端多執行緒問題有點難度,因為它們非常依賴除錯時機和OS的特性。這也是讓我們實現TAkipi的原因之一。

延伸閱讀