`
喻红叶
  • 浏览: 39393 次
  • 性别: Icon_minigender_1
  • 来自: 哈尔滨
社区版块
存档分类
最新评论

Java的方法调用机制

 
阅读更多

在Java中,方法调用是非常基本、非常频繁的行为,但是Java中方法是怎么调用的呢?我们先来看一下Java中的类方法和实例方法。

类方法和实例方法

根据Java语言规范(JLS 3th),静态方法的规定为:
被声明为static的方法叫做类方法(class method),类方法的调用不需要任何该类的实例,在类方法中不能使用关键字this和super,也不能使用类型参数,否则会得到一个编译期错误。
实例方法的规定为:
没有被声明为static的方法叫做实例方法(instance method),或者叫做非静态方法(non-static method)。实例方法总是需要类的对象来调用,在实例方法的内部,可以使用this来表示当前调用该方法的对象,还可以使用关键字super。(以上内容是我自己理解的,不代表规范)
在概念中的JVM上:方法调用时,对应着一个栈帧进入到虚拟机栈中,栈帧中存储了方法的局部变量表,操作数栈等信息。其中,局部变量表用于存放方法参数和方法内定义的局部变量,所有参数按顺序存放在局部变量表中的连续区域。在调用实例方法时,局部变量0默认用于存放传递方法所属对象实例的引用(this),所有参数从局部变量1开始存放。而类方法不需要传递实例引用,所以它们不需要使用第0个局部变量来保存关键子this,类方法在保存参数到局部变量表时,是从编号0的局部变量开始,而不是1。从效果上看,在调用实例方法时,相当于把this作为第一个参数传递给被调用函数。

Java支持重载,支持重写,由于Java是提倡使用面向对象的编程方式进行编程,多态也就成了非常普遍的行为。那么,JVM是如何确定方法的版本的呢?这就涉及到Java中方法的调用。方法的调用并不等同于方法的执行,方法调用阶段唯一的任务就是确定被调用方法的版本(也就是调用哪一个方法),暂时不涉及方法内部的具体运行过程。我们知道,Java程序编译后生成的是Class文件,Class文件的编译过程并不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都是符号引用,而不是方法在实际运行时内存布局中的入口地址。这也使得Java的方法调用过程变得相对复杂起来,有些方法在类加载过程的解析阶段被确定,有些甚至要等到实际运行时才能确定。

解析

类的加载过程包括以下五个步骤:加载-->验证-->准备-->解析-->初始化,其中,验证、准备、解析三个部分统称为连接。在涉及方法调用中,解析调用就是指在类加载的解析阶段把涉及的符号引用全部转换为可确定的直接引用,这种转换能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个调用版本在运行期间不可变。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用就称为解析。
那么哪些方法的可能调用版本总是固定的一个呢?就是那些无法被重写、无法产生多态行为的方法,这些方法在Java中被称为非虚方法。那么Java中哪些方法是非虚的呢?

在Java虚拟机规范第三版中,规定了5条方法调用指令

invokestatic:调用静态(类)方法。
invokespecial:调用实例方法,特化于super方法调用(父类方法),private方法及实例构造器方法<init>
invokevirtual:调用一般实例方法(包括声明为final,但不为private的实例方法)。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象重写的具体方法
invokedynamic:调用动态方法(Java7中新增)

被invokestatic和invokespecial调用的方法都是非虚方法,符合这些条件的有:静态方法,私有方法,实例构造器,父类方法。虽然声明为final的方法是用invokevirtual指令调用的,但是我们知道,final方法不能被重写,无法产生多态行为,final方法的行为是非虚的,因此Java虚拟机规范明确说明了final方法是非虚方法(至于为什么用invokevirtual质量来调用,据说是出于其他方面的考虑)。
通过上面的分析,我们已经知道了,非虚方法是无法产生多态行为的,在编译期间就能完全确定方法的版本,在类加载的解析阶段就把涉及的符号引用全部转换为直接引用,没有必要延迟到运行期再去确定。

分派

变量被声明时的类型叫做变量的静态类型(static type),又叫做外观类型(Apparent Type)。
变量所引用对象的真实类型叫做变量的实际类型(Actual Type)。
Object obj = new Random();
obj的静态类型是Object,它的实际类型是Random。一个变量的静态类型是不能改变的,在编译期就完全确定下来,就像变量obj,无论怎样,在同一个作用域内,它只能是Object类型,当然了在使用的时候,还是可以对它进行强制类型转换,如:
SomeClass.someMethod((Random)obj);
强制类型转换也并没有改变变量的静态类型,其效果相当于:
Random rand = (Random)obj;
SomeClass.someMethod(rand);
变量的实际类型是可以改变的:
obj = new String("abc");
变量的实际类型是什么需要到运行期才能确定,编译器无法获得一个变量的实际类型,在编译器看来,obj就是Object类型。
变量的静态类型和实际类型的概念是比较基础,也比较重要,Java的分派就是根据对象的类型,而对方法进行选择,也就是说,选择哪个版本的方法,是根据对象的类型(静态类型,实际类型)来确定的。
一个方法所属的对象叫做方法的接收者,方法的接收者和方法的参数统称为方法的宗量(宗量这个词是《Java与模式》的作者提出的)。上面的概念比较多,好在都比较简单明了。
静态分派

记住这句话:静态分派发生在编译期,分派是根据静态类型信息发生。静态分派发生在编译阶段,因此静态分派的动作不是由虚拟机来执行的;静态分派是根据接收者的静态类型和方法参数的静态类型来定位方法执行版本的。静态分派的典型应用是方法重载

public class StaticDispatch {
	  static class Animal {}
	  static class Cat extends Animal {}
	  static class Dog extends Animal {}
	  
	  public void enjoy(Animal obj) {
		     System.out.println("动物的叫声,至于是哪种动物,还无法确定");
	  }

	  public void enjoy(Cat obj) {
	     System.out.println("喵喵喵喵~");
	  }

	  public void enjoy(Dog obj) {
	     System.out.println("汪汪汪汪~");
	  }
	  
	  public static void main(String[] args) {
		   Animal dog = new Dog();
		   Animal cat = new Cat();

		   StaticDispatch receiver = new StaticDispatch();
		   
		   receiver.enjoy(dog);//point1
		   receiver.enjoy(cat);//point2

		   receiver.enjoy((Dog)dog);//point3
		   
		   dog = new Cat();
		   receiver.enjoy(dog);//point4
	}
}

记住:静态分派是在编译期,根本不涉及对象的实际类型,所以无论是方法的接收者还是参数,静态分派都仅仅是根据它们的静态类型来定位方法的执行版本的。就用这条准则,我们来分析上面程序的运行结果:
(1)receiver的静态类型是StaticDispath,在编译器看来,执行的enjoy方法应该是StaticDispatch类的;
(2)在方法接收者已经确定的情况下,选择enjoy的哪个版本,完全取决于参数的静态类型。编译器只知道dog和cat的静态类型是Animal,所以执行的是enjoy(Animal)方法;
(3)在point3处,参数的静态类型变成了Dog,所以会执行enjoy(Dog),等同于Dog tmp = (Dog)dog;receiver.enjoy(tmp)
(4)在point4处,虽然dog的实际类型变成了Cat,但是在编译器看来,dog仍然是只Animal,所以会执行enjoy(Animal)。
在《Effective Java》的第41条,作者建议:慎用重载。重载会产生混乱。请想一想,下面的程序的输出是怎样的?

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');
	}
}

动态分派
在运行期根据实际类型确定方法执行版本的本派过程叫做动态分派。记住:动态分派选择的依据是被调用方法所在对象的运行时类型,也就是接收者的实际类型。方法参数的类型在动态分派选择时将不再起到作用。动态分派的重要体现是重写。看下面的程序

public class DynamicBinding {
	static class Animal {
		public void enjoy() {
			System.out.println("动物的叫声,至于是哪种动物,还无法确定");
		}
	}

	static class Dog extends Animal {
		public void enjoy() {
		    System.out.println("汪汪汪汪~");
		}
	}

	static class Cat extends Animal {
		public void enjoy() {
		    System.out.println("喵喵喵喵");
		}
	}
	
	public static void main(String[] args) {
		Animal dog = new Dog();
		Animal cat = new Cat();
		
		cat.enjoy();//Dog.enjoy()
		dog.enjoy();//Dog.enjoy()
	}
}

在前面,我们明确了非虚方法和虚方法的区别,可以重写的方法一定是虚方法,在调用虚方法时使用的指令是invokevirtual,invokevirtual指令的运行时解析过程大致分为以下步骤(极简描述):
(1)找到操作数栈顶的第一个元素所指向对象的实际类型,记作C。也就是找到接收者的实际类型。
(2)如果在类型C中找到相符的方法,进行校验,返回方法的直接引用,查找结束,校验不通过,抛异常。
(3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和校验过程。
(4)如果始终没找到,抛异常。
我们看到,invokevirtual指令第一步就是找接受者的实际类型,所以在两次调用invokevirtual指令时,分别把cat.enjoy()和dog.enjoy()中方法的符号引用解析到了不同的直接引用。

单分派和多分派
方法的接收者和方法的参数统称为方法的宗量,根据分派基于多少宗量,又可以分成单分派和多分派。这些概念很绕口,也不是那么直观,但是有了上面的基础,即使不看实例代码也可以很轻松的理解单分派和多分派。
(1)静态分派中,编译器要判断接受者的静态类型,确定是哪个类上的方法,这个时候已经判定了接受者这个宗量了;
(2)编译器还需要确定参数的静态类型,已确定重载方法中的版本,这个时候又判定了参数这个宗量。
所以静态分派是多分派。
(3)到了运行期,虚拟机首先就是判断接收者的实际类型,去其上搜索匹配的方法,只判定了接收者的实际类型,方法参数的类型已经不重要了。
所以动态分派是单分派。还是通过一个直观的例子来说明以下单分派和多分派,也算是一个总结
:

public class HardChoice {
   static class QQ {}
   static class T_QQ extends QQ {}
   static class _360 {}

   static class Father {
      public void hardChoice(QQ arg) {
         System.out.println("father choose qq");
      }

      public void hardChoice(T_QQ arg) {
         System.out.println("father choose t_qq");
      }

      public void hardChoice(_360 arg) {
         System.out.println("father choose 360");
      }
   }

   static class Son extends Father {
      public void hardChoice(QQ arg) {
         System.out.println("son choose qq");
      }

      public void hardChoice(T_QQ arg) {
         System.out.println("son choose t_qq");
      }

      public void hardChoice(_360 arg) {
         System.out.println("son choose 360");
      }
   }

   public static void main(String[] args) {
       QQ t_qq = new T_QQ();

       Father father = new Father();
       Father son = new Son();//point1

       father.hardChoice(new _360());
       son.hardChoice(new QQ());//point2
       son.hardChoice(t_qq);//point3
   }
}

在面对一个艰难选择的时候,每个人都有自己选择的权利,在民主的社会里,即使是父亲也不能代替儿子做选择。我们以point3为主,分析一下调用的过程:
(1)在编译期,选择目标方法的依据有两点:接收者的静态类型是Father还是Son,接收者son的静态类型是Father;参数的静态类型是什么,参数t_qq的静态类型是QQ。这次选择的结果,invokevirtual指令的参数为常量池中Father.hardChoice(QQ)的符号引用。
(2)在类加载的解析阶段,判断hardChoice()方法是不是虚方法,一看是虚方法,没办法在这里将符号引用转换成直接引用了。
(3)运行时,判断接受者的实际类型,确定是Son,将方法的符号引用直接解析到Son.hardChoice(QQ)方法的直接引用,不去理会参数t_qq的类型,参数的静态类型和实际类型都不会对方法的选择造成仍和影响。

在Java7及以前的版本中,Java是静态单多分派(接收者和参数的静态类型)、动态单分派(接收者的实际类型)语言。

参考资料:《深入理解Java虚拟机》,《Java与模式》《Effective Java》,http://rednaxelafx.iteye.com/blog/652719

分享到:
评论

相关推荐

Global site tag (gtag.js) - Google Analytics