问题
超态虚拟调用是什么?
基础知识
大部分认为超态调用是非常糟糕的,主要是因为超态调用会调用慢路径,并且无法享受编译器优化,那OpenJDK可以取消超态调用吗?那在发生超态调用时我们可以做什么呢?
实验
源码
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();
}
}
}
运行结果
通过-XX:LoopUnrollLimit=1 -XX:-TieredCompilation来运行上述用例,该运行参数的主要作用是阻止循环展开使反汇编复杂化,而禁用分层编译将保证使用最终优化编译器进行编译。执行结果如下:
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
通过-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
通过上述的运行结果可知,超态调用确实会花费一些成本,但绝对不会对性能造成影响。优化情况下的“mono”和“mega”之间的区别基本上在于调用开销:对于“mega”情况,我们花费每个元素 3ns,而对于“mono”情况,我们仅花费每个元素 1ns。
通过通过-prof perfasm对“mega” 案例进行分析,执行结果如下:
....[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>
因此,基准测试循环会调用某个函数(我们可以假设它是虚拟调用处理程序),然后它以 VirtualStub 结束,VirtualStub 据称会执行其他所有运行时对虚拟调用所执行的操作:在虚拟方法表 (VMT)的帮助下跳转到实际方法。
反汇编表明我们实际上是在调用0x…0bf60 ,而不是调用位于0x…59bf0的VirtualStub ?!而且该调用很频繁,所以调用目标也应该很频繁,对吧?这就是运行时本身对我们耍花招的地方。即使编译器放弃优化虚拟调用,运行时也可以自行处理“意外”情况。为了更好地诊断这一点,我们需要获取fastdebug OpenJDK 构建,并为内联缓存 (IC)提供跟踪选项: -XX:+TraceIC 。此外,我们希望使用-prof perfasm:saveLog=true将热点日志保存到文件中,在该日志文件中检索可知:
$ grep IC org.openjdk.VirtualCall.test-AverageTime.log
IC@0x00007fac4fcb428b: to megamorphic {method} {0x00007fabefa81880} 'm' ()V';
in 'org/openjdk/VirtualCall$C2'; entry: 0x00007fac4fcb2ab0
检索结果表明内联缓存已为0x00007fac4fcb428b处的调用点执行了操作。这里的调用就是java调用。
$ 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}
但是 Java 调用中的地址是什么?这是解析运行时存根:
$ 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
其实java调用调用了运行时,确认了需要调用的方法,然后要求 IC修补调用以指向新的解析地址!由于这是一次性操作,这就是为什么没有将其视为热代码。IC 操作行提到将条目更改为另一个地址,顺便说一下,这是我们的实际 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
最后,无需运行时/编译器调用即可调度已解析的调用:调用站点只需调用执行 VMT 调度的VtableStub — 永远不会离开生成的机器代码。此 IC 机制将以类似的方式处理虚拟单态和静态调用,指向不执行 VMT 调度的存根/地址。
我们可以在初始 JMH perfasm 输出中看到的是编译后、执行和潜在运行时修补之前的生成代码。
总结
仅仅因为编译器未能针对最佳情况进行优化,并不意味着最坏情况会更糟糕。确实,你会放弃一些优化,但开销不会大到需要完全避免虚拟调用。