参考文献

  • 极客时间-JVM是如何执行方法调用的?(上)

静态绑定和动态绑定

  • 静态绑定: 所有依赖静态类型来定位方法执行版本的分派方式.如: 重载方法.

    • 静态绑定(也称为早期绑定)是指在编译时进行的方法绑定.在静态绑定中,方法调用的具体实现在编译时已经确定,因此它是基于引用类型而不是基于运行时对象类型的.这意味着,如果调用的方法是在父类中定义的,则不管实际运行时类型是什么,该方法都将被调用.
  • 动态绑定: 根据运行的时机类型来定位方法执行版本的分派方式.如: 重写方法.

    • 动态绑定(也称为晚期绑定或运行时绑定)是指在运行时进行的方法绑定.在动态绑定中,方法调用的具体实现是在运行时根据实际对象类型确定的,因此它是基于运行时对象类型的.
  • Java 虚拟机识别方法的关键在于类名,方法名以及方法描述符(method descriptor).

  • 方法描述符: 它是由方法的参数类型以及返回类型所构成.在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错.

  • Java 虚拟机中关于方法重写的判定同样基于方法描述符.也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写.

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

  • Java 字节码中与调用相关的指令共有五种.

    1. invokestatic:用于调用静态方法.
    2. invokespecial:用于调用私有实例方法、构造方法,以及使用super关键字调用父类的实例方法或构造方法和所实现接口的默认方法.
    3. invokevirtual:用于调用非私有实例方法.
    4. invokeinterface:用于调用接口方法.
    5. invokedynamic:用于调用动态方法.

调用指令的符号引用

  • Java 编译器会暂时用符号引用来表示该目标方法.这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符.

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

  • 在执行使用了符号引用的字节码前,Java 虚拟机需要解析这些符号引用,并替换为实际引用.

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

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

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

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

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

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

    • 在 I 中查找符合名字及描述符的方法.
    • 如果没有找到,在 Object 类中的公有实例方法中搜索.
    • 如果没有找到,则在 I 的超接口中搜索.这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致.
  • 经过上述的解析步骤之后,符号引用会被解析成实际引用.对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针.对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引.

重载与重写

  • 在 Java 中,方法存在重载以及重写的概念,

    • 重载指的是方法名相同而参数类型不相同的方法之间的关系,
    • 重写指的是方法名相同并且参数类型也相同的方法之间的关系.
  • Java 虚拟机识别方法的方式略有不同,除了方法名和参数类型之外,它还会考虑返回类型.

  • 在 Java 虚拟机中,静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况.由于 Java 编译器已经区分了重载的方法,因此可以认为 Java 虚拟机中不存在重载.

  • 在 class 文件中,Java 编译器会用符号引用指代目标方法.在执行调用指令前,它所附带的符号引用需要被解析成实际引用.对于可以静态绑定的方法调用而言,实际引用为目标方法的指针.对于需要动态绑定的方法调用而言,实际引用为辅助动态绑定的信息.

虚方法调用

  • Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令,而接口方法调用都会被编译成 invokeinterface 指令.这两种指令,均属于 Java 虚拟机中的虚方法调用.
  • 在绝大多数情况下,Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法.这个过程我们称之为动态绑定.那么,相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时.
  • 在 Java 虚拟机中,静态绑定包括用于调用静态方法的 invokestatic 指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的 invokespecial 指令.如果虚方法调用指向一个标记为 final 的方法,那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法.
  • Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定.它为每个类生成一张方法表,用以快速定位目标方法

方法表

  • invokevirtual 所使用的虚方法表(virtual method table,vtable)
  • 方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法.这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法.方法表满足两个特质:
    • 其一,子类方法表中包含父类方法表中的所有方法;
    • 其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同.
  • 方法调用指令中的符号引用会在执行之前解析成实际引用.对于静态绑定的方法调用而言,实际引用将指向具体的目标方法.对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值).
  • 在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法.这个过程便是动态绑定.
  • 实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法.相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计.

即时编译

  • 初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行.在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译.
  • 最初,虚拟机中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为“热点代码”.
  • 为了提高热点代码的执行效率,在运行时,即时编译器(JIT)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中.

即时编译器类型

  • 在 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器

  • C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求.

  • C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序.根据各自的适配性,这两种即时编译也被称为 Client Compiler 和 Server Compiler.

  • 在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作.

  • Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 “-client”“-server” 强制指定虚拟机的即时编译模式.分层编译将 JVM 的执行状态分为了 5 个层次:

    • 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;

    • 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;

    • 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;

    • 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;

    • 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化.

  • 在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了.如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1.

  • 除了这种默认的混合编译模式,我们还可以使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式下,这时 JIT 完全不介入工作;我们还可以使用参数“-Xcomp”强制虚拟机运行于只有 JIT 的编译模式下.

  • 通过java -version命令行可以直接查看到当前系统使用的编译模式

    1
    2
    3
    4
    ╰ java -version
    openjdk 11.0.12 2021-07-20 LTS
    OpenJDK Runtime Environment Zulu11.50+19-CA (build 11.0.12+7-LTS)
    OpenJDK 64-Bit Server VM Zulu11.50+19-CA (build 11.0.12+7-LTS, mixed mode)

方法内联

  • 在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段
  • 方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化.因此,它可以算是编译优化里最为重要的一环

内联缓存

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

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

    • 单态(monomorphic)指的是仅有一种状态的情况.
    • 多态(polymorphic)指的是有限数量种状态的情况.二态(bimorphic)是多态的其中一种.
    • 超多态(megamorphic)指的是更多种状态的情况.通常我们用一个具体数值来区分多态和超多态.在这个数值之下,我们称之为多态.否则,我们称之为超多态.
  • 对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存.

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

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

  • 当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定.对于内联缓存中的内容,我们有两种选择.一是替换单态内联缓存中的纪录.这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存.

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

逃逸分析

  • 逃逸分析(Escape Analysis)是目前 JVM 中比较前沿的优化技术.通过逃逸分析,JVM 能够分析出一个新的对象使用范围,从而决定是否要将这个对象分配到堆上.

  • 使用 -XX:+DoEscapeAnalysis 参数可以开启逃逸分析,逃逸分析现在是 JVM 的默认行为,这个参数可以忽略.

  • JVM 判断新创建的对象是否逃逸的依据有:

    • 对象被赋值给堆中对象的字段和类的静态变量;
    • 对象被传进了不确定的代码中去运行.

逃逸分析的好处

  • 同步省略: 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步.
  • 栈上分配: 如果一个对象在子程序中被分配,那么指向该对象的指针永远不会逃逸,对象有可能会被优化为栈分配.
  • 分离对象或标量替换: 有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中.标量是指无法再分解的数据类型,比如原始数据类型及 reference 类型