JVM基础 -- 方法调用
重载+重写
- 重载:方法名相同,但方法描述符不相同的方法之间的关系
- 重写:方法名相同,并且方法描述符也相同的方法之间的关系
- 方法描述符
- Java:参数类型
- JVM:参数类型+返回类型
重载
- 重载的方法在编译过程即可完成识别
- 具体到在每个方法调用时,Java编译器会根据传入参数的声明类型(不是实际类型)来选取重载方法
- 三阶段
- 在不允许自动装拆箱和可变长参数的情况下,选取重载方法
- 允许自动装拆箱,但不允许可变长参数的情况下,选取重载方法
- 在允许自动装拆箱和可变长参数的情况下,选取重载方法
- Java编译器在同一阶段找到多个适配的方法,依据形式参数的继承关系,选择最贴切的方法,原则:子类优先
- 重载来源
- 同一个类中定义
- 继承父类非私有同名方法
重写
- 子类中定义了与父类中非私有的同名实例方法,且参数类型相同
- 如果是静态方法,那么子类中的方法会隐藏父类中方法
- 方法重写是Java多态最重要的一种体现形式
静态绑定与+动态绑定
- JVM识别重载方法的关键在于类名,方法名和方法描述符
- 方法描述符:参数类型 + 返回类型
- 如果在同一个类中出现多个方法名和方法描述符也相同的方法,那么JVM会在类的验证阶段报错
- JVM的限制比Java语言的限制更少,Java语言:方法描述符 = 方法的参数类型
- JVM中关于重写方法的判定同样基于方法描述符
- 如果子类定义了与父类中非私有实例方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,JVM才会判定为重写
- Java语言中的重写而JVM中的非重写,编译器会通过生成桥接方法来实现Java中的重写语义,保证Java语言和JVM表现出来的重写语义一致
- 对重载方法的区分在编译阶段已经完成,可以认为JVM不存在重载这一概念
- 静态绑定:在解析阶段时能够直接识别目标方法
- 动态绑定:在运行过程中根据调用者的动态类型来识别目标方法
- 重载 == 静态绑定,重写 == 动态绑定?
- 反例:重载不一定是静态绑定(某个类的重载方法可能被它的子类所重写)
- 反例:重写不一定是动态绑定(final修饰目标方法)
- Java编译器会将对非私有实例方法的调用都编译为需要动态绑定的类型(可能进一步优化)
- 重载/重写 和 静态绑定/动态绑定 是两个不同纬度的描述
重载不一定是静态绑定
Java代码
1 | // 重载 |
字节码
1 | public static void main(java.lang.String[]); |
重写不一定是动态绑定
Java代码
1 | // 重写 |
字节码
C
1 | final void func(); |
Override
1 | public static void main(java.lang.String[]); |
调用相关的指令
具体指令
- invokestatic:调用静态方法
- invokespecial
- 调用私有实例方法、构造器
- 使用super关键词调用父类的实例方法、构造器
- 调用所实现接口的default方法
- invokevirtual:调用非私有实例方法
- invokeinterface:调用接口方法
- invokedynamic:调用动态方法(比较复杂)
1 | interface Customer { |
定位目标方法
- 对于invokestatic和invokespecial,JVM在解析阶段能够直接识别具体的目标方法
- 对于invokevirtual和invokeinterface,在绝大部分情况下,JVM需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法
- 唯一例外:如果JVM能确定目标方法有且只有一个,例如目标方法被标记为final
调用指令的符号引用
- 在编译过程中,并不知道目标方法的具体内存地址,因此,Java编译器会暂时用符号引用来表示该目标方法
- 符号引用包括:目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符
- 符号引用存储在class文件的常量池之中,根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用
1 | $ javap -v Profiteer |
目标方法的查找步骤
- 对于非接口符号引用,假设该符号引用所指向的类为C,查找步骤
- 在C中查找符合名字和描述符的方法
- 如果没有找到,在C的父类中继续搜索,直至Object类
- 如果没有找到,在C所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的
- 如果目标方法在间接接口中,则需要满足C与该接口之间没有其他符合条件的目标方法 – 越近,优先级越高
- 如果有多个符合条件的目标方法,则任意返回其中一个
- 静态方法 也可以通过子类来调用,子类的静态方法会隐藏父类中同名同描述符的静态方法
- 对于接口符号引用,假设该符号引用所指向的接口为I,查找步骤
- 在I中查找符合名字和描述符的方法
- 如果没有找到,在Object类中的公有实例方法中搜索
- 如果没有找到,则在I的超接口中搜索,这一步的搜索结果的要求与非接口符号引用的要求一致
- 经过上述的解析步骤之后,符号引用会被解析成实际引用
- 对于可以静态绑定的方法调用而言,实际引用的是一个指向方法的指针
- 对于需要动态绑定的方法调用而言,实际引用则是一个虚方法表的索引
虚方法调用
- JVM的虚方法调用指令
- Java里所有非私有实例方法的调用都会编译成invokevirtual指令(绝大数情况下动态绑定)
- 而接口方法调用都会被编译成invokeinterface指令
- 在绝大数情况下,JVM需要根据调用者的动态类型,来确定虚方法调用的目标方法,这个过程称之为动态绑定
- 相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时
- 静态绑定
- 调用静态方法的invokestatic指令
- 调用构造器、私有实例方法和父类非私有实例方法(可继承)的invokespecial指令
- 父类非私有实例方法:本意是要调用父类的特定方法,而非根据具体类型决定目标方法
- 如果虚方法调用指向一个标记为final的方法,那么JVM也可以静态绑定该虚方法调用的目标方法
虚方法表(链接-准备阶段)
- 虚方法表:JVM采取了一种用空间换时间的策略来实现动态绑定
- invokevirtual的虚方法表与invokeinterface的虚方法表类似
- 虚方法表本质上是一个数组,每个数组元素指向当前类及其父类中非私有、非final的实例方法
- 虚方法表的特性
- 子类虚方法表中包含父类虚方法表中的所有方法
- 子类方法在虚方法表中的索引值,与它所重写的父类方法的索引值相同
- 方法调用指令中的符号引用会在执行之前解析成实际引用
- 对于静态绑定的方法调用而言,实际引用将指向具体的目标方法
- 对于动态绑定的方法调用而言,实际引用则是虚方法表的索引(不仅仅是索引值)
- 动态绑定:JVM将获取调用者的实际类型,并在实际类型的虚方法表中,根据索引值获得目标方法
- 使用虚方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作(相对于创建和初始化栈帧来说,开销很小)
- 访问栈上的调用者
- 读取调用者的动态类型
- 读取该类型的虚方法表
- 读取虚方法表某个索引值所对应的目标方法
1 | // -XX:CompileCommand=dontinline,*.outBound |
Passenger的方法表
索引 | 方法 | 备注 |
---|---|---|
0 | Passenger.toString() | 重写Object.toString() |
1 | Passenger.outBound() | 抽象方法,不可执行 |
Foreigner的方法表
索引 | 方法 | 备注 |
---|---|---|
0 | Passenger.toString() | 重写Object.toString() |
1 | Foreigner.outBound() | 重写Passenger.outBound() |
Chinese的方法表
索引 | 方法 | 备注 |
---|---|---|
0 | Passenger.toString() | 重写Object.toString() |
1 | Chinese.outBound() | 重写Passenger.outBound() |
2 | Chinese.shopping() | 购物 |
即时编译优化
内联缓存
- 加快动态绑定的优化技术:缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法
- 在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法
- 如果没有碰到已缓存的类型,内联缓存则会退化至使用基于虚方法表的动态绑定
- 内联缓存实际上并没有内联目标方法
- 任何方法调用除非被内联,否则都会有固定开销
- 开销
- 保存程序在该方法中的执行位置
- 新建、压入和弹出新方法所使用的栈帧
- getter/setter方法的固定开销所占据的CPU时间甚至超过了方法本身
- 在即时编译中,方法内联可以消除方法调用的固定开销
针对多态的优化
- 术语
- 单态:仅有一种状态的情况
- 多态:有限数量状态的情况
- 超多态:在某个具体数值之下,称之为多态,否则,称之为超多态
- 对于内联缓存,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存
- 单态内联缓存
- 只缓存了一种动态类型以及它所对应的目标方法;比较所缓存的动态类型,如果命中,则调用对应的目标方法
- 大部分的虚方法调用均是单态的(只有一种动态类型),为了节省内存空间,JVM只采用单态内联缓存
- 多态内联缓存(HotSpot中不存在)
- 缓存了多个动态类型以及目标方法;逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用相应的目标方法
- 一般来说,我们会将更热门的动态类型放在前面
- 单态内联缓存
- 当内联缓存没有命中的情况下,JVM需要重新使用虚方法表进行动态绑定,有两种选择
- 替换单态内联缓存中的记录(数据局部性原理)
- 最坏情况:每次进行方法调用都轮流替换内联缓存,导致只有写缓存的额外开销,但没有读缓存对性能提升
- 可以劣化为超多态内联缓存
- 超多态内联缓存(JVM的具体实现方式)
- 实际上已经放弃了优化的机会,直接访问虚方法表来动态绑定目标方法
- 替换单态内联缓存中的记录(数据局部性原理)
- 单态内联缓存 -> (无法命中,劣化) -> 超多态内联缓存(直接使用虚方法表来进行动态绑定)
- HotSpot只存在单态内联缓存和超多态内联缓存,不存在多态内联缓存
性能对比
1 | // -XX:CompileCommand=dontinline,*.outBound |
方法内联(跳过,后续介绍)
参考资料
All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.