【譯】JVM Anatomy Quark #25: 隱式空檢查

原文地址:JVM Anatomy Quark #25: Implicit Null Checks

問題

Java 規(guī)范上寫著訪問 null 對(duì)象字段時(shí)將會(huì)拋出 NullPointerException。這意味著 JVM 必須使用運(yùn)行時(shí)檢查對(duì)象是否為空?

理論

在理論上,(JIT)編譯器可以確定某個(gè)對(duì)象不為 null瑰煎,以此略去運(yùn)行時(shí)空檢查藐翎,例如對(duì)于常量來說:

static class Holder { int x; }
static final Holder H = new Holder();

int m() {
  return H.x; // H is known to be not null at JIT compilation time
}

如果這樣還不行动看,例如無法自動(dòng)推斷是否為空懒闷,那么編譯器也可以采用數(shù)據(jù)流分析來移除首次空檢查之后的檢查宪赶。例如:

int m(Holder h) {
  int x1 = h.x; // null-check here
  int x2 = h.x; // no need to null-check here again
  return x1 + x2;
}

這些優(yōu)化非常有用巩趁,但是很無聊痒玩,并且不能解決其它情況下空檢查的需求。

幸運(yùn)的是议慰,有一個(gè)更聰明的方法解決這個(gè)問題:讓用戶代碼在沒有顯式檢查的情況下訪問對(duì)象凰荚!大部分情況下不會(huì)出現(xiàn)異常,因?yàn)榇蟛糠謱?duì)象訪問不會(huì)是空對(duì)象褒脯。但是我們?nèi)匀恍枰幚?null 訪問的異常情況便瑟。當(dāng)訪問空對(duì)象時(shí),JVM 可以攔截生成的 SIGSEGV(信號(hào):段錯(cuò)誤)番川,查看該信號(hào)返回的地址到涂,識(shí)別出生成代碼中的訪問位置脊框。一旦確定了訪問位置,就可以知道在哪里調(diào)度控件來處理這種情況——在大部情況下就是拋出 NullPointerException 或者跳到另外的分支践啄。

這種機(jī)制在 Hotspot 中稱為 ”隱式空檢查“浇雹。該機(jī)制最近也以類似的名稱添加到了 LLVM 中。

我們可以看一下它是如何工作的嗎屿讽?

實(shí)踐

請(qǐng)看這個(gè)巧妙而簡單的 JMH 測試用例:

import org.openjdk.jmh.annotations.*;

import java.util.concurrent.TimeUnit;

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 3, jvmArgsAppend = {"-XX:LoopUnrollLimit=1"})
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class ImplicitNP {

    @Param({"false", "true"})
    boolean blowup;

    volatile Holder h;

    int itCnt;

    @Setup
    public void setup() {
        h = null;
        if (blowup && ++itCnt == 3) { // blow it up on 3-rd iteration
            for (int c = 0; c < 10000; c++) {
                try {
                    test();
                } catch (NullPointerException npe) {
                    // swallow
                }
            }
            System.out.print("Boom! ");
        }
        h = new Holder();
    }

    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    @Benchmark
    public int test() {
        int sum = 0;
        for (int c = 0; c < 100; c++) {
            sum += h.x;
        }
        return sum;
    }

    static class Holder {
        int x;
    }
}

從表面上看昭灵,這個(gè)測試用例很簡單:執(zhí)行 100 次整數(shù)加法。

具體來看伐谈,這個(gè)測試用例有幾次很巧妙的地方:

  1. 這個(gè)測試參數(shù)化了 blowup烂完,當(dāng) blowup = true 時(shí)在第三次迭代會(huì)暴露 null 對(duì)象給 test() 方法。
  2. 這個(gè)測試以不安全的方式使用循環(huán)诵棵。通過 LoopUnrollLimit 設(shè)置 Hotspot 不展開循環(huán)抠蚣,這樣可以消除這個(gè)問題。
  3. 這個(gè)測試一次又一次地訪問同一個(gè)對(duì)象履澳。聰明的優(yōu)化器可以將 h 字段的加載提升到循環(huán)外面嘶窄,然后進(jìn)行積極地優(yōu)化。通過將 h 聲明為 volatile 可以消除這個(gè)問題:除非我們面對(duì)的是一個(gè)像上帝一樣聰明的優(yōu)化器距贷,否則這足以打破提升優(yōu)化柄冲。
  4. 這個(gè)測試使用編譯器提示來打破 test 的內(nèi)聯(lián)。嚴(yán)格來說這不是該測試必需的忠蝗,但是這是安全措施羊初。原因如下:該測試依賴 test 的分析信息,更聰明的編譯器可以使用 caller-callee profiles 來區(qū)分不同調(diào)用來源(setup() 或者測試用例自身的循環(huán))的分析信息什湘。

在最近的 8u232[1] 版本中測試結(jié)果如下:

Benchmark        (blowup)  Mode  Cnt   Score   Error  Units
ImplicitNP.test     false  avgt   15  40.417 ± 0.030  ns/op
ImplicitNP.test      true  avgt   15  63.187 ± 0.156  ns/op

這里具體的數(shù)據(jù)無關(guān)緊要,重要的是一種情況比另外一種快得多晦攒。blowup = false 的情況明顯快闽撤。如果要深入探究原因,我們可以借助 -prof perfnorm脯颜,這個(gè)工具可以展示底層機(jī)器計(jì)數(shù)器:

Benchmark                       (blowup)  Mode  Cnt    Score    Error  Units

ImplicitNP.test                    false  avgt   15   40.484 ±  0.090  ns/op
ImplicitNP.test:L1-dcache-loads    false  avgt    3  206.606 ± 24.336   #/op
ImplicitNP.test:L1-dcache-stores   false  avgt    3    5.861 ±  0.426   #/op
ImplicitNP.test:branches           false  avgt    3  102.972 ± 13.679   #/op
ImplicitNP.test:cycles             false  avgt    3  141.252 ± 22.330   #/op
ImplicitNP.test:instructions       false  avgt    3  521.998 ± 87.292   #/op

ImplicitNP.test                     true  avgt   15   63.254 ±  0.047  ns/op
ImplicitNP.test:L1-dcache-loads     true  avgt    3  206.154 ± 15.231   #/op
ImplicitNP.test:L1-dcache-stores    true  avgt    3    4.971 ±  0.677   #/op
ImplicitNP.test:branches            true  avgt    3  199.993 ± 20.805   #/op ; +100 branches
ImplicitNP.test:cycles              true  avgt    3  221.388 ± 13.126   #/op ;  +80 cycles
ImplicitNP.test:instructions        true  avgt    3  714.439 ± 64.476   #/op ; +190 insns

所以我們需要尋找一些額外的 branches哟旗。注意測試的循環(huán)有100次迭代,所以每次迭代都有額外的分支栋操?另外也多了 200 條額外的指令闸餐,感覺 "branch" 就是 x86_64 的 testjcc 指令。

基于以上的假設(shè)矾芙,我們通過 -prof perfasm 的幫助看下一實(shí)際的熱代碼舍沙。以下是裁剪的片段。

首先剔宪,blowup = false 的情況:

           ...
  1.71%  ↗  0x...020: mov    0x10(%rsi),%r11d       ; get field "h"
  9.19%  │  0x...024: add    0xc(%r12,%r11,8),%eax  ; sum += h.x
         │                                          ; implicit exception:
         │                                          ; dispatches to 0x...03e
 59.60%  │  0x...029: inc    %r10d                  ; increment "c" and loop
  0.02%  │  0x...02c: cmp    $0x64,%r10d
         ╰  0x...030: jl     0x...d204020
  4.57%     0x...032: add    $0x10,%rsp
  3.16%     0x...036: pop    %rbp
  3.37%     0x...037: test   %eax,0x16a18fc3(%rip)
            0x...03d: retq
            0x...03e: mov    $0xfffffff6,%esi
            0x...043: callq  0x00007f8aed0453e0     ; <uncommon trap>
            ...

這里是一個(gè)非常緊密的循環(huán)拂铡,在 0x…?024 行的指令組合了 h壓縮引用解碼壹无,對(duì) h.x 的訪問,以及隱式空檢查感帅。我們沒有發(fā)現(xiàn)對(duì) h 進(jìn)行空檢查的額外指令斗锭。[2]

implicit exception: dispatches to 0x…?03e 這行是 VM 輸出的一部分,表示 VM 知道 SEGV 異常來自空檢查失敗的指令失球。然后 JVM 信號(hào)處理程序執(zhí)行它的請(qǐng)求并將控制轉(zhuǎn)移到 0x…?03e岖是,這里將會(huì)拋出異常。[3]

當(dāng)然实苞,如果在執(zhí)行過程中經(jīng)常遇到 null豺撑,那么每次都經(jīng)過信號(hào)處理程序會(huì)很慢。對(duì)于當(dāng)前的情況硬梁,我們可以說拋出異常也很慢前硫,但是這里有兩個(gè)邏輯問題。第一荧止,即使異常有時(shí)候很慢屹电,但是如果可以避免的話,那么沒有理由讓它更慢跃巡。第二危号,我們想要使用相同的機(jī)制處理用戶編寫的空檢查,但是用戶不會(huì)想要簡單的 if (h == null) { …? } else { …? } 由于 h 的空檢查而導(dǎo)致性能急劇下降素邪。因此我們希望只有在 null 的頻率比較低的情況下使用隱式空檢查外莲。

幸運(yùn)的是,JVM 可以基于運(yùn)行時(shí) profile編譯代碼兔朦。也就是偷线,當(dāng) JIT 編譯器決定是否生成隱式空檢查時(shí),它可以查看分析信息沽甥,看看對(duì)象是否曾經(jīng)為 null声邦。此外,即使 JIT 編譯器已經(jīng)生成了隱式空檢查摆舟,然后在關(guān)于 null 的優(yōu)化假設(shè)違反后也可以重新編譯代碼亥曹。blowup = true 的情況通過在代碼中賦值為 null 違反了優(yōu)化假設(shè)。結(jié)果 JVM 重新編譯代碼為:[4]

            ...
 11.36%  ↗  0x...bd1: mov    0x10(%rsi),%r11d       ; get field "h"
 12.81%  │  0x...bd5: test   %r11d,%r11d            ; EXPLICIT NULL CHECK
  0.02% ╭│  0x...bd8: je     0x...bf4
 17.23% ││  0x...bda: add    0xc(%r12,%r11,8),%eax  ; sum += h.x
 25.07% ││  0x...bdf: inc    %r10d                  ; increment "c" and loop
  8.70% ││  0x...be2: cmp    $0x64,%r10d
  0.02% │╰  0x...be6: jl     0x...bd1
  3.31% │   0x...be8: add    $0x10,%rsp
  2.49% │   0x...bec: pop    %rbp
  2.72% │   0x...bed: test   %eax,0x160e640d(%rip)
        │   0x...bf3: retq
        ↘   0x...bf4: movabs $0x7821044f8,%rsi      ; <preallocated NullPointerException>
            0x...bfe: mov    %r12d,0x10(%rsi)       ; WTF
            0x...c02: add    $0x10,%rsp
            0x...c06: pop    %rbp
            0x...c07: jmpq   0x00007f887d1053a0     ; throw_exception
            ...

砰恨诱!現(xiàn)在生成的代碼是顯式空檢查了媳瞪![5]沒有用戶的干預(yù),隱式空檢查轉(zhuǎn)化為了顯式照宝。

你在完整的測試日志中可以實(shí)時(shí)看到相關(guān)信息:

# JMH version: 1.22
# VM version: JDK 1.8.0_232, OpenJDK 64-Bit Server VM, 25.232-b09
# VM options: -XX:LoopUnrollLimit=1
# Warmup: 5 iterations, 1 s each
# Measurement: 5 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.openjdk.ImplicitNP.test
# Parameters: (blowup = true)

# Run progress: 50.00% complete, ETA 00:00:30
# Fork: 1 of 3
Warmup Iteration   1: 40.900 ns/op
Warmup Iteration   2: 40.698 ns/op
Warmup Iteration   3: Boom! 63.157 ns/op  // <--- recompilation happened here
Warmup Iteration   4: 63.158 ns/op
Warmup Iteration   5: 63.130 ns/op
Iteration   1: 63.188 ns/op
Iteration   2: 63.208 ns/op
Iteration   3: 63.128 ns/op
Iteration   4: 63.137 ns/op
Iteration   5: 63.143 ns/op

你可以看到前兩個(gè)迭代都正常蛇受,然后在第三次迭代中賦值為 null,JVM 注意到變化進(jìn)行重新編譯厕鹃。[6]這為空檢查提供了基本平穩(wěn)的性能模型龙巨。

其它瑣事: Shenandoah GC

總的來說笼呆,這是一個(gè)非常有用的技術(shù),除此之外還有其它使用場景旨别。例如 Shenandoah GCload-reference-barrier 需要檢查對(duì)象是否在 collection set 中诗赌。如果不在,屏障可以跳過秸弛,因?yàn)楫?dāng)前對(duì)象不需要移動(dòng)铭若。

x86_64 平臺(tái)的代碼:

................. LRB fastpath............................
     0x...067: testb  $0x1,0x20(%r15)
  ╭  0x...06c: jne    0x...086
..│.............. actual heap access .....................
  │↗ 0x...06e: movl   $0x2a,0xc(%r9)
  ││  ...
..││............. LRB mid path ...........................
..││............. checking in-cset .......................
  ↘│ 0x...086: mov    %r9,%r10
   │ 0x...089: shr    $0x17,%r10           ; %r10 is biased region idx
   │ 0x...08d: movabs $0x7f60d00919f0,%r8  ; %r8 is biased cset bitmap
   │ 0x...097: cmpb   $0x0,(%r8,%r10,1)    ; <--- implicit check for null here!
   ╰ 0x...09c: je     0x...06e
      ...

"collection set" 比特是 region 的屬性,所以存在一個(gè)全局的 "cset bitmap"递览,用于識(shí)別哪個(gè) region 在 collection set 中叼屠。為了識(shí)別對(duì)象是否在 collection set 中,將對(duì)象的地址整除 region 的大小绞铃,然后檢查對(duì)應(yīng)的 region bitmap镜雨。需要注意的是堆不必以零地址開始。所以整除結(jié)果并不是實(shí)際的 region 索引儿捧。相反荚坞,它給你的是帶偏移的 region 索引:有一個(gè)偏移常量,這取決于實(shí)際的堆基址菲盾。實(shí)際實(shí)現(xiàn)中颓影,我們可以使用偏移后的索引查看 cset bitmap!

這使我們?cè)?region bitmap 中可以命中每個(gè)合法對(duì)象地址懒鉴,除了 null诡挂,異常地址就訪問到 bitmap 之外了。然而我們知道 null 將命中哪個(gè)地址临谱,所以可以在那里分配并提交零頁璃俗,然后這個(gè)檢查可以假裝 null 的答案是 0 或 "false"。這不需要使用單獨(dú)的運(yùn)行時(shí)檢查來處理 null悉默,也不是涉及任何信號(hào)處理機(jī)制城豁。

結(jié)論

虛擬內(nèi)存為處理內(nèi)存訪問提供了很多漂亮的技巧。隱式空檢查利用了大部分空檢查不會(huì)觸發(fā)的事實(shí)麦牺,并在觸發(fā)的時(shí)候讓虛擬內(nèi)存子系統(tǒng)通知我們。帶有重新編譯功能的托管運(yùn)行時(shí)可以利用 profile 生成正確空檢查代碼鞭缭,并且在空檢查假設(shè)違反之后動(dòng)態(tài)重新生成代碼剖膳。最后,以上這些對(duì)用戶來說或多或少是透明的岭辣,并且提供了顯著的性能收益吱晒。


1. 我們使用 8u 版本 —— 而不是哪些新版本 JDK —— 的目的是展示這個(gè)優(yōu)化不是很新 ;)

2. 在更復(fù)雜的情況中,簡化的控制流和不使用顯式空檢查的空閑寄存器/標(biāo)志可以提高代碼質(zhì)量沦童。

3. 在這段代碼中仑濒,它實(shí)際上進(jìn)入了所謂的 ”uncommon trap“叹话,之后我們會(huì)討論這個(gè)主題。簡單來說墩瞳,這是向運(yùn)行時(shí)發(fā)出通知驼壶,告訴它某個(gè)不會(huì)執(zhí)行的分支被執(zhí)行了,并要求 JVM 基于這些信息重新編譯方法喉酌。

4. 雖然這個(gè)測試用例展示了動(dòng)態(tài)重編譯热凹,但是如果我們?cè)跍y試代碼執(zhí)行前賦值 null,更新初始的 profile泪电,那么也會(huì)得到相同的效果般妙。

5. 0x…?bfe: mov %r12d,0x10(%rsi) 是一個(gè) low-level WTF.

6. -prof perfasm 過濾了預(yù)熱階段發(fā)生的事情,這就是我們沒有看到反編譯的原因相速。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末碟渺,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子突诬,更是在濱河造成了極大的恐慌苫拍,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,123評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件攒霹,死亡現(xiàn)場離奇詭異怯疤,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)催束,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,031評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門集峦,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人抠刺,你說我怎么就攤上這事塔淤。” “怎么了速妖?”我有些...
    開封第一講書人閱讀 156,723評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵高蜂,是天一觀的道長。 經(jīng)常有香客問我罕容,道長备恤,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,357評(píng)論 1 283
  • 正文 為了忘掉前任锦秒,我火速辦了婚禮露泊,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘旅择。我一直安慰自己惭笑,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,412評(píng)論 5 384
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著沉噩,像睡著了一般捺宗。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上川蒙,一...
    開封第一講書人閱讀 49,760評(píng)論 1 289
  • 那天蚜厉,我揣著相機(jī)與錄音,去河邊找鬼派歌。 笑死弯囊,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的胶果。 我是一名探鬼主播匾嘱,決...
    沈念sama閱讀 38,904評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼早抠!你這毒婦竟也來了霎烙?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,672評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤蕊连,失蹤者是張志新(化名)和其女友劉穎悬垃,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體甘苍,經(jīng)...
    沈念sama閱讀 44,118評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡尝蠕,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,456評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了载庭。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片看彼。...
    茶點(diǎn)故事閱讀 38,599評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖囚聚,靈堂內(nèi)的尸體忽然破棺而出靖榕,到底是詐尸還是另有隱情,我是刑警寧澤顽铸,帶...
    沈念sama閱讀 34,264評(píng)論 4 328
  • 正文 年R本政府宣布茁计,位于F島的核電站,受9級(jí)特大地震影響谓松,放射性物質(zhì)發(fā)生泄漏星压。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,857評(píng)論 3 312
  • 文/蒙蒙 一鬼譬、第九天 我趴在偏房一處隱蔽的房頂上張望娜膘。 院中可真熱鬧,春花似錦拧簸、人聲如沸劲绪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,731評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽贾富。三九已至处坪,卻和暖如春捧韵,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背恒傻。 一陣腳步聲響...
    開封第一講書人閱讀 31,956評(píng)論 1 264
  • 我被黑心中介騙來泰國打工淑际, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留畏纲,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,286評(píng)論 2 360
  • 正文 我出身青樓春缕,卻偏偏與公主長得像盗胀,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子锄贼,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,465評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容