JVM(八)方法调用的底层实现

JVM(八)方法调用的底层实现

上一章中主要记录的类加载机制和类的加载器的知识,那么这一章的话主要是记录方法调用的一些原理,如果有不对的地方请评论区指出,这块的理论比较多,请耐心看下去。

本次测试所使用的代码,配和 jclasslib 插件使用:
package com.test.demo.method;

public class Test01 implements ITest01 {
    public Test01(){
        System.out.println("构造调用!");
    }

    public void hello(){
        System.out.println("Hello world!");
    }

    public static void sayHello(){
        System.out.println("static Hello world!");
    }

    @Override
    public void sayHi() {
        System.out.println("hi!!!");
    }

    public static void main(String[] args) {

        Test01 test01 = new Test01();//构造
        Test01.sayHello();//静态方法

        test01.hello();//普通public方法
        ITest01 iTest01 = test01;
        iTest01.sayHi();//接口调用
    }
}

一、方法的调用指令

​ 我们写代码的时候方法的调用是最正常不过的一个行为了,其实从调用的方法的类别上的不同,它底层实现上也是不同的。从字节码的角度上来看的话其实方法调用是分五种指令来进行执行的。

invokestatic:调用的静态方法。

invokespecial:调用的是私有方法、构造器或者 super 关键字等。

invokevirtual:调用的是 public 、protected 等修饰的方法,其实我们调用的方法大多就是这个指令。

invokeinterface:以接口类调用的方法就是使用的这个指令。

invokedynamic :调用动态方法比如 Lambda 表达式。

​ 我们所写的方法是可以分为两种的—— 非虚方法虚方法 ,那么是 什么是 非虚 具体的可以继续往下看。

二、非虚方法

​ 非虚方法 的话从字面上理解就是实的方法,就是在代码编译的阶段就可以确定了版本的方法,到后面 JVM 调用的时候就直接调用就可以了。

​ 构造方法和静态方法都属于编译后就已经确定了的,所以都属于非虚方法,而且从 jclasslib 中的字节码可以看到两个方法对应的字节码是invokespecialinvokestatic除了这两个其实也包括了被 final 所修饰的方法(即使它的字节码是invokevirtual)。

三、虚方法

​ 上面介绍了 非虚方法 那么这里的虚方法就是非虚方法的反面——在代码编译阶段是没法确定调用的版本。

invokevirtual(除被final所修饰的方法)和invokeinterface这两这个指令都属于虚方法的类别中。

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

​ 其实我个人认为虚方法其实就是多态的一个体现,那么提到多态的就要讲到一个分派的概念,往下看。。

1、分派

​ 分派的加其实也是多态的基本的体现,比如重载重写等,分派的话也是有分别的一个是静态分派、另一个是动态分派。

  • 静态分派

    静态分派多体现在方法的重载(方法名相同但是参数不同)。

    ​ 虚拟机( 准确地说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判定依据的。 并且静态类型是编译期可知的, 因此, 在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。

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

  • 动态分派

    动态分派多体现在方法的重写(覆盖父类的方法)。

    重写也是使用invokevirtual指令, 只是这个时候具备多态性。
    invokevirtual指令有多态查找的机制, 该指令运行时, 解析过程如下:

    • 找到操作数栈顶的第一个元素所指向的对象实际类型, 记做 c。
    • 如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法, 则进行访问权限校验, 如果通过则返回这个方法直接引用, 查找过程结束, 不通过则返回 java.lang.IllegalAccessError。
    • 否则, 按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程。
    • 如果始终没找到合适的方法, 则抛出 java.lang.AbstractMethodError 异常, 这就是 Java 语言中方法重写的本质。另外一点, 这个时候我如果结合之前课程中讲过虚拟机栈中栈中的内容, 我就知道动态链接是干嘛的。invokevirtual 可以知道方法 call()的符号引用转换是在运行时期完成的, 在方法调用的时候。 部分符号引用在运行期间转化为直接引用,这种转化就是动态链接。

    但是在类关系比较复杂的时候寻找对应数据类型调用的方法就比较麻烦了,于是就有了方法表,那么什么是方法表呢?往下看。。。。。

2、方法表

​ 从图上也可以看到,方法表是根据数据类型进行的一个创建,之后找方法的时候就直接按照调用的数据类型区查找方法即可,这样大大的节省了时间。

四、语法糖原理

​ 上面已经讲了四个指令那么第五个指令——invokedynamic 主要讲的是 JDK 1.8 的 Lambda 表达式,那么我们使用的时候就可以感觉到,可能会感觉这个写的时候感觉很骚,就是好几行的代码可以浓缩成一行,那它底层是怎么实现的呢? 来来来继续往下看(快没了就这一块了)。。

1、方法句柄

​ 这里语法糖的底层实现根 MethodHandle 这个类是密不可分的,大体的实现看代码:

package com.test.demo.method;

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;

public class MethodHandleTest {

    public String find(Object obj) throws Throwable {
        //此处获取lookup,可以看作是一个工厂。
        MethodHandles.Lookup lookup = MethodHandles.lookup();
        
        //这里是获取需要的调用的方法的MethodType,第一个参数是返回值,往后是入参
        MethodType methodType = MethodType.methodType(String.class);
        
        //这里的findxxx可以和相关字节码关联(findStatic、findSpecial、findVirtual)
        MethodHandle sayHi = lookup.findVirtual(obj.getClass(), "sayHi", methodType);
        
        return String.valueOf(sayHi.invoke(obj));
    }

    public static void main(String[] args) throws Throwable {
        MethodHandleTest methodHandleTest = new MethodHandleTest();
        methodHandleTest.find(new MethodTest01());
        methodHandleTest.find(new MethodTest02());
        //运行结果:
        //调用了 MethodTest01 的 sayHi ,返回值:Hi 
        //调用了 MethodTest02 的 sayHi ,返回值:Hi 
    }
}

class MethodTest01{
    String sayHi( ){
        System.out.println("调用了 MethodTest01 的 sayHi ,返回值:Hi ");
        return "Hi";
    }
}

class MethodTest02{
    String sayHi( ){
        System.out.println("调用了 MethodTest02 的 sayHi ,返回值:Hi ");
        return "Hi";
    }
}

我们可以从代码里面看出来其实这里的实现和反射机制很像很像,但是这个的效率对比反射来讲的话效率是比较高的,而且比起反射的话就更简单。

2、捕获与非捕获

package com.test.demo.method;

public class Test02 {

    /**
     *
     * @param str
     * 这个方法就属于捕获,因为涉及到了lambda之外的参数。
     */
    public void say(String str){
        ITest01 iTest01 = () -> System.out.println("你好," + str);
        iTest01.sayHi();
    }

    /**
     * 这个方法属于非捕获,因为没有涉及到其他的参数
     */
    public void say(){
        ITest01 iTest01 = () -> System.out.println("你好,李焕英!");
        iTest01.sayHi();
    }

    public static void main(String[] args) {
        //这里从效率方面来讲的话,毫无疑问肯定是非捕获的效率更高。
        Test02 test02 = new Test02();
        test02.say("李铁蛋");
        test02.say();
    }
}

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

posted @ 2021-03-13 17:09  某人人莫  阅读(108)  评论(0编辑  收藏  举报