歡迎光臨
每天分享高質量文章

Java 鎖粗化與迴圈

(給ImportNew加星標,提高Java技能)

 

來自:唐尤華

https://shipilev.net/jvm-anatomy-park/1-lock-coarsening-for-loops/

 

1. 寫在前面

 

JVM 解剖公園”是一個持續更新的系列迷你部落格,閱讀每篇文章一般需要5到10分鐘。限於篇幅,僅對某個主題按照問題、測試、基準程式、觀察結果深入講解。因此,這裡的資料和討論可以當軼事看,並沒有做一致性、寫作風格、句法和語意錯誤、重覆或一致性檢查。如果選擇採信文中內容,風險自負。

 

Aleksey Shipilёv,JVM 效能極客
推特 @shipilev
問題、評論、建議傳送到 
aleksey@shipilev.net

 

譯註:鎖粗化(Lock Coarsening)。鎖粗化是合併使用相同鎖物件的相鄰同步塊的過程。如果編譯器不能使用鎖省略(Lock Elision)消除鎖,那麼可以使用鎖粗化來減少開銷。

 

2. 問題

 

眾所周知,Hotspot 確實進行了鎖粗化最佳化,可以有效合併幾個相鄰同步塊,從而降低鎖開銷。能夠把下麵的程式碼

 

synchronized (obj) {
  // 陳述句 1
}
synchronized (obj) {
  // 陳述句 2
}

 

轉化為

 

synchronized (obj) {
  // 陳述句 1
  // 陳述句 2
}

 

問題來了,Hotspot 能否對迴圈進行這種最佳化?例如,把

 

for (...) {
  synchronized (obj) {
    // 一些操作
  }
}

 

最佳化成下麵這樣?

 

synchronized (this) {
  for (...) {
     // 一些操作
  }
}

 

理論上,沒有什麼能阻止我們這樣做,甚至可以把這種最佳化看作只針對鎖的最佳化,像 loop unswitching 一樣。然而,缺點是可能把鎖最佳化後變得過粗,執行緒在執行迴圈時會佔據所有的鎖。

 

譯註:Loop unswitching 是一種編譯器最佳化技術。透過複製迴圈主體,在 if 和 else 陳述句中放一份迴圈體程式碼,實現將條件句的內部迴圈移到迴圈外部,進而提高迴圈的並行性。由於處理器可以快速運算向量,因此執行速度得到提升。

 

3. 實驗

 

要回答這個問題,最簡單的辦法就是找到 Hotspot 最佳化的證據。幸運的是,有了 JMH 幫助這項工作變得非常簡單。JMH 不僅在構建基準測試時有用,並且在分析基準測試方面同樣好用。讓我們從一個簡單的基準測試開始:

 

@Fork(..., jvmArgsPrepend = {"-XX:-UseBiasedLocking"})
@State(Scope.Benchmark)
public class LockRoach {
    int x;

    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public void test() {
        for (int c = 0; c < 1000; c++) {
            synchronized (this) {
                x += 0x42;
            }
        }
    }
}

 

(完整的原始碼參見這裡 ,請檢視原文連結)

 

這裡有一些重要的技巧:

 

  1. 使用 -XX:-UseBiasedLocking 禁用偏向鎖(Biased Lock)可以避免啟動時間過長。由於偏向鎖不會立即啟動,在初始化階段要等待5秒鐘(參見 BiasedLockingStartupDelay 選項)
  2. 禁用 @Benchmark 方法行內操作可以幫助我們從反彙編中分離相關內容
  3. 加上“魔數” 0x42 有助於快速從反彙編中定位加法操作

 

 

譯註:偏向鎖(Biased Locking)。儘管 CAS 原子指令相對於重量級鎖來說開銷比較小,但還是存在非常可觀的本地延遲,為了在無鎖競爭的情況下避免取鎖獲過程中執行不必要的 CAS 原子指令提出了偏向鎖技術。
論文 
Quickly Reacquirable Locks ,作者 Dave Dice、Mark Moir、William Scherer III。

 

執行環境 i7 4790K、Linux x86_64、JDK EA 9b156:

 

Benchmark            Mode  Cnt      Score    Error  Units
LockRoach.test       avgt    5   5331.617 ± 19.051  ns/op

 

從上面執行資料能分析出什麼結果?什麼都看不出來,對吧?我們需要調查背後到底發生了什麼。這時 -prof perfasm 配置可以派上用場,它能顯示生成程式碼中的熱點區域。用預設設定執行,能夠發現最熱的指令是加鎖 lock cmpxchg(CAS),而且只打印指令附近的程式碼。-prof perfasm:mergeMargin=1000 配置可以將這些熱點區域合併儲存為輸出片段,乍看之下可能覺得有點恐怖。

 

進一步分析得出連續的跳轉指令是鎖定或解鎖,註意迴圈次數最多的程式碼(第一列),可以看到最熱的迴圈像下麵這樣:

 

↗  0x00007f455cc708c1: lea    0x20(%rsp),%rbx
 │          < 省略若干程式碼,進入 monitor >     ;  │  0x00007f455cc70918: mov    (%rsp),%r10        ; 載入 $this
 │  0x00007f455cc7091c: mov    0xc(%r10),%r11d    ; 載入 $this.x
 │  0x00007f455cc70920: mov    %r11d,%r10d        ; ...hm...
 │  0x00007f455cc70923: add    $0x42,%r10d        ; ...hmmm...
 │  0x00007f455cc70927: mov    (%rsp),%r8         ; ...hmmmmm!...
 │  0x00007f455cc7092b: mov    %r10d,0xc(%r8)     ; LOL Hotspot,冗餘儲存,下麵省略兩行
 │  0x00007f455cc7092f: add    $0x108,%r11d       ; 加 0x108 = 0x42 * 4 4次
 │  0x00007f455cc70936: mov    %r11d,0xc(%r8)     ; 把 $this.x 回省略若干程式碼,退出 monitor >      ;  │  0x00007f455cc709c6: add    $0x4,%ebp          ; c += 4   4次
 │  0x00007f455cc709c9: cmp    $0x3e5,%ebp        ; c < 1000?
 ╰  0x00007f455cc709cf: jl     0x00007f455cc708c1

 

哈哈。迴圈似乎被展開了4次,然後這4個迭代中實現鎖粗化!為了排除迴圈展開對鎖粗化的影響,我們可以透過-XX:LoopUnrollLimit=1 配置裁剪迴圈展開,再次量化受限後的粗化效能。

 

譯註:Loop unrolling(迴圈展開),也稱 Loop unwinding,是一種迴圈轉換技術。它試圖以犧牲二進位制大小為代價最佳化程式的執行速度,這種方法被稱為時空折衷。轉換可以由程式員手動執行,也可以由編譯器最佳化。

 

 

Benchmark            Mode  Cnt      Score    Error  Units

# Default
LockRoach.test       avgt    5   5331.617 ± 19.051  ns/op

# -XX:LoopUnrollLimit=1
LockRoach.test       avgt    5  20679.043 ±  3.133  ns/op

 

哇,效能提升了4倍!顯而易見的,因為我們已經觀察到最熱的指令是加鎖 lock cmpxchg。當然,4倍後的粗化鎖意味著4倍吞吐量。非常酷,我們是不是可以宣佈成功,然後繼續前進?還沒有。我們必須驗證禁用迴圈展開真正提供了我們想要進行比較的內容。perfasm 的結果似乎表明它含有類似的熱點迴圈,只是跨了一大步。

 

↗  0x00007f964d0893d2: lea    0x20(%rsp),%rbx
 │          < 省略若干程式碼,進入 monitor >
 │  0x00007f964d089429: mov    (%rsp),%r10        ; 載入 $this
 │  0x00007f964d08942d: addl   $0x42,0xc(%r10)    ; $this.x += 0x42
 │          < 省略若干程式碼,退出 monitor >
 │  0x00007f964d0894be: inc    %ebp               ; c++
 │  0x00007f964d0894c0: cmp    $0x3e8,%ebp        ; c < 1000?
 ╰  0x00007f964d0894c6: jl     0x00007f964d0893d2 ;

 

一切都檢查 OK。

 

4. 觀察結果

 

當鎖粗化在整個迴圈中不起作用時,一旦中間看起來好像存在 N 個相鄰的加鎖解鎖操作,另一種迴圈最佳化——迴圈展開會提供常規鎖粗化。這將提高效能,並有助於限制粗化的範圍,以避免長迴圈過度粗化。

看完本文有收穫?請轉發分享給更多人

關註「ImportNew」,提升Java技能

喜歡就點「好看」唄~

 

    贊(0)

    分享創造快樂