上一篇:
浅谈JVM(一):Class文件解析
浅谈JVM(二):类加载机制
浅谈JVM(三):类加载器和双亲委派
浅谈JVM(四):运行时数据区
浅谈JVM(五):虚拟机栈帧结构
6.方法调用过程
Java是一门面向对象语言,具有封装、继承、多态的特性。通过继承或其他方式,可以将一个方法重写出多个版本。在方法代码执行之前,确认被调用方法的版本,这个过程就是方法调用(Method Invocation)。方法调用可以从解析(Resolution)和分派(Dispatch)两个方面进行了解。
6.1.解析
方法调用的目标方法在Class文件里是常量池中的符号引用(详见浅谈JVM(一):Class文件解析),在类加载的解析阶段会将部分符号引用转化为直接引用,在编译时就已经确定好要调用的方法版本,即"编译期可知,运行期不可变",这类方法的调用就是解析(Resolution)。
能通过解析调用的典型方法有静态方法、私有方法、构造方法、父类的方法,以及final修饰的方法。因为静态方法是类相关,不会被重写;私有方法在外部不能访问,也不会被重写;构造方法不会被重写;父类的方法指子类能继承和重写的方法;final修饰的方法不能被重写。所以以上五种方法在编译时就能确定唯一执行的版本,在类加载时就能把符号引用解析成直接引用,这些方法统称为"非虚方法"(Non-Virtual Method),与之相反的其他方法都称为"虚方法"(Virtual Method)。
Java虚拟机里有5条字节码指令,用于调用不同类型的方法:
- invokestatic:用于调用静态方法;
- invokespecial:用于调用实例构造器、私有方法和父类的方法(如通过super调用的);
- invokevirtial:用于调用所有虚方法,和final方法(尽管它是非虚方法);
- invokeinterface:用于调用接口方法,在运行时再确定一个实现该接口的对象;
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
package com.menglaoshi.test;
/**
* @author 专治八阿哥的孟老师
*/
public class TestClass02 {
public static void staticMethod(){}
private void privateMethod(){}
public void test(){
staticMethod();
privateMethod();
}
}
解析调用在编译阶段就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成,是一个静态过程。另一种更为复杂的方法调用是分派(Dispatch)。
6.2.分派
分派(Dispatch)调用过程与Java的多态特性紧密相关,如方法重载和方法重写。根据方法调用是在编译器确定还是运行期确定,分派可以分为静态分派和动态分派;根据调用者和参数数量,又可以分成单分派和多分派。
6.2.1.静态分派
package com.menglaoshi.test;
/**
* @author 专治八阿哥的孟老师
*/
public class StaticDispatch{
public void sayHello(Human guy){
System.out.println("hello,Human");
}
public void sayHello(Man guy){
System.out.println("hello,Man");
}
public static void main(String[] args) {
Human man=new Man();
StaticDispatch dispatch=new StaticDispatch();
dispatch.sayHello(man);
}
}
上面代码的两个sayHello()方法是典型的方法重载,测试代码的运行结果是"hello,Human"。我们要从虚拟机的角度对这个结果产生的原因进行分析。
Human man = new Man();
上面代码中,Human称为变量的静态类型(Static Type)或外观类型(Apparent Type),后面的Man是变量的实际类型(Actual Type)或运行时类型(Runtime Type)。外观类型和实际类型都可能发生变化,外观类型的变化是在编译期可知的,实际类型变化是运行时才确定的。
public static void main(String[] args) {
StaticDispatch dispatch = new StaticDispatch();
//实际类型变化,运行时才指导是Human还是Man
Human man = (new Random().nextBoolean()) ? new Human() : new Man();
//外观类型,编译器就知道最终转成Man(运行时可能报错)
dispatch.sayHello(man);
}
变量man的实际类型是根据随机生成的布尔值决定的,而其外观类型Human是编译期可知,在使用时通过强制类型转换改变这个外观类型,仍然是编译期可知的(如果实际类型是Human,强制转换成Man会在运行时报错ClassCastException)。
编译器遇到重载方法时,是根据外观类型作为判定依据的。靠外观类型(静态类型)决定方法执行版本的分派动作称为静态分派,也可以把这一部分归为解析。
静态分派确认的方法版本不是唯一的,编译器会推断一个相对合适的版本。
package com.menglaoshi.test;
/**
* @author 专治八阿哥的孟老师
*/
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
上面的代码运行后会输出"hello char",这没有什么疑问。
注释掉sayHello(char arg)方法,运行得到"hello int",因为’a’还可以代表97(Unicode)。
注释掉sayHello(int arg),输出变为"hello long",char再转换成int后进一步向上转型成long。
继续注释掉sayHello(long arg) 结果变为"hello Character",char类型的’a’被封装成了对象类型。
注释掉sayHello(Character arg) 输出变成了"hello Serializable",这是因为java.lang.Character实现了java.lang.Serializable接口。
如果继续注释掉sayHello(Serializable arg) ,输出结果变成了"hello Object"。
注释掉sayHello(Object arg) ,最后输出"hello char…"
如果有多个父类,会在继承关系中从下向上开始搜索,越接近上层,优先级越低,参数传入null也可以。
6.2.2.动态分派
运行期间通过实际类型来选择方法版本的过程叫做动态分派。动态分派与方法重写有密切关系。
public abstract class Human {
abstract void sayHello();
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
class Man extends Human {
@Override
void sayHello() {
System.out.println("man say hello");
}
}
class Woman extends Human {
@Override
void sayHello() {
System.out.println("woman say hello");
}
}
上面代码的运行结果如下:
man say hellowo
man say hello
woman say hello
这是典型的方法重写后的运行结果。man和woman两个变量的外观类型都是Human,但实际类型不同,导致调用的方法不同。从虚拟机的角度分析为何有这样的结果,关键字节码如下:
从字节码看,invokevirtual调用的方法是常量池中的#6常量,#6常量是Human类的sayHello()。
表面上看通过man和woman两个变量调用的方法是一样的,但实际上invokevirtual命令在解析时会分多个步骤:
- 记当前栈顶引用的实际类型为C,常量池中描述的方法为M,如果类C中有重写(overrides)的方法M,则执行方法M;
- 否则按照继承关系从下往上,直到找到了重写的方法M,访问权限校验通过,执行方法M;
- 如果子类都没有重写方法M,而最初声明M的父类中,M不是抽象的,则执行M。
虚拟机规范中的对invokevirtual执行的方法的形容是"a declaration for an instance method that overrides the resolved method",此处overrides的含义是:
记父类为A,子类为C,父类方法Ma,子类方法Mc;
C是A的子类,Mc与Ma有同样的方法描述,Mc不能是private的;
Ma是public、protected、或者没有访问修饰符且和C在同一个package下;或者Mc重写了一个方法m’,m’重写了Ma;
invokevirtual在执行时,是通过实际类型来确定方法版本的,即方法重写的本质。
需要注意的是,Java中只有虚方法的概念,没有虚字段。当子类声明了与父类同名的字段时,子类的字段会覆盖掉父类的字段,在访问权限允许的条件下,可以通过super访问父类的字段,且子类覆盖父类字段,不要求是同一个类型。字段不存在多态,假设通过类C的方法访问字段F,那么看到的F就是C类中能看到的字段:如果C中没声明F,F就是父类的;如果C中声明了F,那么F就是子类中的。
public class Parent {
String a;
public Parent() {
a = "P";
System.out.println("P:" + a);
test();
}
public void test() {
System.out.println("Parent:" + a);
}
public static void main(String[] args) {
new Child();
}
}
public class Child extends Parent {
String a;
public Child() {
a = "C";
System.out.println("C:" + a);
test();
}
public void test() {
System.out.println("Child:" + a);
}
}
上面代码输出结果如下:
P:P
Child:null
C:C
Child:C
首先构造子类对象要先隐式初始化父类,构造方法不能重写,所以执行Parent方法的时候,变量a被赋值成"P",通过Parent访问到的a是Parent类中的a,输出了"P:P";然而test()方法被子类重写了,当前构造对象的动作是由子类触发的,test()方法是虚方法调用,调用的是当前子类中的方法,看到的字段a应该是子类中的,而此时子类构造方法还没有执行,所以输出了"Child:null"。
6.2.3.单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,基于一个宗量进行的分派称为单分派,基于多个宗量的分派称为多分派。通过下面代码来进行演示:
public class SuperClass {
public void test(String arg) {
System.out.println("super string");
}
public void test(int arg) {
System.out.println("super int");
}
}
public class SubClass extends SuperClass {
public void test(String arg) {
System.out.println("sub string");
}
public void test(int arg) {
System.out.println("sub int");
}
public static void main(String[] args) {
SuperClass c = new SubClass();
c.test(1);
}
}
上面代码的运行结果是"sub int",毫无疑问。在编译阶段的分派过程是静态分派,此时确定方法版本首先要调用者类型,其次要根据参数选择执行哪个重载的方法,是通过两个宗量确定方法版本的,所以静态分派是多分派。
在运行阶段的分派是动态分派,因为编译阶段已经确定调用test(int)了,执行虚方法时只需要知道调用者的实际类型是SuperClass还是SubClass即可,不需要看方法的参数列表,所以只需要知道一个宗量,动态分派是单分派。
综上,Java是一门静态多分派、动态单分派的语言。
6.2.4.虚拟机动态分派实现
虚方法的执行要根据继承关系向上查找类型的元数据,为了提高运行效率,避免反复搜索,虚拟机在方法区中建立了一个虚方法表(Virtual Method Table,简称vtable),接口用到的方法表叫Interface Method Table(简称itable)。使用虚方法表索引代替元数据搜索,以提高性能。虚方法表中存放着各方法的实际入口,如果方法没有被重写,那子类虚方法表中的地址入口和父类的是一样的,指向父类实现的入口。
具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
最后,下面代码输出什么?
public class Father {
private int i = method();
private static int j = staticMethod();
static {
System.out.println(1);
}
public Father() {
System.out.println(2);
}
{
System.out.println(3);
}
public int method() {
System.out.println(4);
return 1;
}
public static int staticMethod() {
System.out.println(5);
return 1;
}
}
public class Son extends Father {
private int i = method();
private static int j = staticMethod();
static {
System.out.println(6);
}
public Son() {
System.out.println(7);
}
{
System.out.println(8);
}
public int method() {
System.out.println(9);
return 1;
}
public static int staticMethod() {
System.out.println(10);
return 1;
}
public static void main(String[] args) {
new Son();
new Son();
}
}