十、方法调用的底层实现

一、方法调用分析(main方法是JVM指令执行的起点)

                我们写的代码,经过编译、经过类加载的各种阶段,进入了 JVM 的运行时数据区。但作为程序员真正关心是代码的执行,代码的执行其实本质上是方法的执行,站在 JVM 的角度归根到底还是字节码的执行。

main 函数是 JVM 指令执行的起点,JVM 会创建 main 线程来执行 main 函数,以触发 JVM 一系列指令的执行,真正地把 JVM 跑起来。

        接着,在我们的代码中,就是方法调用方法的过程,所以了解方法在 JVM 中的调用是非常必要的。

方法的调用与虚拟机栈示意图

二、方法调用的字节码指令

关于方法的调用,Java 字节码提供了 5 个指令,来调用不同类型的方法:

  • invokestatic 用来调用静态方法;--非虚方法
  • invokespecial 用于调用私有实例方法、构造器及 super 关键字等;--非虚方法
  • invokevirtual 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种;-- 虚方法
  • invokeinterface 和上面这条指令类似,不过作用于接口类; -- 虚方法
  • invokedynamic 用于调用动态方法。

三、非虚方法

        如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。这类方法的调用称为解析。它们在类加载的时候就会把符号引用解析为该方法的直接引用。

        静态方法,在编译的时候就已经写入到了常量池--非虚方法

        只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用),这 5 种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。不需要在运行时再去完成。

  • invokeStatic 用来调用静态方法
public static void main(String[] args) 
    StaticResolution.Hello();
}

javap -v

这个方法调用在编译期间就明确以常量池项的形式固化在字节码指令的参数之中了。

  • invokeSpecial 用于调用私有实例方法、构造器及 super 关键字等

四、虚方法

        与非虚方法相反,不是非虚方法的方法就是虚方法。主要包括以下字节码中的两类

  • invokevirtual 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种(排除掉被 final 修饰的方法);
  • invokeinterface 和上面这条指令类似,不过作用于接口类;

        为什么叫做虚方法呢?就是方法在运行时是可变的。

        很多时候,JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程;相对比,invokestatic 指令和 invokespecial 指令,就属于静态绑定过程。

        因为 invokeinterface 指令跟 invokevirtual 类似,只是作用与接口,所以我们只要熟悉 invokevirtual 即可。

        方法调用并不等于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

        在程序运行时,进行方法调用是最普遍、最频繁的操作,但是Class文件的编译过程不包括传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相对于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

  • 解析

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

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

        静态方法、私有方法、实例构造器、父类方法。这些方法称为非虚方法,它们在类加载的时候就会把符号引用解析为该方法的直接引用。与之相反,其他方法称为虚方法(除去final方法)

分派

  • 多态就是动态分派和静态分派

        方法会根据你送入的参数有不同的表现形式,这个就是分派。

要了解虚方法我们必须了解以下基础:

        Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征:继承、封装和多态。

        分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java 虚拟机之中是如何实现的

  • 静态分派-重载

        多见于方法的重载。(重载:一个类中允许同时存在一个以上的同名方法,这些方法的参数个数或者类型不同)

重载使用 invokevirtual 指令

        “Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)。

        静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。父类引用指向子类对象,编译并不知道实际类型,只有运行的时候才知道。

        代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的。因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human)作为调用目标。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。

       静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,而是由编译器来完成。

所以代码运行结果如下:

public class StaticDispatch {
    static abstract class Human{
    }
    static class Man extends Human{
    }
    static class Woman extends Human{
    }
    public static void sayHello(Human guy){
        System.out.println("hello,guy!");
    }
    public static void sayHello(Man guy){
        System.out.println("hello,gentlemen!");
    }
    public static void sayHello(Woman guy){
        System.out.println("hello,lady!");
    }
    
    public static void main(String[] args) {
        Human man=new Man();
        Human woman=new Woman();
        sayHello(man);
        sayHello(woman);
    }
}

输出:

hello,guy!

hello,guy!

总结:例子很简单,方法会根据你送入的参数有不同的表现形式,这个就是分派。

        举个简单的例子:你在酒吧遇到一个你心动的人,但这个人看上去不男不女,你怎么去与他/她打招呼?这个时候我至少知道是一个人,所以 打招呼说:地球人!你好,我是来自火星的(hello guy!)。我调用它最原始的外观类型(至少是个人)

  • 动态分派-重写

多见于方法的重写。(重写:在子类中将父类的成员方法的名称保留,重新编写成员方法的实现内容,更改方法的访问权限,修改返回类型的为父类返回类型的子类。)

另外一个例子:

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!

重写也是使用 invokevirtual 指令,只是这个时候具备多态性。

        显然,这里不可能再根据静态类型来决定,因为静态类型同样是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢

invokevirtual 指令有多态查找的机制,该指令运行时,解析过程如下:

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

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

        另外一点,这个时候结合之前课程中讲过虚拟机栈中栈中的内容,我就知道动态链接是干嘛的:

        invokevirtual 可以知道方法 call()的符号引用转换是在运行时期完成的,在方法调用的时候。部分符号引用在运行期间转化为直接引用,这种转化就是动态链接。

详细看栈帧执行对内存区域的影响

方法表,也叫虚方法表

        虚拟机动态分派的实现

        动态分派会执行非常频繁的动作,JVM 运行时会频繁的、反复的去搜索元数据,所以 JVM 使用了一种优化手段,这个就是在方法区中建立一个虚方法表。使用虚方法表索引来替代元数据查找以提高性能。

        在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。

        如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。

        如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

        如图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。

        但是 Son 和 Father都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。

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

五、接口调用

  • invokeinterface 和 invokevirtual 指令类似,不过作用于接口类的default方法

六、Lambda 表达式

  • invokedynamic,用于调用动态方法

lambda表达式

Runnable r = () -> System.out.println("Hello Lambda!");

        主要使用invokedynamic,用于调用动态方法。底层是 methodHandle,方法句柄

        methodhandle 比反射好用,效率高,但是反射权限更大,能看到更多底层

        invokedynamic 这个字节码是比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高得多。

        invokedynamic这个指令通常在 Lambda 语法中出现,我们来看一下一小段代码:

        使用 javap -v 命令可以在 main 方法中看到 invokedynamic 指令:

        另外,我们在 javap 的输出中找到了一些奇怪的东西:

        BootstrapMethods 属性在 Java 1.7 以后才有,位于类文件的属性列表中,这个属性用于保存 invokedynamic 指令引用的引导方法限定符。和上面介绍的四个指令不同,invokedynamic 并没有确切的接受对象,取而代之的,是一个叫 CallSite 的对象。

七、方法句柄-MethodHandler

官方文档解释:https://docs.oracle.com/javase/7/docs/api/java/lang/invoke/MethodHandles.html

        invokedynamic 指令的底层,是使用方法句柄(MethodHandle)来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法,以及虚构的 get 和 set 方法,从以下案例中可以看到 MethodHandle 提供的一些方法。

        MethodHandle 是什么?简单的说就是方法句柄,通过这个句柄可以调用相应的方法。

用 MethodHandle 调用方法的流程为:

(1) 创建 MethodType,获取指定方法的签名(出参和入参)

(2) 在 Lookup 中查找 MethodType 的方法句柄 MethodHandle

(3) 传入方法参数通过 MethodHandle 调用方法

methodhandle 比反射好用,效率高,但是反射权限更大,能看到更多底层

MethodType

MethodType 表示一个方法类型的对象,每个 MethodHandle 都有一个 MethodType 实例,MethodType 用来指明方法的返回类型和参数类型。其有多个工厂方法的重载。

STATIC METHODTYPE METHODTYPE(CLASS RTYPE)

STATIC METHODTYPE METHODTYPE(CLASS RTYPE, CLASS PTYPE0)

STATIC METHODTYPE METHODTYPE(CLASS RTYPE, CLASS[] PTYPES)

STATIC METHODTYPE METHODTYPE(CLASS RTYPE, CLASS PTYPE0, CLASS... PTYPES)

STATIC METHODTYPE METHODTYPE(CLASS RTYPE, LISTLASS> PTYPES)

STATIC METHODTYPE METHODTYPE(CLASS RTYPE, METHODTYPE PTYPES)

Lookup

MethodHandle.Lookup 可以通过相应的 findxxx 方法得到相应的 MethodHandle,相当于 MethodHandle 的工厂方法。查找对象上的工厂方法对应于方法、构造函数和字段的所有主要用例。

findStatic 相当于得到的是一个 static 方法的句柄(类似于 invokestatic 的作用)

findVirtual 找的是普通方法(类似于 invokevirtual 的作用)

invoke

其中需要注意的是 invoke 和 invokeExact,前者在调用的时候可以进行返回值和参数的类型转换工作,而后者是精确匹配的。

所以一般在使用是,往往 invoke 使用比 invokeExact 要多,因为 invokeExact 如果类型不匹配,则会抛错。

Lambda 表达式的捕获与非捕获

当 Lambda 表达式访问一个定义在 Lambda 表达式体外的非静态变量或者对象时,这个 Lambda 表达式称为“捕获的”

那么“非捕获”的 Lambda 表达式来就是 Lambda 表达式没有访问一个定义在 Lambda 表达式体外的非静态变量或者对象

Lambda 表达式是否是捕获的和性能悄然相关。一个非捕获的 lambda 通常比捕获的更高效,非捕获的 lambda 只需要计算一次. 然后每次使用到它都会返回一个唯一的实例。而捕获的 lambda 表达式每次使用时都需要重新计算一次,而且从目前实现来看,它很像实例化一个匿名内部类的实例。

lambda 最差的情况性能内部类一样, 好的情况肯定比内部类性能高。

Oracle 公司的性能比较的文档,详细而全面的比较了 lambda 表达式和匿名函数之间的性能差别。

lambda 开发组也有一篇 PPT 其中也讲到了 lambda 的性能(包括 capture 和非 capture 的情况)。 lambda 最差的情况性能内部类一样, 好的情况肯定比内部类性能高。

https://www.oracle.com/technetwork/java/jvmls2013kuksen-2014088.pdf

http://nerds-central.blogspot.tw/2013/03/java-8-lambdas-they-are-fast-very-fast.html

/**
 * 方法句柄(MethodHandle)使用案例
 **/
public class MethodHandleDemo {
    static class Bike {
        String sound(Integer a) {
            return "ding  ding ding";
        }
    }

    static class Animal {
        String sound(Integer a) {
            return "wow  wow wow";
        }
    }

    static class Man extends Animal {
        @Override
        String sound(Integer a) {
            return "ha ha ha" + a;
        }
    }

    String sound(Object o) throws Throwable {
        // 1、方法句柄--工厂方法Factory
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        // 2、方法类型表示接受的参数和返回类型(第一个参数是返回参数)
        MethodType methodType = MethodType.methodType(String.class,Integer.class);
        // 3、拿到具体的MethodHandle(findVirtual相当于字节码)
        MethodHandle methodHandle = lookup.findVirtual(o.getClass(), "sound", methodType);
        // 4、执行方法
        String obj = (String) methodHandle.invoke(o,1);
        return obj;
    }

    public static void main(String[] args) throws Throwable {
        // 每次送入的实例不一样
        String str = new MethodHandleDemo().sound(new Bike());
        System.out.println(str);

        str = new MethodHandleDemo().sound(new Animal());
        System.out.println(str);

        str = new MethodHandleDemo().sound(new Man());
        System.out.println(str);
    }
}

总结

        Lambda 语言实际上是通过methodhanle方法句柄来完成的,大致这么实现(JVM 编译的时候使用 invokedynamic 实现 Lambda 表达式,invokedynamic的是使用 MethodHandle 实现的,所以 JVM 会根据你编写的 Lambda 表达式的代码,编译出一套可以去调用 MethodHandle 的字节码代码,参考实例类:MethodHandleDemo)

        句柄类型(MethodType)是我们对方法的具体描述,配合方法名称,能够定位到一类函数。访问方法句柄和调用原来的指令基本一致,但它的调用异常,包括一些权限检查,在运行时才能被发现。

        案例中,我们完成了动态语言的特性,通过方法名称和传入的对象主体,进行不同的调用,而 Bike 和 Man 类,可以没有任何关系。

        可以看到 Lambda 语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着 Lambda 性能低呢?对于大部分“非捕获”的 Lambda 表达式来说,JIT 编译器的逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,则需要通过方法句柄,不断地生成适配器,性能自然就低了很多(不过和便捷性相比,一丁点性能损失是可接受的)。

        invokedynamic 指令,它实际上是通过方法句柄来实现的。和我们关系最大的就是 Lambda 语法,我们了解原理,可以忽略那些对 Lambda 性能高低的争论,同时还是要尽量写一些“非捕获”的 Lambda 表达式。

  • 19
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
函数调用是编程中的一个基本操作,它允许程序在不同的代码块之间进行切换,并传递参数和执行不同的任务。函数调用底层实现原理涉及到许多复杂的计算机科学概念,包括内存管理、栈、寄存器、调用约定等。 在底层实现中,函数调用通常涉及以下几个步骤: 1. **参数传递**:当一个函数被调用时,它的参数会被压入调用函数的栈帧中。这些参数包括输入和输出参数,以及局部变量。 2. **代码执行**:当函数开始执行时,控制权会转移到该函数的代码上。这个过程通常涉及到将程序的执行上下文(包括寄存器的内容、内存中的数据等)保存到栈帧中,以便函数执行完毕后可以恢复这些信息。 3. **返回地址保存**:当函数执行完毕并准备返回时,它会将程序计数器的当前值(即下一条要执行的指令的地址)保存到一个特殊的寄存器(通常是EIP)中,以便函数可以返回调用它的代码。 4. **返回**:函数执行完毕后,会从栈帧中取出返回地址(通常是EIP),然后跳转到这个地址继续执行程序。此时,函数调用就完成了。 这个过程在许多不同的编程语言中都是相似的,但是实现方式可能会有所不同。具体实现会取决于所使用的编程语言和操作系统,以及硬件架构(如x86、ARM等)。此外,不同的编译器和运行时环境可能会有不同的调用约定,这也会影响函数调用底层实现。 值得注意的是,函数调用底层实现通常涉及到许多底层的细节和复杂性,对于大多数编程任务来说并不需要了解这些细节。如果你对这方面的知识感兴趣,可以进一步学习计算机体系结构和操作系统课程,以了解更多关于函数调用和程序执行的基础知识。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值