【译】JVM Anatomy Park #16: 超多态虚调用

原文地址:JVM Anatomy Park #16: Megamorphic Virtual Calls

问题

我听说超多态虚调用(megamorphic virtual calls)非常糟糕,因为这种调用是由解释器执行的,而不是优化编译器。这是真的么?

理论

如果你读过许多 Hotspot 中关于虚调用优化的文章,你可能会有这样的印象:超多态调用邪恶到家了,因为它们执行慢路径处理,无法获得编译器优化的好处。如果你尝试理解调用去虚化失败之后 OpenJDK 的行为,那么你可能会惊讶这导致的性能问题。但是要考虑到,即使是基准编译器,JVM 也工作地相当好,在某些情况下,即使是解释器的性能也是可以接受的(并且这关系到 time-to-performance)。

所以,现在下结论说运行时系统只是放弃优化还为时过早?

实践

让我们尝试看看虚调用慢路径。因此我们在 JMH 测试用例中制造了人为的超多态调用点:使三个子类访问同一个调用点:

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。

通过 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 结束,这应该是所有运行时对虚调用所做的事情:在虚方法表(VMT)的帮助下跳转到实际的方法。[1]

但是等一下,这里不是这样!反汇编代码显示实际调用的是 0x…​0bf60,而不是在 0x…​59bf0VirtualStub?!并且这个调用很频繁,所以调用的目标也应该很频繁,对么?这就是运行时系统戏弄我们的地方。即使编译器放弃优化虚调用,运行时也可以自行处理“悲观”的情况。为了更好的诊断问题,我们需要获取 fastdebug OpenJDK 构建,这提供了内联缓存(IC) 的追踪选项:-XX:+TraceIC。另外,我们通过 -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

好的,内联缓存代替了位于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

这基本上是由运行时调用的,找出我们想要调用的方法,然后让 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 输出中看到的像是编译之后,但是执行和运行时优化之前的代码。[2]

观察

仅仅因为编译器未能优化到最佳情况,并不意味着最坏情况会更糟糕。诚然,你会放弃一些优化,但是成本不足以大到完全避免虚调用。这个观点与“Java 方法分发的黑魔法” 的结论一致:除非你非常关心,否则没有必要担心调用的性能。


[1] 接口调用的处理方式与此类似,但是在存根中解析和调用的过程会有一些变化。

[2] “分析器是说谎的霍比特人 (并且我们讨厌它们!)” 的另一个例子

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,100评论 18 139
  • 这篇文章是我之前翻阅了不少的书籍以及从网络上收集的一些资料的整理,因此不免有一些不准确的地方,同时不同JDK版本的...
    高广超阅读 15,413评论 3 83
  • 早晨被闹铃声惊醒,揉揉发胀的眼睛,望见窗帘缝隙外渐渐发白的天空,新的一天开始了。 这是我元旦假期的第二天,和往常一...
    斯文韫阅读 796评论 0 7
  • 我双手合十 世界便在我千里之外 拥挤的人群 客套是种伤害 那许多无法深入的情感 逐一凋零 还有那些爱与憎 梦与幻等...
    妖精的瓶子阅读 303评论 0 6
  • 今天,好久不见的老友,相约参加一场书会分享“草木有本心”。东南大学人文学院张娟教授,从一年四季,每个节气的不同植物...
    七点起床阅读 631评论 0 2