原文地址: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è)測試用例有幾次很巧妙的地方:
- 這個(gè)測試參數(shù)化了
blowup
烂完,當(dāng)blowup = true
時(shí)在第三次迭代會(huì)暴露null
對(duì)象給test()
方法。 - 這個(gè)測試以不安全的方式使用循環(huán)诵棵。通過
LoopUnrollLimit
設(shè)置 Hotspot 不展開循環(huán)抠蚣,這樣可以消除這個(gè)問題。 - 這個(gè)測試一次又一次地訪問同一個(gè)對(duì)象履澳。聰明的優(yōu)化器可以將
h
字段的加載提升到循環(huán)外面嘶窄,然后進(jìn)行積極地優(yōu)化。通過將h
聲明為volatile
可以消除這個(gè)問題:除非我們面對(duì)的是一個(gè)像上帝一樣聰明的優(yōu)化器距贷,否則這足以打破提升優(yōu)化柄冲。 - 這個(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 的 test
和 jcc
指令。
基于以上的假設(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 GC 的 load-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ā)生的事情,這就是我們沒有看到反編譯的原因相速。