方法调用执行模型
Java的方法调用执行模型在宏观上通过栈模型实现的。每一个方法都可以看做一个栈帧。每当有方法被调用执行,就把该方法的栈帧入栈,方法执行完毕时出栈。
public class Test {
public void method1() {
method2();
}
public void method2() {
System.out.println("method2");
}
public static void main(String[] args) {
Test t1 = new Test();
t1.method1();
}
}
这段代码执行时当前线程的栈流程图如下。
方法栈帧结构
一个方法的栈帧中主要包括“局部变量表”,“操作栈”,“动态连接”,“返回地址”等信息。
局部变量表
在方法中定义,且作用域仅在方法域内的变量为局部变量,栈帧中的局部变量表就是用来保存局部变量的。
操作栈
在方法执行的过程我们的程序基本都是在进行取值,赋值,计算的操作。Java把方法内字节码的执行模型也定义为一个栈模型。JVM通过操作这个栈中的数据执行方法的字节码。
例如要执行一个a = b + c的代码。JVM的大体执行过程是先从内存中读取b的值压入栈中,接着读取c的值压入栈中。然后执行+操作,把栈中最上面的两个值取出,相加并把结果压入栈中。最后从栈中取最上层的值赋给a。方法是依靠操作栈执行的。
操作栈的深度由虚拟机根据方法的具体执行逻辑进行计算。
动态连接
Java强大的多态能力就是依靠动态连接工作的。在Java类加载时期的连接阶段,许多符号引用都会变为直接引用,通俗的说就是把字面量变成指向实际内容的指针。private方法和静态方法在这个时间段与他们定义好的方法相连了,但是普通的public方法却没有变为直接引用,而是在方法执行时动态的连接到具体的对象所特有的方法。当我们用父类的引用调用子类实例的方法时,JVM会动态的把该方法的引用连接到子类实例的方法入口,这样就实现了多态。
动态连接之中储存在运行时才进行转换的直接引用。
返回地址
每个方法最终都会执行完毕,之后程序会跳到调用这个方法的地方继续向下执行。记录这个跳跃目标就是返回地址的责任。返回地址让每个方法都有序的执行。
方法调用
方法调用的过程分为解析和分派两个过程。
解析
在Java程序中我们通过方法名调用特定方法,为什么通过方法名就能找到具体方法呢?在class文件中每个方法名只是一个字面量而已,把这个字面量变成对具体方法的引用就是解析的过程。
Java方法大体分为静态方法,私有方法,final方法和其他方法。前三种方法因为它们的特性(无法改变,专属于特定对象,无法继承扩展)所以在类加载的连接阶段就被虚拟机把方法名和具体实现解析相连了。
除了以上三种方法之外的方法称之为“虚方法”。虚方法调用的指令和上述三种方法不同,而且虚方法的引用解析是在运行时进行的,这是为了实现Java的多态性所作的努力。
public class Test {
public void method1() {
System.out.println("Test method1");
}
public static void main(String[] args) {
Test t1 = new TestG2();
t1.method1();
}
}
class TestG2 extends Test {
@Override
public void method1() {
System.out.println("TestG2 method1");
}
}
在上面代码中method1()方法就是虚方法,它的解析连接是在运行时动态进行的。这样做的结果就是t1.method1()语句运行结果是”TestG2 method1”。虽然引用类型是Test,但是对于method1()方法的连接是在运行时解析的,这时JVM会找到t1引用的具体实例,然后连接到它的method1()方法,因为t1引用的对象其实是TestG2类型的对象,所以最后执行的结果也就是TestG2类中覆盖后的方法内容。
如果虚方法的解析是在类加载时就定好的,那么Test类型引用的对象调用方法时只能获得Test类中所定义的方法,这样就无法体现Java语言的多态能力了。
分派
通过解析,我们可以通过方法名得到恰当的具体方法,具有了方法调用的前提。接下来要做的事就是选择要调用的方法,把不同的调用分派到不同的方法。
Java中的方法有两个特别的操作:覆盖(Override)和重载(Overload)
覆盖是在多个类层次中体现的,由子类覆盖重写父类已经拥有的方法,除了方法体其他的标识都不能改变。
重载是在单个类层次中体现的,在一个方法中通过修改方法的参数表(和返回类型)得到两个方法名相同的方法。
JVM在我们调用方法时从覆盖和重载的各个方法中进行选择的过程就是分派。
分派又分为静态分派和动态分派两种。
public class Test {
public void method1() {
System.out.println("Test method1");
}
public void method1(String str) {
System.out.println("Test method1" + str);
}
public static void main(String[] args) {
Test t1 = new TestG2();
Test t2 = new TestG3();
t1.method1();
t2.method1();
}
}
class TestG2 extends Test {
@Override
public void method1() {
System.out.println("TestG2 method1");
}
@Override
public void method1(String str) {
System.out.println("TestG2 method1" + str);
}
}
class TestG3 extends Test {
@Override
public void method1() {
System.out.println("TestG3 method1");
}
@Override
public void method1(String str) {
System.out.println("TestG3 method1" + str);
}
}
动态分派
动态分派强调的是从覆盖的方法中分派合适的方法。代码块中Test t1 = new TestG2()和Test1 t2 = new TestG3()中的两个不同的实例体现了动态分配的特征。两个实例引用类型虽然是同样Test类型,但是引用的对象类型则是不同类型类型,于是t1和t2引用调用的虚方法会被JVM分派给他们指向的实际对象类型,于是同一类型的两个引用调用同意方法出现了不同的结果。并且因为这个分配的结果是在实际运行时得出的,所以成为动态分派。
静态分派
静态分派强调的是从重载的方法中分派合适的方法。代码块中Test t1 = new TestG2()和Test1 t2 = new TestG3()中的同样的Test类型引用体现了静态分配的特征。不论引用的具体实例是什么类型,由于引用类型是Test类型的,所以在方法调用时只能调用Test类中所定义的方法。虽然最终调用结果可能由于动态结果而不同,但是调用的方法的特征必须是Test类中的。JVM根据方法调用时根据传入参数不同分派给Test类中的重载方法就是静态分配的过程。只是这个分派的过程在编译期根据源代码就能确定下来,所以称为静态分派。
单分派和多分派
根据方法分派时依据的宗量不同,分派又分为单分派和多分派。
动态分派时只需根据实例类型这一因素选择分派即可,所以它只有一个宗量,是单分派。
静态分派时需要根据引用类型和参数表两个因素才能确定分派的方法,所有它有两个宗量,是多分派。
Java是一种静态多分派,动态单分派语言。
执行
通过调用获得了恰当方法,然后才是执行方法的过程。
Java有两种执行方式,分别为解释执行和编译执行。
一般的方法都会被JVM通过词法分析,语法分析变成抽象语法树,然后由JVM解释执行。
如果一个方法被多次调用,那么JVM就会使用内置的编译器(如JIT)把该方法的字节码编译为机器码,让计算机直接执行。执行机器码可以大大提高方法执行速度,因此编译执行是Java中的一种方法执行优化。