[JVM] 2.6 虚拟机执行子系统:方法调用


本篇博客内容基本出自《深入理解java虚拟机》
代码编译的结果从本地机器码转变为字节码,是存储格式发展的—小步,却是编程语言发展的一大步。

3. 方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,但前面已经讲过, Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)它这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

3.1 解析

所有方法调用中的目标方法在Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种分析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution) 。

在Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

在Java虚拟机中提供了5条方法调用字节码指令:

  • invokestatic : 调用静态方法
  • invokespecial:调用实例构造器方法、私有方法、父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
  • invokedynamic:先在运行时动态解析出点限定符所引用的方法,然后再执行该方法,在此之前的4条调用命令的分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

非虚方法:只要能被invokestatic和invokespecial指令调用的方法,都可以在类加载的时候把符号引用解析为该方法的直接引用。这里主要是指,私有方法,静态方法,实例构造器,父类方法.(Java中明确说明了final方法是一种非虚方法,虽然被invokevirtual调用,但它无法被覆盖,没有其它版本)

虚方法,被invokevirtual和invokeinterface调用(除去final方法)的则为虚方法,因为在编译期间并不能确定要调用的真正方法,所以称为虚方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

3.2 分派

分派( Dispatch) 调用可能是静态的也可能是动态的,根据分派依据的宗量数e可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4 种分派组合

3.2.1 静态分派

/**
 * 方法静态分派演示
 */
public class StaticDispatch {
     static abstract class Human {}  

        static class Man extends Human {}  

        static class Woman extends Human {}  

        public void sayHello(Human human) {  
            System.out.println("hello,human");  
        }  

        public void sayHello(Man man) {  
            System.out.println("hello,man");  
        }  

        public void sayHello(Woman woman) {  
            System.out.println("hello,woman");  
        }  

        public static void main(String[] args) {  
            Human man = new Man();  
            Human woman = new Woman();  

            StaticDispatch sr = new StaticDispatch();  
            sr.sayHello(man);  
            sr.sayHello(woman);  
        }  
}

运行结果:

hello human
hello human

Human man = new Man();

代码里的Human称为静态类型(或者叫外观类型):其变化仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译期可知的。

而Man称为实际类型:其变化的结果在运行期才可确定,编译器不编译程序时并不知道一个对象的实际类型是什么。

//实际类型变化  
Human man = new Man();  
man = new Woman();  

//静态类型变化  
sr.sayHello((Man)man);  
sr.sayHello((Woman)man);  

代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以结果会是这样子。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派典型的应用是方法重载,.静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的 。对于方法参数的匹配也是根据变量的静态类型来确定,在很多情况下根据参数的类型并不能找到唯一的方法调用

sayHello(‘a’);
基本类型是重载按char->int->long->float->double顺序匹配的,可变参数的重载优先级是最低的。

解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,静态方法会在类加载期就进行解析而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

3.2.2 动态分派

public class DynamicDispatch {
     static abstract class Human {  
            protected abstract void sayHello();  
        }  

        static class Man extends Human {  
            @Override  
            protected void sayHello() {  
                System.out.println("man say hello");  
            }  
        }  

        static class Woman extends Human {  
            @Override  
            protected void sayHello() {  
                System.out.println("woman say hello");  
            }  
        }  

        public static void main(String[] args) {  
            Human man = new Man();  
            Human woman = new Woman();  
            man.sayHello();  
            woman.sayHello();  

            man = new Woman();  
            man.sayHello();  
        }
}

运行结果:

man say hello
woman say hello
woman say hello

显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量在两次调用中执行了不同的方法。

导致这个现象的原因:是这两个变量的实际类型不同。

invokevirtual指令的运行时解析过程大致分为以
下几个步骤;

  1. 找到操作数栈顶的第—个元素所指向的对象的实际类型;记作C
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2 步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.fang. AbstractMethodError 异常。

invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言方法重写的本质。这种在运行期间根据实际类型确定方法执行版本的过程称为动态分派。

动态分派的一个重要体现就是方法的重写,虽然父类引用可以指向子类对象,但是动态分派的方法调用是在运行时根据对象的实际类型去确认的.

3.2.3 单分派和多分派

方法的接收者和方法的参数统称为宗量。可以根据宗量将分派划分为单分派和多分派。单分派是根据一个宗量对对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。

/** 
 * 单分派,多分派演示 
 */  
public class Dispatch {  
    static class QQ{}  
    static class _360{}  

    public static class Father {  
        public void hardChoose(QQ args) {  
            System.out.println("父亲选择 qq");  
        }  
        public void hardChoose (_360 args) {  
            System.out.println("父亲选择 360");  
        }  
    }  

    public static class Son extends Father {  
        public void hardChoose(QQ args) {  
            System.out.println("儿子选择 qq");  
        }  
        public void hardChoose (_360 args) {  
            System.out.println("儿子选择 360");  
        }  
    }  

    public static void main(String[] args) {  
        Father father = new Father();  
        Father son = new Son();  
        father.hardChoose(new _360());  
        son.hardChoose(new QQ());  
    }

运行结果:

父亲选择 360
儿子选择 qq

编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。产生了两条invokevirtual指令,分别指向常量池中的Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoose(new QQ())”这句代码时(准确的说是在执行这句代码所对应的invokevirtual指令),由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机不会关心传递过来的参数”QQ”是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

至今为止,Java语言是一门静态多分派,动态单分派的语言。

3.2.4 虚拟机动态分派的实现

由于动态分派是非常频繁的,而且动态分派的方法版本选择过程需要在运行时在类的方法元数据中搜索合适的目标方法。虚拟机在实际实现动态分派基于性能的考虑,会在jvm在实现层面提供了一个叫做虚方法表的索引来代替元数据查找以提高性能。虚方法表结构图:


虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果重写了这个方法,子类方法表中的地址将会替换指向子类实现版本的入口地址。

Father是父类son是子类,并且子类重写了父类的连个方法,hardChoice(QQ),hardChoice(_360),因此子类中的这两个方法指向了Son的类型数据,而这两个类都继承自Object且没重写它的任何方法,因此都指向了Object的类型数据。

为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

上文中笔者说方法表是分派调用的“稳定优化”手段,虚拟机除了使用方法表之外,在条件允许的悄况下,还会使用内联缓存(lnline Cache) 和基于“类型继承关系分析" (Class Hierarchy Analysis, CHA) 技术的守护内联(Guarded lnlining) 两种非稳定的“激进优化”手段来获得更高的性能,关于这两种优化技术的原理和运作过程。

4. 动态类型语言支待

4.1 动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。

public static void main(String[] args) {
		
		int i = 10;
		int j = 0;
		int v = i/j;
	}

这段代码相信大家再熟悉不过了,它可以正常编译,但是会运行时会报ArithmeticException异常。在Java虚拟机中规范中明确规定了

ArithmeticException是一个运行异常,通俗一点说,运行时异常只要代码不运行到这一行就不会有问题。与运行异常相对应的是编译时

异常,接下来看一下编译时异常的例子:

public static void main(String[] args) {
		
		 FileInputStream fis = null;  
	         
	         fis = new FileInputStream("test.txt");  
	        
	}

上面这个例子中 fis = new FileInputStream(“test.txt”)会抛出IOException异常,这是一个编译时异常,如果不做try-catch处理,编译都通不过。通过上面两个例子就是想说明有些检查是在运行期进行的,有些检查是在编译器进行的。

接下来再举一个例子来解释“类型检查”,例如下面这一句非常简单的代码:

 obj = Demo();
 obj.function();
 

上面代码中假设Demo是一个类,且里面有function方法,这两行对于Java说,相信大家都知道是无法编译的更别提执行了。

obj.println("hello world");

现在假设这行代码是在Java语言中,变量obj的静态类型为java.io.PrintStream,那变量obj 的实际类型就必须是PrintStream的子类(实现了PrintSteam接口的类)才是合法的。否则,哪怕obj 属于一个确实有用println(String) 方法,但与PrintStream 接口没有继承关系,代码依然不可能运行,因为类型检查不合法。

这种差别产生的原因是Java 语言打编译期间已将println(String) 方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class 文件中。

这个符号引用包含了此方法定文在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用:而在动态类型语言中, 变量obj 本身是没有类型的,变量obj 的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。

”变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

4.2 JDK1.7与动态类型

JDK1.7以前的字节码指令集中,4条方法调用指令(invokevirtual , invokespecial , invokestatic , invokeinterface)的第一条参数都是被调用的方法的符号引用,前面已经提到过,方法的符号引用在编译时产生,而动态类型语言是在动态运行期才能确定接受者的类型。因此这也就是JDK1.7中invokedynamic指令以及java.lang.invoke包出现要解决的问题。

4.2.1 java.lang.invoke包

JDK1.7实现了JSR-292,新加入的java.lang.invoke包是一个重要组成部分,主要目的就是在之前单纯依靠符号引用来确定的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称之为MethodHandle。其实MethodHandle就是类似C/C++中的函数指针,或者C#中的委托。举个例子,如果我们要实现一个带有函数参数的排序函数,用函数指针的方法如下:

void sort(int list[], const int size , int (*compare)(int, int))

但Java语言就做不到这点,即没有办法单独的把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数。不过,在拥有Method Handle之后,Java语言也可以拥有类似于函数指针或者委托的方法别名的工具了。如下代码演示了MethodHandle的基本用法,无论obj是何种类型,都可以正确的调用到println()方法。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodType;
import static java.lang.invoke.MethodHandles.lookup;
 
public class MethodHandleTest {
 
	static class ClassA{
		public void println(String s){
			System.out.println(s);
		}
	}
	public static void main(String[] args) throws Throwable {
		Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
		
		//无论obj最终是哪个实现类,下面这句都能正确调用到println方法
		getPrintlnMH(obj).invokeExact("test");
	}
	private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable{
		//MethodType: 代表"方法类型",包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)
		MethodType mt = MethodType.methodType(void.class,String.class);
		//lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称、方法类型、并且符合调用权限的方法语柄
		/*因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接受者,也即是this指向的对象,这个参数以前是放在
		 参数列表中进行传递的,而现在提供了bindTo方法来完成这件事情*/
		return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);
		
	}

实际上,方法getPrintlnMH()中模拟了invokevirt指令的执行过程,只不过它的分派逻辑并非固化在Class字节码上,而是通过一个具体方法来实现。。而这个方法本身的返回值( MethodHandle 对象),可以视为对最终调用方法的一个“引用”。以此为基础,有了MetbodHandle 就可以写出类似于下面这样的函数声明:

void sort(List list, MethodHandle compare)

在这里本人仅仅举了MethodHandle来实现java对动态类型的支持。但是还有其他的方法,比如反射、invokedynamic指令,之后再补充。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值