原文地址:JVM Anatomy Park #16: Megamorphic Virtual Calls
問(wèn)題
我聽說(shuō)超多態(tài)虛調(diào)用(megamorphic virtual calls)非常糟糕,因?yàn)檫@種調(diào)用是由解釋器執(zhí)行的绿贞,而不是優(yōu)化編譯器调塌。這是真的么?
理論
如果你讀過(guò)許多 Hotspot 中關(guān)于虛調(diào)用優(yōu)化的文章惩阶,你可能會(huì)有這樣的印象:超多態(tài)調(diào)用邪惡到家了匈辱,因?yàn)樗鼈儓?zhí)行慢路徑處理寺擂,無(wú)法獲得編譯器優(yōu)化的好處。如果你嘗試?yán)斫庹{(diào)用去虛化失敗之后 OpenJDK 的行為砾医,那么你可能會(huì)驚訝這導(dǎo)致的性能問(wèn)題拿撩。但是要考慮到,即使是基準(zhǔn)編譯器如蚜,JVM 也工作地相當(dāng)好压恒,在某些情況下,即使是解釋器的性能也是可以接受的(并且這關(guān)系到 time-to-performance)错邦。
所以探赫,現(xiàn)在下結(jié)論說(shuō)運(yùn)行時(shí)系統(tǒng)只是放棄優(yōu)化還為時(shí)過(guò)早?
實(shí)踐
讓我們嘗試看看虛調(diào)用慢路徑撬呢。因此我們?cè)?JMH 測(cè)試用例中制造了人為的超多態(tài)調(diào)用點(diǎn):使三個(gè)子類訪問(wèn)同一個(gè)調(diào)用點(diǎn):
import org.openjdk.jmh.annotations.*;
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class VirtualCall {
static abstract class A {
int c1, c2, c3;
public abstract void m();
}
static class C1 extends A {
public void m() { c1++; }
}
static class C2 extends A {
public void m() { c2++; }
}
static class C3 extends A {
public void m() { c3++; }
}
A[] as;
@Param({"mono", "mega"})
private String mode;
@Setup
public void setup() {
as = new A[300];
boolean mega = mode.equals("mega");
for (int c = 0; c < 300; c += 3) {
as[c] = new C1();
as[c+1] = mega ? new C2() : new C1();
as[c+2] = mega ? new C3() : new C1();
}
}
@Benchmark
public void test() {
for (A a : as) {
a.m();
}
}
}
為了簡(jiǎn)化分析伦吠,我們?cè)O(shè)置參數(shù) -XX:LoopUnrollLimit=1 -XX:-TieredCompilation
:禁止循環(huán)展開,以免反匯編代碼過(guò)于復(fù)雜,關(guān)閉分層編譯毛仪,保證只用最終優(yōu)化編譯器搁嗓。雖然我們不太關(guān)心性能數(shù)值,但是讓我們用這些數(shù)據(jù)構(gòu)建分析框架:
Benchmark (mode) Mode Cnt Score Error Units
VirtualCall.test mono avgt 5 325.478 ± 18.156 ns/op
VirtualCall.test mega avgt 5 1070.304 ± 53.910 ns/op
為了了解不使用優(yōu)化編譯器的情況箱靴,設(shè)置參數(shù) -XX:CompileCommand=exclude,org.openjdk.VirtualCall::test
Benchmark (mode) Mode Cnt Score Error Units
VirtualCall.test mono avgt 5 11598.390 ± 535.593 ns/op
VirtualCall.test mega avgt 5 11787.686 ± 884.384 ns/op
所以谱姓,超多態(tài)調(diào)用確實(shí)很低效,但是這絕不是解釋器的問(wèn)題刨晴。在優(yōu)化過(guò)的情況下 “mono” 與 “mega” 的差別基本上是調(diào)用開銷:在 “mega” 情況下每個(gè)元素耗費(fèi) 3ns,然而在 “mono” 情況下每個(gè)元素僅僅耗費(fèi) 1ns路翻。
通過(guò) perfasm
輸出 “mega” 情況下的執(zhí)行情況狈癞。為了看得清晰,這里刪除了一些內(nèi)容:
....[Hottest Region 1].......................................................................
C2, org.openjdk.generated.VirtualCall_test_jmhTest::test_avgt_jmhStub, version 88 (143 bytes)
6.93% 5.40% ↗ 0x...5c450: mov 0x40(%rsp),%r9
│ ...
3.65% 4.31% │ 0x...5c47b: callq 0x...0bf60 ;*invokevirtual m
│ ; - org.openjdk.VirtualCall::test@22 (line 76)
│ ; {virtual_call}
3.12% 2.34% │ 0x...5c480: inc %ebp
3.33% 0.02% │ 0x...5c482: cmp 0x10(%rsp),%ebp
╰ 0x...5c486: jl 0x...5c450
...
.............................................................................................
31.26% 21.77% <total for region 1>
....[Hottest Region 2].......................................................................
C2, org.openjdk.VirtualCall$C1::m, version 84 (14 bytes) <--- mis-attributed :(
...
Decoding VtableStub vtbl[5]@12
3.95% 1.57% 0x...59bf0: mov 0x8(%rsi),%eax
3.73% 3.34% 0x...59bf3: shl $0x3,%rax
3.73% 5.04% 0x...59bf7: mov 0x1d0(%rax),%rbx
16.45% 22.42% 0x...59bfe: jmpq *0x40(%rbx) ; jump to target
0x...59c01: add %al,(%rax)
0x...59c03: add %al,(%rax)
...
.............................................................................................
27.87% 32.37% <total for region 2>
....[Hottest Region 3].......................................................................
C2, org.openjdk.VirtualCall$C3::m, version 86 (26 bytes)
# {method} {0x00007f75aaf4dd50} 'm' '()V' in 'org/openjdk/VirtualCall$C3'
...
[Verified Entry Point]
17.82% 26.04% 0x...595c0: sub $0x18,%rsp
0.06% 0.04% 0x...595c7: mov %rbp,0x10(%rsp)
0x...595cc: incl 0x14(%rsi) ; c3++
3.53% 5.14% 0x...595cf: add $0x10,%rsp
0x...595d3: pop %rbp
3.29% 5.10% 0x...595d4: test %eax,0x9f01a26(%rip)
0.02% 0.02% 0x...595da: retq
...
.............................................................................................
24.73% 36.35% <total for region 3>
所以性能測(cè)試調(diào)用了一些東西茂契,我們可以假設(shè)為虛調(diào)用處理程序蝶桶,然后以 VirtualStub 結(jié)束,這應(yīng)該是所有運(yùn)行時(shí)對(duì)虛調(diào)用所做的事情:在虛方法表(VMT)的幫助下跳轉(zhuǎn)到實(shí)際的方法掉冶。[1]
但是等一下真竖,這里不是這樣!反匯編代碼顯示實(shí)際調(diào)用的是 0x…?0bf60
厌小,而不是在 0x…?59bf0
的 VirtualStub
恢共?!并且這個(gè)調(diào)用很頻繁璧亚,所以調(diào)用的目標(biāo)也應(yīng)該很頻繁讨韭,對(duì)么?這就是運(yùn)行時(shí)系統(tǒng)戲弄我們的地方癣蟋。即使編譯器放棄優(yōu)化虛調(diào)用透硝,運(yùn)行時(shí)也可以自行處理“悲觀”的情況。為了更好的診斷問(wèn)題疯搅,我們需要獲取 fastdebug OpenJDK 構(gòu)建濒生,這提供了內(nèi)聯(lián)緩存(IC) 的追蹤選項(xiàng):-XX:+TraceIC
。另外幔欧,我們通過(guò) -prof perfasm:saveLog=true
保存 Hotspot 日志
你瞧罪治!
$ grep IC org.openjdk.VirtualCall.test-AverageTime.log
IC@0x00007fac4fcb428b: to megamorphic {method} {0x00007fabefa81880} 'm' ()V';
in 'org/openjdk/VirtualCall$C2'; entry: 0x00007fac4fcb2ab0
好的,內(nèi)聯(lián)緩存代替了位于0x00007fac4fcb428b
的調(diào)用點(diǎn)礁蔗。這是什么规阀?這是我們的 Java 調(diào)用!
$ grep -A 4 0x00007fac4fcb428b: org.openjdk.VirtualCall.test-AverageTime.log
0.02% 0x00007fac4fcb428b: callq 0x00007fac4fb7dda0
;*invokevirtual m {reexecute=0 rethrow=0 return_oop=0}
; - org.openjdk.VirtualCall::test@22 (line 76)
; {virtual_call}
但是這個(gè) Java 調(diào)用中的地址是什么瘦麸?解析運(yùn)行時(shí)存根:
$ grep -C 2 0x00007fac4fb7dda0 org.openjdk.VirtualCall.test-AverageTime.log
0x00007fac4fb7dcdf: hlt
Decoding RuntimeStub - resolve_virtual_call 0x00007fac4fb7dd10
0x00007fac4fb7dda0: push %rbp
0x00007fac4fb7dda1: mov %rsp,%rbp
0x00007fac4fb7dda4: pushfq
這基本上是由運(yùn)行時(shí)調(diào)用的谁撼,找出我們想要調(diào)用的方法,然后讓 IC 修補(bǔ)指向新解析地址的調(diào)用!因?yàn)檫@是一次性操作厉碟,難怪不會(huì)出現(xiàn)在熱代碼中喊巍。IC 操作行提示修改入口為另一個(gè)地址,順便說(shuō)一下箍鼓,也就是實(shí)際的 VtableStub:
$ grep -C 4 0x00007fac4fcb2ab0: org.openjdk.VirtualCall.test-AverageTime.log
Decoding VtableStub vtbl[5]@12
8.94% 6.49% 0x00007fac4fcb2ab0: mov 0x8(%rsi),%eax
0.16% 0.06% 0x00007fac4fcb2ab3: shl $0x3,%rax
0.20% 0.10% 0x00007fac4fcb2ab7: mov 0x1e0(%rax),%rbx
2.34% 1.90% 0x00007fac4fcb2abe: jmpq *0x40(%rbx)
0x00007fac4fcb2ac1: int3
最后就不需要通過(guò)運(yùn)行時(shí)和編譯器調(diào)用解析邏輯來(lái)分發(fā)了崭参,解析邏輯就是調(diào)用做 VMT 分發(fā)的 VtableStub
—— 從不離開生成的機(jī)器碼。IC 機(jī)制將會(huì)以相同的方式處理虛單態(tài)和靜態(tài)調(diào)用款咖,指向不需要做 VMT 分發(fā)的存根和地址何暮。
我們從第一次 JMH perfasm 輸出中看到的像是編譯之后,但是執(zhí)行和運(yùn)行時(shí)優(yōu)化之前的代碼铐殃。[2]
觀察
僅僅因?yàn)榫幾g器未能優(yōu)化到最佳情況海洼,并不意味著最壞情況會(huì)更糟糕。誠(chéng)然富腊,你會(huì)放棄一些優(yōu)化坏逢,但是成本不足以大到完全避免虛調(diào)用。這個(gè)觀點(diǎn)與“Java 方法分發(fā)的黑魔法” 的結(jié)論一致:除非你非常關(guān)心赘被,否則沒有必要擔(dān)心調(diào)用的性能是整。
[1] 接口調(diào)用的處理方式與此類似,但是在存根中解析和調(diào)用的過(guò)程會(huì)有一些變化民假。
[2] “分析器是說(shuō)謊的霍比特人 (并且我們討厭它們8∪搿)” 的另一個(gè)例子