JVM 基础篇11 —— JVM中的方法调用与优化

方法调用的任务是确定被调用方法的版本(调用的是哪个方法),不会涉及到方法内部的具体执行过程。

Class文件的编译过程中不包括传统编译中的连接步骤,所有的方法调用在Class文件中保存的只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用)。

这个特性给Java带来了强大的动态布局能力,同时也是的Java方法调用过程变得复杂起来,需要在类加载或者运行期间才能确定目标方法的直接引用

解析

如果一个方法在程序真正开始运行前就可以确定的调用版本,并且这个方法版本在运行期不可变。那么在类加载的解析阶段就能够将一部分符号引用转换为直接引用,这类方法的调用被称为解析

解析是一个静态过程在编译期就会将符号引用转变为直接引用。

Java中满足这种“编译期可知,运行期不可变”的方法主要分为:

  • 静态方法 : 与类型直接关联
  • 私有方法 : 外部不可访问

在JVM中提供了5条方法调用的字节码指令:

  • invokestatic: 调用静态方法
  • invokespecial: 调用实例构造器方法、私有方法和父类方法
  • invokevirtual: 调用所有虚方法
  • invokeinterface: 调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic: 现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。与前4条指令分派逻辑固定在JVM内部不同invokedynamic指令分派逻辑由用户设定的引导方法决定。

虚方法

只要能够被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本:

  • 静态方法
  • 私有方法
  • 构造器
  • 父类方法

这些方法在类加载的时候就会将符号引用解析为该方法的直接引用,因此也被被称为非虚方法。相反,其他方法除final方法外被称为虚方法(注意不是C++中的虚函数)。

特殊:Java中的非虚方法除了使用invokestatic和invokespecial调用的方法外还有一种就是final修饰的方法。虽然final修饰的方法是使用invokevirtual来调用的但是由于它无法被覆盖没有版本,也就无需对方法接受者进行多态选择,因为肯定唯一。所以在Java语言规范中明确说明final方法是非虚方法

分派

对于不满足“编译期可知,运行期不可变”的方法在这个时候就需要使用分派机制。分派发生在编译期和运行期两个阶段。

静态分派

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

静态分派发生在编译阶段,因此静态分派操作由编译器完成。但是因为字面量不需要定义,所以字面量没有显式的静态类型,只能通过语言上的规则去推断(如方法名,参数类型等等)。所以编译器虽然能够确定出方法的重载版本,但是并不能保证这个方法唯一(如:重载的可变长参数与一个普通参数),因此只能确定一个较为合适的版本。

动态分派

与静态分派相反,动态分派发生在程序的执行期与之对应的是方法的重写

invokevirtual指令的运行解析过程主要分为以下几个步骤:

  • 找到操作数栈顶的第一个元素所指向的对象类型,记作C。
  • 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过返回java.lang.IllegalAccessError异常。
  • 否则按照继承关系从下往上(从子类到父类)依次对父类进行第二步的搜索校验过程。
  • 如果最终依旧没有找到对应的方法则返回java.lang.AbstractMethodError异常。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少宗量,可以将分派划分为单分派(一个宗量)和多分派(多个宗量)两种。

Java中静态分派的方法调用,首先确定调用者的静态类型是什么,然后根据要调用的方法参数的静态类型(声明类型)确定所有重载方法中要调用哪一个, 需要根据这两个宗量来编译,所以是静态多分派(多个宗量确定)。

Java中动态分派的方法调用,在运行期间,虚拟机会根据调用者的实际类型调用对应的方法,只需根据这一个宗量就可以确定要调用的方法,所以是动态单分派(一个宗量)。

所以截止目前的JDK 13 在内的Java语言是一门:静态多分派,动态单分派的语言

JVM中动态分派的实际实现

由于动态分派是非常繁琐的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正进行从下到上如此繁琐的搜索。

动态分派的“稳定优化”

目前最常用的"稳定优化"手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

虚方法表:Vritual Method Table,也称为vtable。与之对应的在invokeinterface执行时也会用到接口方法表(Interface method table,简称itable).

方法表中直接存放各个方法的实际入口地址,如果某个方法没被重写那么子类的虚方法表中存放的入口地址与父类的相同,否则就替换为子类实现的方法入口地址。

动态分派的“激进优化”

内联缓存(方法内联)

官方解释:

在计算机科学中,内联函数(有时称作在线函数或编译时期展开函数)是一种编程语言结构,用来建议编译器对一些特殊函数进行内联扩展(有时称作在线扩展);也就是说建议编译器将指定的函数体插入并取代每一处调用该函数的地方(上下文),从而节省了每次调用函数带来的额外时间开支。

简单的说就是把方法内部调用的其它方法的逻辑,嵌入到自身的方法中去,变成自身的一部分,之后不再调用该方法,从而节省调用函数带来的额外开支(如:方法栈帧的生成、参数字段的压入、栈帧的弹出、还有指令执行地址的跳转等)。

如:

public void a(){
	System.out.println("hello ");
	b();
}

public void b(){
	System.out.println("world");
}

进行方法内联后:

public void a(){
	System.out.println("hello ");
	System.out.println("world");
}

这里更常见的是如我们的get/set方法。在使用时可能自身执行逻辑的开销还比不上为了调用这个方法的额外开销。所以很多情景下JVM可以通过内联来减少甚至消除方法调用的开销。

内联条件:

  • 热点代码。 如果一个方法的执行频率很高就表示优化的潜在价值就越大。那代码执行多少次才能确定为热点代码?这是根据编译器的编译模式来决定的。如果是客户端编译模式则次数是1500,服务端编译模式是10000。次数的大小可以通过-XX:CompileThreshold来调整。

  • 方法体不大。jvm中被内联的方法会编译成机器码放在code cache中。如果方法体太大,则能缓存热点方法就少,反而会影响性能。

  • 如果希望方法被内联,尽量用非虚方法,这样jvm可以直接内联。否则因为方法可以被子类继承和覆盖,jvm需要判断内联究竟内联是父类还是其中某个子类的方法。

因为对于一个虚方法,编译器做内联的时候根本无法确定应该使用哪个方法版本。假如有ParentB和SubB两个具有继承关系的类,并且子类重写了父类的get()方法,那么,是要执行父类的get()方法还是子类的get()方法,需要在运行期才能确定,编译器无法得出结论。

CHA 类型继承关系分析:

为了解决虚方法的内联问题,Java虚拟机设计团队想了很多办法,首先是引入“类型继承关系分析”(Class Hierarchy Analysis,CHA),这是一种基于整个应用程序的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。

  • 编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是由稳定前提保障的。
  • 如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个“逃生门”(Guard条件不成立时的Slow Path),称为守护内联(Guarded Inlining)
  • 如果虚拟机一直没有加载到会令方法接收者的继承关系发生变化的类,那这个内联优化代码就可以一直使用下去。如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译
  • 如果向CHA查询出来的结果是由多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用**内敛缓存(Inline Cache)**来完成方法内联。

    内敛缓存(Inline Cache)是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。

  • 如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

所以说,在许多情况下虚拟机进行的内联都是一种激进优化,激进优化的手段在高性能的商用虚拟机中很常见,除了内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。

逃逸分析

逃逸分析与类型继承关系分析一样并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。

逃逸分析的的基本行为就是分析对象的动态作用域:

  • 当一个对象在方法中被定义后,它可能被外部方法所引用(如作为参数传进其他方法),称为方法逃逸。
  • 同理当这个对象可能被外部线程访问(如作为参数赋值给类变量或其他线程中可以访问的实例变量),称为线程逃逸。

如果能够证明一个对象不会逃逸到方法或线程之外,也就是说正常情况下别的方法或线程无法通过任何途径访问到这个对象,那么我们就能对这个对象进行一些高效的优化。

栈上分配

通过将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使用指向该对象的引用永远不会逃逸,那么对象可能是栈分配的候选,而不是堆分配。

我们知道一般的对象在堆中开辟内存,同时Java堆上的对象是线程共享的(见前面章节)。而销毁工作由GC负责对堆上的垃圾进行回收,但是回收的动作不管是筛选还是回收,整理内存都需要消耗时间。如果确定一个对象不会逃逸,那么我们可以直接在栈上分配内存,该对象的内存随着栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占比例很大,如果采用栈上分配,那么大量的对象就会随着方法的结束而自动销毁,从而极大的减轻GC的压力。

同步消除

我们知道通常线程同步的开销非常大,但是通过逃逸分析如果我们知道一个对象不会逃逸出线程,那么外部线程无法对其进行访问也就不会有竞争,也就不需要同步。所以对于这个变量的同步措施就可以消除掉。

标量替换

将一个对象拆散,根据应该的访问情况将要用到的成员变量恢复成原始类型来访问。

标量(Scalar): 指一个数据已经无法再分解成更小的数据来表示。如Java中的基本类型和reference类型等

聚合量(Aggregate): 指一个数据可以继续分解拆散成为更小的数据。典型的如Java中的对象。

如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,程序真正执行的时候可能不创建这个对象,而是创建若干个被这个方法使用到的成员变量来代替

将对象拆散后除了可以让对象的成员变量在栈上(栈上的数据有很大概率被加载到CPU高速缓存中)分配读写外,还可以为后续进一步优化提供条件。

更新时间:2020-08-02 13:08:43

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/jvm基础篇11jvm中的方法调用与优化
最后更新:2020-08-02 13:08:43

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×