(給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;
}
}
}
}
(完整的原始碼參見這裡 ,請檢視原文連結)
這裡有一些重要的技巧:
- 使用 -XX:-UseBiasedLocking 禁用偏向鎖(Biased Lock)可以避免啟動時間過長。由於偏向鎖不會立即啟動,在初始化階段要等待5秒鐘(參見 BiasedLockingStartupDelay 選項)
- 禁用 @Benchmark 方法行內操作可以幫助我們從反彙編中分離相關內容
- 加上“魔數” 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技能
喜歡就點「好看」唄~