读郑雨迪《深入拆解Java虚拟机》 -- 第五讲 JVM是如何执行方法调用的?(下)

 2019-12-22 10:44  阅读(1717)
文章分类:JVM

本文转自https://time.geekbang.org/column/article/12098

这里我们来聊一聊Java虚拟机中虚方法调用的具体实现。

首先,我们来看一个模拟出国边检的小例子

abstract class 乘客{
        abstract void 出境();
        @Override
        public String toString(){...}
    }

    class 外国人 extends 乘客{
        @Override
        void 出境(){/*进外国人通道*/}
    }
    class 中国人 extends 乘客{
        @Override
        void 出境() {/* 进中国人通道 */}
        void 买买买() {/*逛免税店*/}
    }

    乘客 某乘客 = ...
    某乘客.出境();

这列我们定义了一个抽象类,叫做“乘客”,这个类中有一个名为“出境”的抽象方法,以及重写值Object类的toString方法

然后,我们将“乘客”粗暴底分为两种:“中国人”以及“外国人”。这两个类分别实现了“出境”这个方法,具体来说,就是中国人走中国人通道,外国人走外国人通道。由于咱们储蓄比较多,所以在“中国人”这个类中,还特意加了一个叫做“买买买”的方法。

虚方法调用

我们知道,Java中所有非私有实例方法调用都会被编译成invokevirtual指令,而接口方法调用会被编译成invokeinterface指令。这两种指令,均属于Java虚拟机中的虚方法调用。

在绝大多数情况下,Java虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

在Java虚拟机中,静态绑定包括用于调用静态方法的invokestatic指令,和调用构造器、私有实例方法以及超类非私有实例方法的invokespecial指令。如果虚方法调用指向一个标记为final的方法,那么Java虚拟机也可以静态绑定该虚方法调用的目标方法。

Java虚拟机中采用了一种用空间换区时间的策略来实现动态绑定。它为每个类生成一个方法表,用以快速定位目标方法。

方法表

类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。

这个数据结构,便是Java虚拟机实现动态绑定的关键所在。invokeinterface所使用的*接口方法表(interface method table, itable)稍微比invokevirtual所使用的虚方法表(virtual method table, vtable)*复杂些,但是原理是类似的。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类的非私有实例方法。

这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:

  1. 子类方法表中包含父类方法表中的所有方法
  2. 子类方法在方法表中的索引值,与它所重写的分类方法的索引值相同

方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上不仅是索引值)。

在执行过程中,Java虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

2019120001257\_1.png

在我们的例子中,“乘客”类的方法表包括两个方法:“toString”以及“出境”,分别对应0号以及1号。

之所以方法表调换了“toString”和“出境方法的位置,是因为”toString”方法的索引值需要与Object类中同名方法的索引值一致。为了保持简洁,这里我们不考虑Object类的其它方法。

“外国人”的方法表同样有两行。其中0号方法指向继承而来的“乘客”类的“toString”方法。1号方法则指向自己重写的“出境”方法。

“中国人”的方法则包括三个方法,除了继承而来的“乘客”类的“toString”方法,自己重写的“出境”方法之外,还包括独有的“买买买”方法。

乘客 某乘客 = ...
    某乘客.出境();

这里,Java虚拟机的工作可以想象为导航员。每当来了一个乘客需要出境,导航员会先问是中国人还是外国人(获取动态类型),然后放出中国人/外国人对应的小册子(获得动态类型的方法表),小册子的第1页便写着应该到哪条通道办理出境手续(用1作为索引来查找方法表所对应的目标方法)。

实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用的操作:

  • 访问栈上的调用者
  • 读取调用者的动态类型
  • 读取该类型的方法表
  • 读取方法表中某个索引值所对应的目标方法。

这几个内存解引用的开销为2019120001257\_2.png,简直可以忽略不计。

但是虚方法的调用对性能还是有影响的,上述游湖的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为***即时编译还拥有两种更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)**。***

内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型中所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至基于方法表的动态绑定。

在例子中,这相当于导航员记住了上一个出境乘客的国籍和对应的通道。

在针对多态的优化手段中,我们通常会提及以下三个术语:

  1. 单态(monomorphic)指的是仅有一种状态的情况。
  2. 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)就是多态的其中一种。
  3. 超多态(megamorphic)指定是更多种状态的情况。通常我们用一个具体阈值来区分多态和超多态。在这个阈值之下,我们称之为多态,否则,我们称之为超多态

对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java虚拟机只采用单态内联缓存。

前面提到,当内联缓存没有命中的情况下,Java虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。

  • **替换单态内联缓存中的记录。**这种做法就好比CPU中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态内心应当保持一致,从而能够有效地利用内联缓存。因此,在最坏的情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用豆浆替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。
  • 劣化为超多态状态。这也是Java虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存记录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

方法内联

虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

对于极其简单的方法而言,比如说getter/setter,这部分固定开销占据的CPU时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。

本次我们来观测一下单态内联缓存和超多态内联缓存的性能差距。为了消除方法内联的影响,请使用java -XX:CompileCommand=’dontinline,*. depart’ Passenger

public abstract class Passenger{
        abstract void depart();

        public static void main(String[] args){
            Passenger a = new Chinese();
            Passenger b = new Foreigner();
            long current = System.currentTimeMillis();
            for(int i = 0;i < 2000000000;i++){
                if(i % 100000000 == 0){
                    long temp = System.currentTimeMillis();
                    System.out.println(temp - current);
                    current = temp;
                }
                Passenger c = i < 1000000000 ? a : b;
                c.depart();
            }
        }
    }

    class Chinese extends Passenger{
        @Override
        public void depart(){}
    }

    class Foreigner extends Passenger{
        @Override
        public void depart(){}
    }

我们进行编译并运行

javac Passenger.java 
    java Passenger
    0
    326
    437
    468
    402
    428
    464
    442
    429
    459
    461
    560
    515
    473
    481
    452
    454
    437
    443
    437
    java -XX:CompileCommand='dontinline,*. depart' Passenger
    CompilerOracle: dontinline *.depart
    0
    905
    805
    781
    792
    799
    834
    794
    823
    788
    783
    896
    884
    873
    860
    881
    911
    881
    935
    862

由此可见,内联缓存对于简单方法提升了一倍的速度。

此文从极客时间专栏《深入理解Java虚拟机》搬运而来,撰写此文的目的:

  1. 对自己的学习总结归纳

  2. 此篇文章对想深入理解Java虚拟机的人来说是非常不错的文章,希望大家支持一下郑老师。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 读郑雨迪《深入拆解Java虚拟机》 -- 第五讲 JVM是如何执行方法调用的?(下)

相关推荐