天天看點

關于Java和Scala同步的五件事你不知道

實際上,所有伺服器應用程式都需要在多個線程之間進行某種同步。 大多數同步工作是在架構級别為我們完成的,例如通過我們的Web伺服器,資料庫用戶端或消息傳遞架構。 Java和Scala提供了許多元件來編寫可靠的多線程應用程式。 這些包括對象池,并發集合,進階鎖,執行上下文等。

關于Java和Scala同步的五件事你不知道

為了更好地了解它們,讓我們探索最同步的習慣用法-Object lock 。 這種機制為synced關鍵字提供了動力,使其成為Java中最流行的多線程習慣用法之一(如果不是)。 它也是我們使用的許多更複雜模式的基礎,例如線程和連接配接池,并發集合等等。

synced關鍵字在兩個主要上下文中使用:

  1. 作為方法修飾符,用于标記一種方法,該方法一次隻能由一個線程執行。
  2. 通過将代碼塊聲明為關鍵部分 –在任何給定時間點僅一個線程可以使用一個代碼塊。

鎖定說明

事實1 。 同步代碼塊使用兩個專用位元組碼指令實作,這是官方規範的一部分-MonitorEnter和MonitorExit 。 這與其他鎖定機制不同,例如在java.util.concurrent包中找到的那些鎖定機制,這些鎖定機制是結合Java代碼和通過sun.misc.Unsafe進行的本機調用實作的(對于HotSpot而言)。

這些指令對開發人員在同步塊的上下文中明确指定的對象進行操作。 對于同步方法,鎖定将自動選擇為“ this ”變量。 對于靜态方法,鎖将放置在Class對象上。

同步方法有時會導緻不良行為 。 一個示例是在相同對象的不同同步方法之間建立隐式依賴關系,因為它們共享相同的鎖。 更糟糕的情況是在基類(甚至可能是第三方類)中聲明同步方法,然後将新的同步方法添加到派生類。 這會在整個層次結構中建立隐式同步依賴關系,并有可能導緻吞吐量問題甚至死鎖。 為避免這些情況,建議使用私有對象作為鎖,以防止意外共享或逃脫鎖。

編譯器和同步

有兩個位元組碼指令負責同步。 這是不尋常的,因為大多數位元組碼指令彼此獨立,通常通過将值放線上程的操作數堆棧上來彼此“通信”。 還可以從操作數堆棧中加載要鎖定的對象,該操作數堆棧先前是通過取消引用變量,字段或調用傳回對象的方法來放置的。

事實2。 那麼,如果在沒有分别調用另一條指令的情況下調用了兩條指令之一,會發生什麼呢? 如果不調用MonitorEnter,Java編譯器将不會生成調用MonitorExit的代碼。 即使這樣,從JVM的角度來看,這樣的代碼也是完全有效的。 這種情況的結果是MonitorExit指令抛出IllegalMonitorStateException。

如果通過MonitorEnter獲得鎖但沒有通過對MonitorExit的相應調用釋放鎖,将會發生更危險的情況 。 在這種情況下,擁有該鎖的線程可能導緻其他試圖擷取該鎖的線程無限期地阻塞。 值得注意的是,由于鎖是可重入的,是以擁有該鎖的線程可能會繼續愉快地執行,即使它再次到達并重新輸入相同的鎖也是如此。

這就是陷阱。 為了防止這種情況的發生,Java編譯器以這種方式生成比對的輸入和退出指令,即一旦執行已進入同步塊或方法,它就必須通過比對的MonitorExit指令來處理同一對象。 可能會引起麻煩的一件事是,如果關鍵部分抛出異常。

public void hello() {
  synchronized (this) {
    System.out.println("Hi!, I'm alone here");
  }
}
           

讓我們分析一下位元組碼–

aload_0 //load this into the operand stack
dup //load it again
astore_1 //backup this into an implicit variable stored at register 1
monitorenter //pop the value of this from the stack to enter the monitor
 
//the actual critical section
getstatic java/lang/System/out Ljava/io/PrintStream;
ldc "Hi!, I'm alone here"
invokevirtual java/io/PrintStream/println(Ljava/lang/String;)V
 
aload_1 //load the backup of this
monitorexit //pop up the var and exit the monitor
goto 14 // completed - jump to the end
 
// the added catch clause - we got here if an exception was thrown -
aload_1 // load the backup var.
monitorexit //exit the monitor
athrow // rethrow the exception object, loaded into the operand stack
return
           

編譯器用來防止棧不展開而無需通過MonitorExit指令的機制非常簡單–編譯器添加了一個隐式的try…catch子句以釋放鎖并重新抛出異常。

事實三 。 另一個問題是在相應的enter和exit調用之間存儲的對鎖定對象的引用在哪裡。 請記住,多個線程可能會使用不同的鎖定對象同時執行同一同步塊。 如果鎖定的對象是調用方法的結果,則JVM極不可能再次執行它,因為它可能會更改對象的狀态,甚至可能不會傳回相同的對象。 對于自輸入螢幕以來可能已更改的變量或字段,情況也是如此。

監視變量 。 為了解決這個問題,編譯器将一個隐式局部變量添加到方法中,以儲存鎖定對象的值。 這是一個聰明的解決方案,因為與使用并發堆結構将鎖定對象映射到線程(該結構本身可能需要同步)相比,該方法在維護對鎖定對象的引用上的開銷非常小。 在建構Takipi的堆棧分析算法時,我首先觀察到了這個新變量,并發現代碼中彈出了意外變量。

注意,所有這些工作都是在Java編譯器級别完成的。 JVM非常樂意通過MonitorEnter指令進入關鍵部分而不退出(反之亦然),或将不同的對象用作對應的enter和exit方法。

鎖定在JVM級别

現在讓我們更深入地研究如何在JVM級别上實際實作鎖。 為此,我們将研究HotSpot SE 7的實作,因為它是VM特定的。 由于鎖定可能會對代碼吞吐量産生一些不利影響,是以JVM進行了一些非常強大的優化,以使擷取和釋放鎖定的效率盡可能高。

事實#4。 JVM所采用的最強大的機制之一是線程鎖偏置 。 鎖定是每個Java對象都具有的一種固有功能,就像具有系統哈希碼或對其定義類的引用一樣。 無論對象的類型如何,都是如此(如果需要,您甚至可以使用基本數組作為鎖)。

這些類型的資料存儲在每個對象的标頭(也稱為對象的标記)中。 保留在對象标題中的某些資料保留用于描述對象的鎖定狀态。 這包括描述對象的鎖定狀态(即鎖定/解鎖)的位标志,以及對目前擁有該鎖的線程的引用-指向該對象的線程有偏。

為了節省對象标頭中的空間,為了減少位址大小并節省每個對象标頭中的位(64位和32位JVM為54位或23位),在VM堆的較低段中配置設定了Java線程對象。分别)。

對于64位–

關于Java和Scala同步的五件事你不知道

鎖定算法

當JVM嘗試擷取對象的鎖時,它會經曆從樂觀到悲觀的一系列步驟。

事實五。 如果線程成功将其自身确立為對象鎖的所有者,則該線程将擷取該鎖。 這取決于線程是否能夠在對象的頭中安裝對自身的引用(指向内部JavaThread對象的指針)。

擷取鎖。 使用簡單的比較交換(CAS)操作即可完成此操作。 這非常有效,因為它通常可以轉換為直接CPU指令(例如cmpxchg)。 CAS操作與OS特定的線程駐留例程一起用作對象同步習慣用法的建構塊。

如果該鎖是免費的,或者先前已對該線程進行了預緊,則該線程将獲得對象的鎖,并且可以立即繼續執行。 如果CAS失敗,則JVM将執行一輪自旋鎖定,在該循環中線程停放以有效地使其在重試CAS之間進入睡眠狀态。 如果這些初始嘗試失敗(向鎖發出更進階别的争用信号),線程将自身進入阻塞狀态,并将自己排入争用該鎖的線程清單,并開始一系列自旋鎖。

在每輪旋轉之後,線程将檢查JVM全局狀态的變化,例如“停止世界” GC的出現,在這種情況下,線程将需要暫停自身直到GC完成以防止出現這種情況。在執行STW GC時獲得鎖并繼續執行的位置。

釋放鎖。 當通過MonitorExit指令退出關鍵部分時,所有者線程将嘗試檢視它是否可以喚醒任何正在等待釋放鎖的駐留線程。 此過程稱為選擇“繼承人”。 這是為了增加活動性,并防止在釋放鎖的情況下仍保持線程駐留(也稱為絞合)的情況。

調試伺服器多線程問題很困難,因為它們往往取決于非常特定的時間安排和作業系統啟發。 這就是讓我們首先緻力于Takipi的原因之一。

參考:

我們的JCG合作夥伴 Tal Weiss在Takipi部落格上對Java和Scala中的同步不了解的5件事 。

翻譯自: https://www.javacodegeeks.com/2013/08/5-things-you-didnt-know-about-synchronization-in-java-and-scala.html