在Java方法调用的过程中,JVM是如何知道调用的是哪个类的方法源代码呢?这就涉及到程序绑定这个概念。
程序绑定指的是一个方法的调用与方法所在的类(方法主体)关联起来。对Java来说,绑定分为静态绑定和动态绑定;或者叫做前期绑定和后期绑定。
在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。例如:C。
针对Java,可以简单地理解为程序编译期的绑定。这里特别说明一点,Java当中的方法只有final,static,private和构造方法是静态绑定。
请看如下Java代码:
//被调用的类
package hr.test;
class Father{
public static void f1(){
System.out.println("Father— f1()");
}
}
//调用静态方法
import hr.test.Father;
public class StaticCall{
public static void main(){
Father.f1(); //调用静态方法
}
}
上面的源代码中执行方法调用的语句Father.f1()
被编译器编译成了一条指令:invokestatic #13
。
我们看看JVM是如何处理这条指令的:
(1) 指令中的#13指的是StaticCall类的常量池中第13个常量表的索引项(关于常量池详见《Class文件内容及常量池 》)。这个常量表(CONSTANT_Methodref_info ) 记录的是方法f1信息的符号引用(包括f1所在的类名,方法名和返回类型)。JVM会首先根据这个符号引用找到方法f1所在的类的全限定名: hr.test.Father。
(2) 紧接着JVM会加载、链接和初始化Father类。
(3) 然后在Father类所在的方法区中找到f1()方法的直接地址,并将这个直接地址记录到StaticCall类的常量池索引为13的常量表中。这个过程叫常量池解析 ,以后再次调用Father.f1()时,将直接找到f1方法的字节码。
(4) 完成了StaticCall类常量池索引项13的常量表的解析之后,JVM就可以调用f1()方法,并开始解释执行f1()方法中的指令了。
通过上面的过程,我们发现经过常量池解析之后,JVM就能够确定要调用的f1()方法具体在内存的什么位置上了。实际上,这个信息在编译阶段就已经在StaticCall类的常量池中记录了下来。这种在编译阶段就能够确定调用哪个方法的方式,我们叫做静态绑定机制 。
除了被static修饰的静态方法,所有被private修饰的私有方法、被final 修饰的禁止子类覆盖的方法都会被编译成invokestatic指令。另外所有类的初始化方法< init >和< clinit >会被编译成invokespecial指令。JVM会采用静态绑定机制来顺利的调用这些方法。
在运行时根据具体对象的类型进行绑定。
若一种语言实现了后期绑定,同时必须提供一些机制,可在运行期间判断对象的类型,并分别调用适当的方法。也就是说,编译器此时依然不知道对象的类型,但方法调用机制能自己去调查,找到正确的方法主体。不同的语言对后期绑定的实现方法是有所区别的。但我们至少可以这样认为:它们都要在对象中安插某些特殊类型的信息。
看如下Java代码:
package hr.test;
//被调用的父类
class Father{
public void f1(){
System.out.println("father-f1()");
}
public void f1(int i){
System.out.println("father-f1() para-int "+i);
}
}
//被调用的子类
class Son extends Father{
public void f1(){ //覆盖父类的方法
System.out.println("Son-f1()");
}
public void f1(char c){
System.out.println("Son-s1() para-char "+c);
}
}
//调用方法
import hr.test.*;
public class AutoCall{
public static void main(String[] args){
Father father=new Son(); //多态
father.f1(); //打印结果: Son-f1()
}
}
上面的源代码中有三个重要的概念:多态(polymorphism) 、方法覆盖 、方法重载 。
打印的结果大家也都比较清楚,但是JVM是如何知道f.f1()调用的是子类Sun中方法而不是Father中的方法呢?在解释这个问题之前,我们首先简单的讲下JVM管理的一个非常重要的数据结构——方法表 。
在JVM加载类的同时,会在方法区中为这个类存放很多信息(详见《Java 虚拟机体系结构 》)。其中就有一个数据结构叫方法表。它以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址 。
下图是上面源代码中Father和Sun类在方法区中的方法表:
可以看出方法表有两个特点:
对于上面的源代码,编译器首先会把main方法编译成下面的字节码指令:
0 new hr.test.Son [13] //在堆中开辟一个Son对象的内存空间,并将对象引用压入操作数栈
3 dup
4 invokespecial #7 [15] // 调用初始化方法来初始化堆中的Son对象
7 astore_1 //弹出操作数栈的Son对象引用压入局部变量1中
8 aload_1 //取出局部变量1中的对象引用压入操作数栈
9 invokevirtual #15 //调用f1()方法
12 return
其中invokevirtual指令的详细调用过程是这样的:
(1) invokevirtual指令中的#15指的是AutoCall类的常量池中第15个常量表的索引项。这个常量表(CONSTANT_Methodref_info ) 记录的是方法f1信息的符号引用(包括f1所在的类名,方法名和返回类型)。JVM会首先根据这个符号引用找到调用方法f1的类的全限定名: hr.test.Father。这是因为调用方法f1的类的对象father声明为Father类型。
(2) 在Father类型的方法表中查找方法f1,如果找到,则将方法f1在方法表中的索引项11(如上图)记录到AutoCall类的常量池中第15个常量表中(常量池解析 )。
这里有一点要注意:如果Father类型方法表中没有方法f1,那么即使Son类型中方法表有,编译的时候也通过不了。因为调用方法f1的类的对象father的声明为Father类型。
(3) 在调用invokevirtual指令前有一个aload_1指令,它会将开始创建在堆中的Son对象的引用压入操作数栈。然后invokevirtual指令会根据这个Son对象的引用首先找到堆中的Son对象,然后进一步找到Son对象所属类型的方法表。过程如下图所示:
(4)这是通过第(2)步中解析完成的#15常量表中的方法表的索引项11,可以定位到Son类型方法表中的方法f1(),然后通过直接地址找到该方法字节码所在的内存空间。
很明显,根据对象(father)的声明类型(Father)还不能够确定调用方法f1的位置,必须根据father在堆中实际创建的对象类型Son来确定f1方法所在的位置。这种在程序运行过程中,通过动态创建的对象的方法表来定位方法的方式,我们叫做动态绑定机制 。
上面的过程很清楚的反映出在方法覆盖的多态调用的情况下,JVM是如何定位到准确的方法的。但是下面的调用方法JVM是如何定位的呢?(仍然使用上面代码中的Father和Son类型)
public class AutoCall{
public static void main(String[] args){
Father father=new Son();
char c='a';
father.f1(c); //打印结果:father-f1() para-int 97
}
}
问题是Fahter类型中并没有方法签名为f1(char)的方法呀。但打印结果显示JVM调用了Father类型中的f1(int)方法,并没有调用到Son类型中的f1(char)方法。
根据上面详细阐述的调用过程,首先可以明确的是:JVM首先是根据对象father声明的类型Father来解析常量池的(也就是用Father方法表中的索引项来代替常量池中的符号引用)。如果Father中没有匹配到”合适” 的方法,就无法进行常量池解析,这在编译阶段就通过不了。
那么什么叫”合适”的方法呢?当然,方法签名完全一样的方法自然是合适的。但是如果方法中的参数类型在声明的类型中并不能找到呢?比如上面的代码中调用father.f1(char),Father类型并没有f1(char)的方法签名。实际上,JVM会找到一种“凑合”的办法,就是通过 参数的自动转型 来找 到“合适”的 方法。比如char可以通过自动转型成int,那么Father类中就可以匹配到这个方法了 (关于Java的自动转型问题可以参见《【解惑】Java类型间的转型》)。但是还有一个问题,如果通过自动转型发现可以“凑合”出两个方法的话怎么办?比如下面的代码:
class Father{
public void f1(Object o){
System.out.println("Object");
}
public void f1(double[] d){
System.out.println("double[]");
}
}
public class Demo{
public static void main(String[] args) {
new Father().f1(null); //打印结果: double[]
}
}
null可以引用于任何的引用类型,那么JVM如何确定“合适”的方法呢。一个很重要的标准就是:如果一个方法可以接受传递给另一个方法的任何参数,那么第一个方法就相对不合适。比如上面的代码: 任何传递给f1(double[])方法的参数都可以传递给f1(Object)方法,而反之却不行,那么f1(double[])方法就更合适。因此JVM就会调用这个更合适的方法。
首先来看代码:
public class Father {
protected String name="父亲属性";
public void method() {
System.out.println("父类方法,对象类型:" + this.getClass());
}
}
public class Son extends Father {
protected String name="儿子属性";
public void method() {
System.out.println("子类方法,对象类型:" + this.getClass());
}
public static void main(String[] args) {
Father sample = new Son();//向上转型
System.out.println("调用的成员:"+sample.name);
}
}
运行结果:
调用的成员为父亲的属性
这个结果表明,子类的对象(由父类的引用handle)调用到的是父类的成员变量。所以必须明确,运行时(动态)绑定针对的范畴只是对象的方法。
现在试图调用子类的成员变量name,该怎么做?最简单的办法是将该成员变量封装成方法getter形式:
public class Father {
protected String name = "父亲属性";
public String getName() {
return name;
}
public void method() {
System.out.println("父类方法,对象类型:" + this.getClass());
}
}
public class Son extends Father {
protected String name="儿子属性";
public String getName() {
return name;
}
public void method() {
System.out.println("子类方法,对象类型:" + this.getClass());
}
public static void main(String[] args) {
Father sample = new Son();//向上转型
System.out.println("调用的成员:"+sample.getName());
}
}
Java的method overriding则发生在虚方法之间。调用虚方法时,Java采用的是延迟绑定 / 动态分派的语义,根据被调用对象(receiver)的实际类型来决定选择哪个版本的虚方法。
一个实例方法可以重写(override)在其超类中可访问到的具有相同签名的所有实例方法,从而使能了动态分派(dynamic dispatch);换句话说,JVM将基于实例的运行期类型来选择要调用的重写方法。重写是面向对象编程技术的基础,并且是唯一没有被普遍劝阻的名字重用形式:
class Base{
public void f(){}
}
class Derived extends Base{
public void f(){}
}
重写的特点:
1、覆盖的方法的标志必须要和被覆盖的方法的标志完全匹配,才能达到覆盖的效果;
2、覆盖的方法的返回值必须和被覆盖的方法的返回一致;
3、覆盖的方法所抛出的异常必须和被覆盖方法的所抛出的异常一致,或者是其子类;
4、被覆盖的方法不能为private,否则在其子类中只是新定义了一个方法,并没有对其进行覆盖。
在某个类中的方法可以重载(overload)另一个方法,只要它们具有相同的名字和不同的签名。由调用所指定的重载方法是在编译期选定的:
class CircuitBreaker{
public void f (int i){} //int overloading
public void f(String s){} //String overloading
}
有一个值得注意的地方:
例如如下代码:
public class Test {
public static void main(String[] args) {
Animal animal = new Animal();
animal.eat();
animal.eat(1);
animal.eat("1");
}
}
class Animal {
public void eat() {
}
public void eat(int a) {
}
public void eat(String a) {
}
}
对应的字节码:
public class com.example.tsnt.Test {
public com.example.tsnt.Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class com/example/tsnt/Animal
3: dup
4: invokespecial #3 // Method com/example/tsnt/Animal."":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method com/example/tsnt/Animal.eat:()V
12: aload_1
13: iconst_1
14: invokevirtual #5 // Method com/example/tsnt/Animal.eat:(I)V
17: aload_1
18: ldc #6 // String 1
20: invokevirtual #7 // Method com/example/tsnt/Animal.eat:(Ljava/lang/String;)V
23: return
}
可以看到其中这几行:
9: invokevirtual #4 // Method com/example/tsnt/Animal.eat:()V
14: invokevirtual #5 // Method com/example/tsnt/Animal.eat:(I)V
20: invokevirtual #7 // Method com/example/tsnt/Animal.eat:(Ljava/lang/String;)V
对于调用哪个重载的方法,编译阶段已经确定下来了,但是对于调用哪个类的方法编译阶段没有确定下来,所以显示的是invokevirtual,这个要等到运行时才能确定。
重载的特点:
1、在使用重载时只能通过不同的参数样式。例如,不同的参数类型,不同的参数个数,不同的参数顺序(当然,同一方法内的几个参数类型必须不一样,例如可以是fun(int, float), 但是不能为fun(int, int));
2、不能通过访问权限、返回类型、抛出的异常进行重载;
3、方法的异常类型和数目不会对重载造成影响;
4、对于继承来说,如果某一方法在父类中是访问权限是priavte,那么就不能在子类对其进行重载,如果定义的话,也只是定义了一个新方法,而不会达到重载的效果。
静态方法和属性是属于类的,调用的时候直接通过类名.方法名完成的,不需继承机制就可以调用如果子类里面定义了和父类声明一样的静态方法和属性,那么这时候父类的静态方法或属性称之为“隐藏”,你如果想要调用父类的静态方法和属性,直接通过父类名.方法名或变量名完成,至于是否继承一说,子类是有继承静态方法和属性,但是跟实例方法和属性不太一样,存在“隐藏”的这种情况。
多态之所以能够实现是依赖于继承和重写 、重载(继承和重写最为关键)。有了继承和重写就可以 实现父类的引用可以指向不同子类的对象。重写的功能是:“重写”后子类的优先级要高于父类的优先级,但是“隐藏”是没有这个优先级之分的。
结论:静态属性、静态方法和非静态的属性都可以被继承和隐藏而不能够被重写,因此不能实现多态,不能实现父类的引用可以指向不同子类的对象。非静态的方法可以被继承和重写,因此可以实现多态。
附:
查看字节码的方法:查看Java字节码的方法
参考:
1.java中静态属性和和静态方法的继承问题 以及多态的实质
2.java的动态绑定与静态绑定
3.【解惑】Java动态绑定机制的内幕
4.Java的函数重载为什么采取静态静态绑定而非动态绑定?