Java虛擬機器對內部鎖的四種優化方式

博文視點發表於2017-09-29

自Java 6/Java 7開始,Java虛擬機器對內部鎖的實現進行了一些優化。這些優化主要包括鎖消除(Lock Elision)、鎖粗化(Lock Coarsening)、偏向鎖(Biased Locking)以及適應性鎖(Adaptive Locking)。這些優化僅在Java虛擬機器server模式下起作用(即執行Java程式時我們可能需要在命令列中指定Java虛擬機器引數“-server”以開啟這些優化)。

1 鎖消除

  鎖消除(Lock Elision)是JIT編譯器對內部鎖的具體實現所做的一種優化。


鎖消除(Lock Elision)示意圖

  在動態編譯同步塊的時候,JIT編譯器可以藉助一種被稱為逃逸分析(Escape Analysis)的技術來判斷同步塊所使用的鎖物件是否只能夠被一個執行緒訪問而沒有被髮布到其他執行緒。如果同步塊所使用的鎖物件通過這種分析被證實只能夠被一個執行緒訪問,那麼JIT編譯器在編譯這個同步塊的時候並不生成synchronized所表示的鎖的申請與釋放對應的機器碼,而僅生成原臨界區程式碼對應的機器碼,這就造成了被動態編譯的位元組碼就像是不包含monitorenter(申請鎖)和monitorexit(釋放鎖)這兩個位元組碼指令一樣,即消除了鎖的使用。這種編譯器優化就被稱為鎖消除(Lock Elision),它使得特定情況下我們可以完全消除鎖的開銷。

  Java標準庫中的有些類(比如StringBuffer)雖然是執行緒安全的,但是在實際使用中我們往往不在多個執行緒間共享這些類的例項。而這些類在實現執行緒安全的時候往往藉助於內部鎖。因此,這些類是鎖消除優化的常見目標。

清單12-1  可進行鎖消除優化的示例程式碼
public class LockElisionExample {

  public static String toJSON(ProductInfo productInfo) {
    StringBuffer sbf = new StringBuffer();
    sbf.append("{\"productID\":\"").append(productInfo.productID);
    sbf.append("\",\"categoryID\":\"").append(productInfo.categoryID);
    sbf.append("\",\"rank\":").append(productInfo.rank);
    sbf.append(",\"inventory\":").append(productInfo.inventory);
    sbf.append('}');

    return sbf.toString();
  }
}

  在上面例子中,JIT編譯器在編譯toJSON方法的時候會將其呼叫的StringBuffer.append/toString方法內聯(Inline)到該方法之中,這相當於把StringBuffer.append/toString方法的方法體中的指令複製到toJSON方法體之中。這裡的StringBuffer例項sbf是一個區域性變數,並且該變數所引用的物件並沒有被髮布到其他執行緒,因此sbf引用的物件只能夠被sbf所在的方法(toJSON方法)的當前執行執行緒(一個執行緒)訪問。所以,JIT編譯器此時可以消除toJSON方法中從StringBuffer.append/toString方法的方法體複製的指令所使用的內部鎖。在這個例子中,StringBuffer.append/toString方法本身所使用的鎖並不會被消除,因為系統中可能還有其他地方在使用StringBuffer,而這些程式碼可能會共享StringBuffer例項。

  鎖消除優化所依賴的逃逸分析技術自Java SE 6u23起預設是開啟的,但是鎖消除優化是在Java 7開始引入的。

  從上述例子可以看出,鎖消除優化還可能需要以JIT編譯器的內聯優化為前提。而一個方法是否會被JIT編譯器內聯取決於該方法的熱度以及該方法對應的位元組碼的尺寸(Bytecode Size)。因此,鎖消除優化能否被實施還取決於被呼叫的同步方法(或者帶同步塊的方法)是否能夠被內聯。

  鎖消除優化告訴我們在該使用鎖的情況下必須使用鎖,而不必過多在意鎖的開銷。開發人員應該在程式碼的邏輯層面考慮是否需要加鎖,而至於程式碼執行層面上某個鎖是否真的有必要使用則由JIT編譯器來決定。鎖消除優化並不表示開發人員在編寫程式碼的時候可以隨意使用內部鎖(在不需要加鎖的情況下加鎖),因為鎖消除是JIT編譯器而不是javac所做的一種優化,而一段程式碼只有在其被執行的頻率足夠大的情況下才有可能會被JIT編譯器優化。也就是說在JIT編譯器優化介入之前,只要原始碼中使用了內部鎖,那麼這個鎖的開銷就會存在。另外,JIT編譯器所執行的內聯優化、逃逸分析以及鎖消除優化本身都是有其開銷的。

  在鎖消除的作用下,利用ThreadLocal將一個執行緒安全的物件(比如Random)作為一個執行緒特有物件來使用,不僅僅可以避免鎖的爭用,還可以徹底消除這些物件內部所使用的鎖的開銷。

2 鎖粗化

  鎖粗化(Lock Coarsening/Lock Merging)是JIT編譯器對內部鎖的具體實現所做的一種優化。


鎖粗化(Lock Coarsening)示意圖

  對於相鄰的幾個同步塊,如果這些同步塊使用的是同一個鎖例項,那麼JIT編譯器會將這些同步塊合併為一個大同步塊,從而避免了一個執行緒反覆申請、釋放同一個鎖所導致的開銷。然而,鎖粗化可能導致一個執行緒持續持有一個鎖的時間變長,從而使得同步在該鎖之上的其他執行緒在申請鎖時的等待時間變長。例如上圖中,第1個同步塊結束和第2個同步塊開始之間的時間間隙中,其他執行緒本來是有機會獲得monitorX的,但是經過鎖粗化之後由於臨界區的長度變長,這些執行緒在申請monitorX時所需的等待時間也相應變長了。因此,鎖粗化不會被應用到迴圈體內的相鄰同步塊。

  相鄰的兩個同步塊之間如果存在其他語句,也不一定就會阻礙JIT編譯器執行鎖粗化優化,這是因為JIT編譯器可能在執行鎖粗化優化前將這些語句挪到(即指令重排序)後一個同步塊的臨界區之中(當然,JIT編譯器並不會將臨界區內的程式碼挪到臨界區之外)。

  實際上,我們寫的程式碼中可能很少會出現上圖中那種連續的同步塊。這種同一個鎖例項引導的相鄰同步塊往往是JIT編譯器編譯之後形成的。

  例如,在下面的例子中

清單12-2  可進行鎖粗化優化的示例程式碼
public class LockCoarseningExample {
  private final Random rnd = new Random();

  public void simulate() {
    int iq1 = randomIQ();
    int iq2 = randomIQ();
    int iq3 = randomIQ();
    act(iq1, iq2, iq3);
  }

  private void act(int... n) {
    // ...
  }

 // 返回隨機的智商值
  public int randomIQ() {
    // 人類智商的標準差是15,平均值是100
    return (int) Math.round(rnd.nextGaussian() * 15 + 100);
  }
  // ...
}

  simulate方法連續呼叫randomIQ方法來生成3個符合正態分佈(高斯分佈)的隨機智商(IQ)。在simulate方法被執行得足夠頻繁的情況下,JIT編譯器可能對該方法執行一系優化:首先,JIT編譯器可能將randomIQ方法內聯(inline)到simulate方法中,這相當於把randomIQ方法體中的指令複製到simulate方法之中。在此基礎上,randomIQ方法中的rnd.nextGaussian()呼叫也可能被內聯,這相當於把Random.nextGaussian()方法體中的指令複製到simulate方法之中。Random.nextGaussian()是一個同步方法,由於Random例項rnd可能被多個執行緒共享(因為simulate方法可能被多個執行緒執行),因此JIT編譯器無法對Random.nextGaussian()方法本身執行鎖消除優化,這使得被內聯到simulate方法中的Random.nextGaussian()方法體相當於一個由rnd引導的同步塊。經過上述優化之後,JIT編譯器便會發現simulate方法中存在3個相鄰的由rnd(Random例項)引導的同步塊,於是鎖粗化優化便“粉墨登場”了。

  鎖粗化預設是開啟的。如果要關閉這個特性,我們可以在Java程式的啟動命令列中新增虛擬機器引數“-XX:-EliminateLocks”(開啟則可以使用虛擬機器引數“-XX:+EliminateLocks”)。

3 偏向鎖

  偏向鎖(Biased Locking)是Java虛擬機器對鎖的實現所做的一種優化。這種優化基於這樣的觀測結果(Observation):大多數鎖並沒有被爭用(Contented),並且這些鎖在其整個生命週期內至多隻會被一個執行緒持有。然而,Java虛擬機器在實現monitorenter位元組碼(申請鎖)和monitorexit位元組碼(釋放鎖)時需要藉助一個原子操作(CAS操作),這個操作代價相對來說比較昂貴。因此,Java虛擬機器會為每個物件維護一個偏好(Bias),即一個物件對應的內部鎖第1次被一個執行緒獲得,那麼這個執行緒就會被記錄為該物件的偏好執行緒(Biased Thread)。這個執行緒後續無論是再次申請該鎖還是釋放該鎖,都無須藉助原先(指未實施偏向鎖優化前)昂貴的原子操作,從而減少了鎖的申請與釋放的開銷。

  然而,一個鎖沒有被爭用並不代表僅僅只有一個執行緒訪問該鎖,當一個物件的偏好執行緒以外的其他執行緒申請該物件的內部鎖時,Java虛擬機器需要收回(Revoke)該物件對原偏好執行緒的“偏好”並重新設定該物件的偏好執行緒。這個偏好收回和重新分配過程的代價也是比較昂貴的,因此如果程式執行過程中存在比較多的鎖爭用的情況,那麼這種偏好收回和重新分配的代價便會被放大。有鑑於此,偏向鎖優化只適合於存在相當大一部分鎖並沒有被爭用的系統之中。如果系統中存在大量被爭用的鎖而沒有被爭用的鎖僅佔極小的部分,那麼我們可以考慮關閉偏向鎖優化。

  偏向鎖優化預設是開啟的。要關閉偏向鎖優化,我們可以在Java程式的啟動命令列中新增虛擬機器引數“-XX:-UseBiasedLocking”(開啟偏向鎖優化可以使用虛擬機器引數“-XX:+UseBiasedLocking”)。

4 適應性鎖

  適應性鎖(Adaptive Locking,也被稱為 Adaptive Spinning )是JIT編譯器對內部鎖實現所做的一種優化。

  存在鎖爭用的情況下,一個執行緒申請一個鎖的時候如果這個鎖恰好被其他執行緒持有,那麼這個執行緒就需要等待該鎖被其持有執行緒釋放。實現這種等待的一種保守方法——將這個執行緒暫停(執行緒的生命週期狀態變為非Runnable狀態)。由於暫停執行緒會導致上下文切換,因此對於一個具體鎖例項來說,這種實現策略比較適合於系統中絕大多數執行緒對該鎖的持有時間較長的場景,這樣才能夠抵消上下文切換的開銷。另外一種實現方法就是採用忙等(Busy Wait)。所謂忙等相當於如下程式碼所示的一個迴圈體為空的迴圈語句:

// 當鎖被其他執行緒持有時一直迴圈
while (lockIsHeldByOtherThread){}

  可見,忙等是通過反覆執行空操作(什麼也不做)直到所需的條件成立為止而實現等待的。這種策略的好處是不會導致上下文切換,缺點是比較耗費處理器資源——如果所需的條件在相當長時間內未能成立,那麼忙等的迴圈就會一直被執行。因此,對於一個具體的鎖例項來說,忙等策略比較適合於絕大多數執行緒對該鎖的持有時間較短的場景,這樣能夠避免過多的處理器時間開銷。

  事實上,Java虛擬機器也不是非要在上述兩種實現策略之中擇其一 ——它可以綜合使用上述兩種策略。對於一個具體的鎖例項,Java虛擬機器會根據其執行過程中收集到的資訊來判斷這個鎖是屬於被執行緒持有時間“較長”的還是“較短”的。對於被執行緒持有時間“較長”的鎖,Java虛擬機器會選用暫停等待策略;而對於被執行緒持有時間“較短”的鎖,Java虛擬機器會選用忙等等待策略。Java虛擬機器也可能先採用忙等等待策略,在忙等失敗的情況下再採用暫停等待策略。Java虛擬機器的這種優化就被稱為適應性鎖(Adaptive Locking),這種優化同樣也需要JIT編譯器介入。

  適應性鎖優化可以是以具體的一個鎖例項為基礎的。也就是說,Java虛擬機器可能對一個鎖例項採用忙等等待策略,而對另外一個鎖例項採用暫停等待策略。

  從適應性鎖優化可以看出,內部鎖的使用並不一定會導致上下文切換,這就是我們說鎖與上下文切換時均說鎖“可能”導致上下文切換的原因。

  本文選自《Java多執行緒程式設計實戰指南(核心篇)》,點此連結可在博文視點官網檢視此書。
                
  想及時獲得更多精彩文章,可在微信中搜尋“博文視點”或者掃描下方二維碼並關注。
                    圖片描述

相關文章