JVM方法调用过程

  JVM方法调用过程

  重载和重写

  同一个类中,如果出现多个名称相同,并且参数类型相同的方法,将无法通过编译.因此,想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同.这种方法上的联系就是重载.

  重载的方法在编译过程中即可完成识别.具体到每一个方法调用,Java编译器会根据所传入参数的声明类型(有别实际类型)来选取重载方法.

  选取过程如下:

    1.不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;

    2.如果1中未找到适配的方法,则允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;

    3.如果2中未找到适配的方法,则在允许自动装拆箱以及可变长参数的情况下选取重载方法.

  JVM的静态绑定和动态绑定

  Java虚拟机识别方法的关键在于类名/方法名/方法描述符(method descriptor).注:方法描述符由方法的参数类型/返回类型构成.

  Java虚拟机中的静态绑定(static binding)指的是在解析时便能够直接识别目标方法的情况;而动态绑定(dynamic binding)则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况.

  具体来说,Java字节码中与调用相关的指令共有五种:

    1.invokestatic:用于调用静态方法

    2.invokespecial:用于调用私有实例方法/构造器,以及使用super关键字调用父类的实例方法/构造器,和所有实现接口的默认方法

    3.invokevirtual:用于调用非私有实例方法

    4.invokeinterface:用于调用接口方法

    5.invokedynamic:用于调用动态方法

  示例代码如下:

interface 客户 {
  boolean isVIP();
}

class 商户 {
  public double 折后价格 (double 原价, 客户 某客户) {
    return 原价 * 0.8d;
  }
}

class 奸商 extends 商户 {
  @Override
  public double 折后价格 (double 原价, 客户 某客户) {
    if (某客户.isVIP()) {                         // invokeinterface      
      return 原价 * 价格歧视 ();                    // invokestatic
    } else {
      return super. 折后价格 (原价, 某客户);          // invokespecial
    }
  }
  public static double 价格歧视 () {
    // 咱们的杀熟算法太粗暴了,应该将客户城市作为随机数生成器的种子。
    return new Random()                          // invokespecial
           .nextDouble()                         // invokevirtual
           + 0.8d;
  }
}

  调用指令的符号引用

  在编译过程中,目标方法的具体内存地址尚未确定.这时,Java编译器会暂时用符号引用来表示该目标方法.这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符.

  符号引用存储在class文件的常量池中.根据目标方法是否为接口方法,又可分为接口符号引用和非接口符号引用.

  对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

    1.在 C 中查找符合名字及描述符的方法。

    2.如果没有找到,在 C 的父类中继续搜索,直至 Object 类。

    3.如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

  从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。

  对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

    1.在 I 中查找符合名字及描述符的方法。

    2.如果没有找到,在 Object 类中的公有实例方法中搜索。

    3.如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

  经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

  虚方法调用

  所有非私有实例方法被调用-->编译-->invokevirtual指令.

  接口方法调用-->编译-->invokeinterface指令. 

  这两种指令,均属于Java虚拟机中的虚方法调用.

  多数情况下,Java虚拟机需要根据调用者的动态类型-->确定虚方法调用的目标方法.这个过程被称为动态绑定.相对于静态绑定的非虚方法调用,虚方法调用更加耗时.

  在Java虚拟机中,静态绑定包括用于调用静态方法的invokestatic指令,和用于调用构造器/私有实例方法/超类非私有实例方法的invokespecial指令.

  如果虚方法调用指向一个标记为final的方法,那么Java虚拟机也可以静态绑定该虚方法调用的目标方法.

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

  方法表

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

  方法表,时Java虚拟机实现动态绑定的关键所在.

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

  方法表满足两个特质:

    1.子类方法表中包含父类方法表中的所有方法

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

  pre:方法调用指令中的符号引用会在执行之前解析为实际引用.

    静态绑定的方法调用:实际引用-->具体的目标方法

    动态绑定的方法调用:实际引用-->方法表的索引值(实际上不止索引值)

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

  in fact,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作 : 访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法.相对于创建并初始化Java栈帧来说,这几个内存解引用操作的开销可以忽略不计.

  但是,虚方法调用对性能仍有影响:

    方法表的引入带来的优化效果仅存在与解释执行或者即时编译代码的最坏情况下.而且即时编译还拥有两个性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining).

  内联缓存

  内联缓存是一种加快动态绑定的优化技术.它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法.后续执行中,优先使用缓存,没有则使用基于方法表的动态绑定.

  对多态的优化,术语:

    1.单态(monomorphic),指的是仅有一种状态的情况

    2.多态(polymorphic),指的是有限数量种状态的情况.二态(bimorphic)是多态的其中一种.

    3.超多态(megamorphic),指的是更多种状态的情况.通常用某个阈值来区分多态和超多态.

  综上,内联缓存对应单态内联缓存/多态内联缓存/超多态内联缓存.

  1.单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。

  2.多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

    注:一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

  在选择内联缓存时,如果未命中则重新使用方法表做动态绑定.这时有两种选择:

    1.替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。因此,在最坏情况下,用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。

    2.劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

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

  JVM处理invokedynamic

  在Java中,方法调用会编译为invokestatic/invokespecial/invokevirtual/invokeinterface四种指令.这些类名与包含目标方法类名/方法名/方法描述符的符号引用捆绑.在实际运行之前,Java虚拟机将根据这个符号引用链接到具体的目标方法.

  Java7引入了invokedynamic指令,该指令的调用机制抽象出调用点这一概念,并允许应用程序将调用点链接至任何符合条件的方法上.

  作为invokedynamic的准备工作,Java7引入了更加底层/更加灵活的方法抽象:方法句柄(MethodHandle).

  方法句柄的概念

  方法句柄是一种强类型的,能够被直接执行的引用.该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段.当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的getter或者setter方法.

  HotSpot虚拟机中方法句柄调用的具体实现 :

    以DirectMethodHandle为例,调用方法句柄所使用的invokeExact或者invoke方法具备签名多态性的特性.会根据具体的传入参数来生成方法描述符.其中,invokeExact要求传入的参数和所指向方法的描述符严格匹配.方法句柄还支持增删改参数的操作,这些操作时通过生成另一个充当适配器的方法句柄来实现的.

    方法句柄的调用和反射调用一样,都是间接调用.同样都面临无法内联的问题,不过与反射调用不同的是,方法句柄的内联瓶颈在于即时编译器能否将该方法句柄识别为常量.

  invokedynamic指令

  invokedynamic是Java7引入的一条新指令,用以支持动态语言的方法调用.具体来说,它将调用点(CallSite)抽象成一个Java类,并且将原本由Java虚拟机控制的方法调用以及方法链接暴露给了应用程序.在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并会调用该调用点所链接的方法句柄.

  在第一次执行invokedynamic指令时,Java虚拟机会调用该指令所对应的启动方法(BootStrapMethod),来生成调用点,并将之绑定至该invokedynamic指令中.在之后的运行过程中,Java虚拟机则会直接调用绑定的调用点所链接的方法句柄.

  在字节码中,启动方法是用方法句柄来指定的.这个方法句柄指向一个返回类型为调用点的静态方法.该方法必须接收三个固定的参数,分别为一个Lookup类实例,一个用来指代目标方法名字的字符串,以及该调用点能够链接的方法句柄的类型.

  除了三个必须参数外,启动方法(BootStrapMethod)还可以接收若干个其它的参数,用来辅助生成调用点,或者定位索要链接的目标方法.

  Java8的Lambda表达式

  在Java8中,Lambda表达式也是借助invokedynamic来实现的

  具体来说,Java编译器利用invokedynamic指令来生成实现了函数式接口的适配器.这里的函数式接口指的是仅包括一个非default接口方法的接口,一般通过@FunctionalInterface注解.同时,该invokedynamic指令对应的启动方法将通过ASM生成一个适配器类.

  对于没有捕获其它变量的Lambda表达式,该invokedynamic指令始终返回同一个适配器类的实例.对于捕获了其它变量的Lambda表达式,每次执行invokedynamic指令将新建一个适配器类实例.

  不管是捕获型的还是未捕获型的Lambda表达式,它们的性能上限皆可以达到直接调用的性能.其中,捕获型Lambda表达式借助了即时编译器的逃逸分析,来避免实际的新建适配器类实例的操作.

原文地址:https://www.cnblogs.com/nyatom/p/9379013.html