目录
1.方法调用的本质
Java中可以通过对象.方法来实现方法的调用,但在虚拟机内究竟是怎么实现的呢?Demo:
public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
用jclasslib打开字节码文件,我们可以看到方法间调用的本质其实是将符号引用(#7)转换为直接引用的过程,再简单点说就是把上图中的#7变成实际内存中的地址。(此过程涉及到动态链接的知识,不了解的小伙伴可以看下我这篇博文:JVM04 - 虚拟机栈中关于动态链接的描述)那究竟JVM是怎么实现的呢?在JVM中,将符号引用转换为调用方法的直接引用这个过程与方法的绑定机制相关。
2.静态链接与动态链接
我们都知道Java有多态的特性,究竟是怎样实现的呢?针对方法而言,将符号引用转换为直接引用这一过程是在编译期间实现的还是在运行期间实现的呢?带着这两个问题,我们先来看下静态链接与动态链接。
2.1 静态链接
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期确定,且运行期保持不变时,这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
2.2 动态链接
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。
3.早期绑定与晚期绑定
再扩展一下,静态链接与动态链接对应的方法绑定机制为早期绑定与晚期绑定,绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这个过程仅仅发生一次。
3.1 早期绑定
早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
3.2 晚期绑定
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
来看个Demo:
package run;
class Animal {
public static void staticTest(){
}
public void eat() {
System.out.println("动物进食");
}
}
interface Huntable {
void hunt();
}
class Dog extends Animal implements Huntable {
@Override
public void eat() {
System.out.println("狗吃骨头");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管闲事");
}
}
class Cat extends Animal implements Huntable {
public Cat() {
super();//表现为:早期绑定
}
public Cat(String name) {
this();//表现为:早期绑定
}
@Override
public void eat() {
super.eat();//表现为:早期绑定
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
}
public class AnimalTest {
public static void staticTest(){
Animal.staticTest();
}
public void showAnimal(Animal animal) {
animal.eat();//表现为:晚期绑定
}
public void showHunt(Huntable h) {
h.hunt();//表现为:晚期绑定
}
}
用jclasslib反编译查看AnimalTest类的字节码:
发现了四种方法调用字节码指令,分别为:
-
invokestatic:调用静态方法,解析阶段确定唯一方法版本
-
invokespecial:用于调用实例构造器
<init>()
方法、私有方法和父类中的方法 -
invokevirtual:用于调用所有的虚方法。
-
invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象
其实还有第五种为invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的(Java8的Lamda表达式)。先对这五种指令有个印象,为了解释上面关于Java多态埋下的坑,我们还需要再了解一个概念,非虚方法与虚方法。
4.非虚方法与虚方法
4.1 非虚方法
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。包括静态方法、私有方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用),它们在类加载的时候就可以把符号引用解析为该方法的直接引用。
4.2 虚方法
反之,在运行期间才能确定具体方法版本的方法称作虚方法。
5.静态分派与重载
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,Demo:
public class Test {
public void test(Grandpa grandpa) {
System.out.println("Grandpa");
}
public void test(Father father) {
System.out.println("Father");
}
public void test(Son son) {
System.out.println("Son");
}
public static void main(String[] args) {
Grandpa g1 = new Father();
Grandpa g2 = new Son();
Test test04 = new Test();
test04.test(g1);
test04.test(g2);
}
}
class Grandpa {
}
class Father extends Grandpa {
}
class Son extends Father {
}
main方法的字节码:
追踪#13:
发现形参为Grandpa ,Grandpa g1 = new Father(),我们把Grandpa称作做g1的静态类型(外观类型),Father称作g1的实际类型(运行时类型)。变量的静态类型在编译期间确定,在运行期不会发生变化,而变量的实际类型则是可以发生变化的(多态的一种体现), 实际类型是在运行期方可确定。所以说方法的重载是一种静态的行为,在编译期就可以完全确定。
6.动态分派与重写
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,Demo:
public class DynamicDispatch {
interface Human {
void sayHello();
}
static class Man implements Human {
@Override public void sayHello() {
System.out.println("Man sayHello");
}
}
static class Woman implements Human {
@Override public void sayHello() {
System.out.println("Woman sayHello");
}
}
public static void main(String[] args) {
Human man = new Man();
final Human woman = new Woman();
man.sayHello(); // Man sayHello
woman.sayHello(); // Woman sayHello
man = new Woman();
man.sayHello(); // Woman sayHello
}
}
main方法字节码:
分析字节码中 invokeinterface 指令即为确定方法调用版本的关键。invokevirtual 字节码指令的多态查找流程:
在运行期间确定实际的方法接收者
(1)到操作数栈顶,寻找栈顶元素,找到这个对象所指向的实际类型。
(2)如果寻找到了与常量池中描述符和名称都相同的方法,并且权限校验也是通过的,就返回该方法的直接引用,整个流程结束。
(3)如果找不到:按照继承的层次关系,从子类往上,在它的各个父类中重复该查找流程。
(4)如果最终都找不到:抛出java.lang.AbstractMethodError异常。
7.多态与虚方法表
虚方法表:在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此为了提高性能,JVM采用在类的方法区建立一个虚方法表来实现,使用索引表来代替查找。每个类中都有一个虚方法表,表中存放着各个方法的实际入口。虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的虚方法表也初始化完毕。如图所示:
如果类中重写了方法,那么调用的时候,就会直接在该类的虚方法表中查找,以提高代码的执行效率。
本文部分内容整理自:https://www.bilibili.com/video/BV1PJ411n7xZ?p=57
https://blog.csdn.net/oneby1314/article/details/107960414