原文地址:JVM Anatomy Park #22: Safepoint Polls
問題
- JVM 如何停止 Java 線程以實現(xiàn) stop-the-world马靠?
- 在熱循環(huán)中奇怪的
test
指令是什么骑歹? - 為什么在 Java 11 中空方法執(zhí)行變慢了德崭?
所有這些問題的答案是同一個猿规。
理論
像 JVM 這種托管運行時系統(tǒng)譬巫,有時需要停止 Java 線程帚桩,執(zhí)行一些運行時代碼供嚎。比如,執(zhí)行 stop-the-world GC糯而∑仆校可以等待所有線程最終調(diào)用 JVM,比如申請內(nèi)存(經(jīng)常執(zhí)行 TLAB 替換)歧蒋,或者類似的操作。但是這不一定會發(fā)生州既!如果線程正在執(zhí)行某種頻繁的循環(huán)邏輯而不做別的事情怎么辦谜洽?
在大部分機器上停止運行的線程實際上是很簡單的:向線程發(fā)送一個信號,強制處理器中斷吴叶,等阐虚。停止線程正在執(zhí)行的操作,將控制權(quán)轉(zhuǎn)交給別處蚌卤。然而实束,這還不足以讓 Java 線程在任意位置停止奥秆,特別是如果你需要精確的垃圾回收。在這種情況下咸灿,你需要知道寄存器和棧中的內(nèi)容构订,這些內(nèi)容可能是你需要處理的對象引用”苁福或者如果你想要取消偏向鎖悼瘾,你需要精確的知道線程的狀態(tài)和獲取的鎖∩笮兀或者如果你想要逆優(yōu)化方法亥宿,你需要在不丟失執(zhí)行狀態(tài)和臨時值的安全位置操作。
因此像 Hotspot 這種現(xiàn)代 JVMs 實現(xiàn)了協(xié)作機制:線程經(jīng)常詢問是否應(yīng)該將控制權(quán)交給 VM砂沛,在線程生命周期中某些已知的位置烫扼,線程的狀態(tài)是已知的。當(dāng)所有線程都在已知的位置停止的時候碍庵,VM 被認為是到達了安全點映企。檢查安全點請求的代碼片段因此被稱為安全點檢查(safepoint polls)
這種實現(xiàn)需要滿足以下有趣的權(quán)衡:安全點檢查很少觸發(fā)進程停止,所以在不觸發(fā)時應(yīng)該非常高效怎抛。我們可以通過實驗觀測么卑吭?
實踐
考慮這個簡單的 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(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class EmptyBench {
@Benchmark
public void emptyMethod() {
// This method is intentionally left blank.
}
}
你可能認為這個測試用例測量的是空方法,但是實際上這測量的是執(zhí)行測試用例最小的基礎(chǔ)設(shè)施代碼:統(tǒng)計迭代马绝,等待迭代執(zhí)行時間豆赏。這段代碼執(zhí)行的非常快富稻,所以可以使用 -prof perfasm
剖析執(zhí)行過程掷邦。
這是原裝 OpenJDK 8u191 的執(zhí)行結(jié)果:
3.60% ...a2: movzbl 0x94(%r8),%r10d ; load "isDone" field
0.63% │ ...aa: add $0x1,%rbp ; iterations++;
32.82% │ ...ae: test %eax,0x1765654c(%rip) ; global safepoint poll
58.14% │ ...b4: test %r10d,%r10d ; if !isDone, do the cycle again
╰ ...b7: je ...a2
這個空方法被內(nèi)聯(lián)了,所有的調(diào)用成本都消除了椭赋,僅僅剩下基礎(chǔ)設(shè)施邏輯抚岗。
看到 “global safepoint poll” 了么?當(dāng)需要檢查點的時候哪怔,JVM 將會持有“檢查頁(polling page)”[1]宣蔚,任何對該頁的讀操作將會觸發(fā) 段錯誤 (SEGV)。當(dāng)安全點檢查最終觸發(fā) SEGV 時认境,控制權(quán)將會傳遞給任一存在的 SEGV 處理器胚委,JVM 已經(jīng)準(zhǔn)備好了一個!可以看一下 JVM_handle_linux_signal
如何處理段錯誤叉信。
所有這些技巧的目的是使得安全點的成本盡可能低亩冬,因為很多位置都需要安全點,但是幾乎不會觸發(fā)硼身」杓保基于這個原因覆享,使用 test %eax, (addr)
指令:當(dāng)安全點檢查沒有觸發(fā)的時候,這條指令沒有作用[2]营袜。這條指令的編碼也很緊湊撒顿,在 x86_64 平臺僅僅占用6字節(jié)。對于給定 JVM 進程连茧,檢查的頁地址是固定的核蘸,所以 JIT 生成的代碼可以使用相對 RIP 尋址(RIP-relative addressing):從當(dāng)前指令指針給定頁地址的偏移量,而不需要耗費空間編碼8字節(jié)絕對地址啸驯。
通常來說只有一個檢查頁來處理所有線程客扎,所以生成的代碼不需要辨別當(dāng)前執(zhí)行的線程。但是如果 VM 想要停止單個線程罚斗,如何操作徙鱼?JEP-312: "Thread-Local Handshakes" 給出了這個問題的答案。為 VM 提供了對單個線程觸發(fā)握手(handshake)檢查的能力针姿,當(dāng)前是通過為每個線程分配單獨的檢查頁實現(xiàn)的袱吆,檢查指令讀取線程局部存儲(thread-local storage)中的頁地址。[3][4]
這是原裝 OpenJDK 11.0.1 的執(zhí)行結(jié)果:
0.31% ...70: movzbl 0x94(%r9),%r10d ; load "isDone" field
0.19% │ ...78: mov 0x108(%r15),%r11 ; reading the thread-local poll page addr
25.62% │ ...7f: add $0x1,%rbp ; iterations++;
35.10% │ ...83: test %eax,(%r11) ; thread-local handshake poll
34.91% │ ...86: test %r10d,%r10d ; if !isDone, do the cycle again
╰ ...89: je ...70
這純粹是運行時的問題距淫,所以可以通過 -XX:-ThreadLocalHandshakes
關(guān)閉這個特性绞绒,生成的代碼將會與 8u191 中的一樣。這解釋了 8 與 11 測試結(jié)果不同的原因(讓我們馬上用 -prof perfnorm
執(zhí)行測試用例):
Benchmark Mode Cnt Score Error Units
# 8u191
EmptyBench.test avgt 15 0.383 ± 0.007 ns/op
EmptyBench.test:CPI avgt 3 0.203 ± 0.014 #/op
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10?? #/op
EmptyBench.test:L1-dcache-loads avgt 3 2.009 ± 0.291 #/op
EmptyBench.test:cycles avgt 3 1.021 ± 0.193 #/op
EmptyBench.test:instructions avgt 3 5.024 ± 0.229 #/op
# 11.0.1
EmptyBench.test avgt 15 0.590 ± 0.023 ns/op ; +0.2 ns
EmptyBench.test:CPI avgt 3 0.260 ± 0.173 #/op
EmptyBench.test:L1-dcache-loads avgt 3 3.015 ± 0.120 #/op ; +1 load
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10?? #/op
EmptyBench.test:cycles avgt 3 1.570 ± 0.248 #/op ; +0.5 cycles
EmptyBench.test:instructions avgt 3 6.032 ± 0.197 #/op ; +1 instruction
# 11.0.1, -XX:-ThreadLocalHandshakes
EmptyBench.test avgt 15 0.385 ± 0.007 ns/op
EmptyBench.test:CPI avgt 3 0.205 ± 0.027 #/op
EmptyBench.test:L1-dcache-loads avgt 3 2.012 ± 0.122 #/op
EmptyBench.test:L1-dcache-load-misses avgt 3 ≈ 10?? #/op
EmptyBench.test:cycles avgt 3 1.030 ± 0.079 #/op
EmptyBench.test:instructions avgt 3 5.031 ± 0.299 #/op
所以線程局部握手增加了一次額外的 L1 命中加載榕暇,這耗費了大約半個周期蓬衡。這也為我們評估安全點檢查的成本提供了一些基準(zhǔn):L1 命中加載,大概額外耗費半個周期彤枢。
觀察
安全點和握手檢查是托管運行時系統(tǒng)實現(xiàn)中的小細節(jié)狰晚。它們經(jīng)常出現(xiàn)在生成代碼的熱路徑中,有時候會影響性能缴啡,特別是在密集的循環(huán)中壁晒。然而,對于運行時系統(tǒng)實現(xiàn)諸如垃圾回收业栅、鎖優(yōu)化秒咐、逆優(yōu)化等重要的特性,這些操作是必要的碘裕。
有許多安全點相關(guān)的優(yōu)化反镇,我們將會單獨討論。
- 在 Linux/POSIX 中娘汞,調(diào)用
mprotect(PROT_NONE)
就足夠了。 - 差不多吧夕玩。在 x86 中你弦,這條指令改變標(biāo)志位惊豺,但是下一條指令將會重寫,我們只需要注意別在
test
和相關(guān)的jCC
之間再做安全點檢查禽作。 - 線程局部存儲是每個線程可以訪問的一段本地數(shù)據(jù)尸昧。在許多平臺中,寄存器的壓力不是很高旷偿,生成的代碼總是將線程局部存儲放在寄存器中烹俗。在 x86_64 中,存儲位置通常是
%r15
萍程。 - 從技術(shù)上講幢妄,停止一部分線程不是一個“安全點”。但是當(dāng)線程局部握手(thread-local handshakes)啟動的時候茫负,可以通過握手所有線程實現(xiàn)安全點蕉鸳。對于“安全點”和“握手”的場景都支持。