JVM常用概念之超态虚拟调用

发布于:2025-03-16 ⋅ 阅读:(21) ⋅ 点赞:(0)

问题

超态虚拟调用是什么?

基础知识

大部分认为超态调用是非常糟糕的,主要是因为超态调用会调用慢路径,并且无法享受编译器优化,那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 输出中看到的是编译后、执行和潜在运行时修补之前的生成代码。

总结

仅仅因为编译器未能针对最佳情况进行优化,并不意味着最坏情况会更糟糕。确实,你会放弃一些优化,但开销不会大到需要完全避免虚拟调用。