【JVM系列】方法执行指令详解

news/2024/7/24 11:55:50

方法执行

分类

jvm 有 5 条调用方法的字节码指令

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

可以分为三类(说到底是因为方法的多态性)

  • invokestatic 和 invokespecial,其调用的方法叫做“非虚方法”,即调用目标在编译那一刻就已经确定下来,和“虚方法” 的核心差异就是不可能调用到子类的该方法,在类加载的时候就可以把符号引用解析为该方法的直接引用。

    • 静态方法:static 的方法和属性是属于类的,无法被子类重写,只能是覆盖。
    • 私有方法:不解释
    • 实例构造器:不存在父类调用子类构造器
    • 父类方法:这个指的类的内部,通过 super.* 来调用父类方法,如果是直接调用父类方法,不通过 super 关键字来调用,那么还是 invokevitural。原因可以参考模板方法模式。
    • 被final修饰的方法(尽管它使用invokevirtual指令调用):不解释
  • invokevirual 和 invokeinterface,其调用的方法需要分派(Dispatch),虽然说都要运行期才确定目标,但是分配执行者不同,如果是静态的,那么分派由编译器完成,如果是动态的,那么分派由jvm 完成。

  • invokedynamic,执行的方法不再和类型在编译时绑定,所以完全不知道方法实现,那么执行何种 invoke 指令必须由用户指定,因此把分派逻变为由用户设定的引导方法来决定的,其余4条调用指令,由于编译时确定的符号引用把方法和类型进行了绑定,所以方法如何实现很清楚,那么执行哪种指令 invoke 指令就很明确,所以分派逻辑都固化在Java虚拟机内部。

补充:字段没有多态性

  • 当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段

    public class FieldHasNoPolymorphic {
        static class Father {
            public int money = 1;
            public Father() {
                money = 2;
                showMeTheMoney();
            }
            public void showMeTheMoney() {
                System.out.println("I am Father, i have $" + money);
            }
        }
        static class Son extends Father {
            public int money = 3;
            public Son() {
                money = 4;
                showMeTheMoney();
            }
            public void showMeTheMoney() {
                System.out.println("I am Son, i have $" + money);
            }
        }
        
        public static void main(String[] args) {
            Father gay = new Son();
            System.out.println("This gay has $" + gay.money);
        }
    }
    
    // I am Son, i have $0 (因为子类构造方法都必须显式或者隐式调用父类构造方法,所以触发了父类构造,又因为子类对象的 filed 会覆盖父类的,但是子类的构造还没有触发,所以只有对象创建阶段初始化的零值)
    // I am Son, i have $4
    // This gay has $2
    

方法解析

字节码指令为 invokespecial 和 invokestatic 是在类加载时解析

  • 方法解析的第一个步骤与字段解析一样,也是需要先解析 Methodref 中 class_index 索引的方法所属的类或接口的符号引用,如果解析成功,那么我们用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的方法搜索:

    • 由于Class文件格式中类的方法和接口的方法符号引用的常量类型定义是分开的,如果在 Methodref 中发现 class_index 中索引的C是个接口的话,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    • 如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    • 否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时候查找结束,抛出 java.lang.AbstractMethodError异常。
    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。

    最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。

方法分派

  • 因为存在多态性,所以要确定最终要执行的方法。

变量的“静态类型”:变量的静态类型可以通过强制转换来改变,但是变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的。

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

// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)
静态分派
  • 根据静态类型来决定方法执行版本的分派动作,都称为静态分派,典型例子为重写。
  • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

重载原理:

  • 重载时是通过参数的静态类型而不是实际类型作为判定依据的。

  • 由于静态类型在编译期可知,所以在编译阶段,Java c编译器就根据参数的静态类型决定了会使用哪个重载版本,即 invokevitural 后面的符号引用就是最终要执行的方法签名(因为方法签名包含参数类型,所以说编译阶段就确定了要执行的方法)

    public class StaticDispatch {
        static abstract class Human {}
        static class Man extends Human {}
        static class Woman extends Human {}
        
        public void sayHello(Human guy) { System.out.println("hello,guy!"); }
        public void sayHello(Man guy) { System.out.println("hello,gentleman!"); }
        public void sayHello(Woman guy) { System.out.println("hello,lady!");}
        
        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,guy!
    // hello,guy!
    
  • Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本。

    public class Test {
    
        public void sayHello(Object arg) {
            System.out.println("hello Object");
        }
        public void sayHello(int arg) {
            System.out.println("hello int");
        }
        public void sayHello(long arg) {
            System.out.println("hello long");
        }
        public void sayHello(Character arg) {
            System.out.println("hello Character");
        }
        public void sayHello(char arg) {
            System.out.println("hello char");
        }
        public void sayHello(char... arg) {
            System.out.println("hello char ...");
        }
        public void sayHello(Serializable arg) {
            System.out.println("hello Serializable");
        }
        public static void main(String[] args) {
            Test test = new Tes4();
            test.sayHello('a');
        }
    }
    
    // 优先级:char -> int -> long -> Character -> Serializable -> Object -> char ...
    
动态分派
  • 根据实际类型确定方法执行版本的分派动作,都称为动态分派,典型例子为重写。

invokevirtual 解析过程

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

虚拟机动态分派的实现

  • 动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。

  • 一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。

    • 虚方法表中存放着该类型各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
    • 虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

    在这里插入图片描述

单分派与多分派
  • 方法的接收者(执行者)与方法的参数统称为方法的宗量。
  • 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

Java语言是一门静态多分派、动态单分派的语言

  • Java:一个方法的执行要经过三步,编译时确定调用类型的全限定签名 -> 编译时确定方法签名(前两步就是说编译时就生成了完整的符号引用) -> 运行时确定实际执行方法的类

  • 其他动态语言:运行时推断调用的哪个类型(C#4.0 中 dynamic 关键字) -> 编译时确定方法的签名 -> 运行时确定实际执行方法的类

  • 注意:JDK 10时Java语法中新出现var关键字,虽然与 C#中的dynamic 有相对应的特性,但是有着本质的区别

    • var是在编译时根据声明语句中赋值符右侧的表达式类型来静态地推断类型,这本质是一种语法糖;而dynamic在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。

动态类型

动态类型语言

  • 动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,所以其调用某个对象的方法或者字段时,不会事先检查,而是运行时才检查

  • 产生这种差别产生的根本原因是,Java语言在编译期间却已将方法和字段完整的符号引用确定下来,而符号引用里面就包含了类型信息,还有方法的名字以及参数顺序、参数类型和方法返回值等信息。

  • 对比:

    • 静态类型语言能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。
    • 而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁通常也就意味着开发效率的提升。

invokedynamic产生原因

  • 因为 jvm 上要运行其他动态类型语言,就必须在运行期才能确定要执行的方法,然后现有的 invoke* 其符号引用已经确定了方法的类型,所以必须提供一种和类型无关的方法执行机制,和类型无关了所以就必须用户自己实现分派(因为之前的四种 invoke 都是编译时就确定类型,然后就可以知道该方法的实现,因此可以确定用哪一种 invoke* 指令,但是现在不知道方法实现的信息,所以就没法确定使用何种指令)。

  • 因此 Java虚拟机上实现的动态类型语言就不得不使用“曲线救国”的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的替换)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销,最严重的性能瓶颈是无法方法内联或者其他编译优化,因为字节码是变化的。

  • 所以 JDK 7 时出现invokedynamic指令以及java.lang.invoke包。但是注意一下,只是提供了一个类型无关的方法执行机制,但是依然没有做到动态语言的运行时类型推断。

java.lang.invoke

这个包的主要提供一种模拟字节码层面的方法调用,因为不知道方法实现,就要用户自己决定执行哪种 invoke,所以这个包提供了对 invoke 指令的模拟,最终返回一个方法句柄。

MethodHandle

  • “方法句柄”(Method Handle),与C/C++中的函数指针类似,指向一个函数。

  • 示例

    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();
            // 通过实际对象,返回一个 MethodHandle。无论obj最终是哪个实现类,只要有 println 方法就能正确调用
            getPrintlnMH(obj).invokeExact("icyfenix");
        }
        private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable {
            // MethodType:代表“方法类型”,包含了方法的返回值(第一个参数),和具体参数(第二个及以后的参数)。
            MethodType mt = MethodType.methodType(void.class, String.class);
            /**
             * lookup():返回一个 MethodHandles.lookup 对象,其作用是按照 jvm 分派的方式是去查找给定方法签名的方法句柄。
             * findVirtual() :虚方法查找方式,第一个参数代表实际对象的类型,后两个参数是方法签名
             * bindTo():绑定执行该方法的实际对象
             */
            return lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
        }
    }
    
  • 和反射区别

    • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。

      • 在MethodHandles.Lookup上的3个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令,而这些具体执行哪个字节码指令在使用Reflection API时是不需要关心的。
      • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点优化措施。
    • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的 java.lang.invoke.MethodHandle对象所包含的信息来得多。

      • 前者是方法在Java端的全面映像,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。
      • 而后者仅包含执行该方法的相关信息,只提供了一个返回 MethodType的方法,剩下的都是执行的方法。用开发人员通俗的话来讲,Reflection是重量级,而MethodHandle是轻量级。
invokedynamic

执行的方法不和类型绑定,不知道方法实现,所以执行哪种invoke指令通过用户定义引导方法实现,即把分派的权利交给用户。

CONSTANT_InvokeDynamic_info(invokedynamic 的操作数)

  • 引导方法索引(对应的BootstrapMethods属性中相应的引导方法)

  • 方法类型(MethodType,包含了参数类型和返回值类型)

  • 名称(方法名称)

lamda 底层

lamda 的引导方法

// CallSite 就是最后返回的 MethodHandle 对象
public static CallSite metafactory(MethodHandles.Lookup caller, // 由 jvm 设置
                                   //  方法名(InvokeDynamic_info 第二项)
                                   String invokedName, 
                                   // 方法签名,参数是 捕获的外部对象,返回值是接口的类型(InvokeDynamic_info 第三项)
                                   MethodType invokedType, 
                                   // 方法签名,原先接口的该方法签名(Mehtod arguments 第一项)
                                   MethodType samMethodType,
                                   // 目标方法句柄,最终会实际要执行的方法,如果有外部引用,其会通过根据 samMethodType
                                   // 构造的 class, 的构造方法传入。(Mehtod arguments 第二项)
                                   MethodHandle implMethod, 
                                   // 方法签名,一般和 saMethodType 一样(Mehtod arguments 第三项)
                                   MethodType instantiatedMethodType) 
        throws LambdaConversionException {
    AbstractValidatingLambdaMetafactory mf;
    mf = new InnerClassLambdaMetafactory(caller, invokedType,
                                         invokedName, samMethodType,
                                         implMethod, instantiatedMethodType,
                                         false, EMPTY_CLASS_ARRAY, EMPTY_MT_ARRAY);
    mf.validateMetafactoryArgs();
    // 最后执行的 MethodHandles.Lookup.IMPL_LOOKUP.findStatic(innerClass, NAME_FACTORY, invokedType)
    // innerclass:动态生成的 class,根据 samMethodType 构造的接口的实现类,用来代表接受该方法的类
    return mf.buildCallSite();
}

http://www.niftyadmin.cn/n/805868.html

相关文章

linux 中 ‘|’的作用是什么

利用Linux所提供的管道符“|”将两个命令隔开&#xff0c;管道符左边命令的输出就会作为管道符右边命令的输入。连续使用管道意味着第一个命令的输出会作为 第二个命令的输入&#xff0c;第二个命令的输出又会作为第三个命令的输入&#xff0c;依此类推。下面来看看管道是如何在…

日历函数单元 (转)

日历函数单元 (转)[more]//原始版权宣告&#xff1a; /*************************************************************************** 致看到这些源代码的兄弟: 你好! 这本来是我为一个商业PDA产品开发的日历程序,最近移植于PC机上, 所以算法 和数据部分是用纯C写的,不涉…

linux vim 查找或替换空格

1. 匹配1到多个空格/\s\2. 替换一个或多个空格&#xff0c;替换为逗号&#xff0c; :%s/\s\/,/g3. 替换一个或多个空格&#xff0c;替换为换行符 :%s/\s\/\r/g 简单解释一下&#xff1a;%s &#xff1a;在整个文件范围查找替换(或者使用1,$s 也是整个文件范围查…

python学习笔记19(序列的方法)

python学习笔记19&#xff08;序列的方法&#xff09; 序列包含有宝值 表(tuple)和表(list)。此外&#xff0c;字符串(string)是一种特殊的定值表&#xff0c;表的元素可以更改&#xff0c;定值表一旦建立&#xff0c;其元素不可更改。 任何的序列都可以引用其中的元素(item)。…

VUE面试题汇总(十)

往期点这里&#xff1a;↓ VUE面试题汇总&#xff08;一&#xff09; VUE面试题汇总&#xff08;二&#xff09; VUE面试题汇总&#xff08;三&#xff09; VUE面试题汇总&#xff08;四&#xff09; VUE面试题汇总&#xff08;五&#xff09; VUE面试题汇总&#xff08;六&…

前后端数据获取

1. 前端传给后端&#xff0c;后端获取参数方式&#xff1a; 前端通过ajax或者windows.location.hrefurl等方式传入给端的参数&#xff0c;后端通过request.getParameter或getPara方式接受参数。后端框架不同&#xff0c;接受也略有不同。 2. 后端传给前端&#xff0c;前端获取参…

解决Win8无法升级.NET Framework 3.5.1 提示错误0x800F0906

1. 打开 win8 安装盘&#xff0c;提取 sources\sxs 文件夹到 d:\sources\sxs &#xff08;或别的盘也行&#xff0c;举个例子&#xff09;&#xff1b; 2. 打开 c:\windows\system32 文件夹&#xff0c;找到 cmd.exe&#xff0c;右击&#xff0c;选择”Run as administrator”&…

【JVM系列】前端编译与语法糖

前端编译器Javac 从Javac代码的总体结构来看&#xff0c;编译过程大致可以分为1个准备过程和3个处理过程&#xff0c;它们分别如下所示。 准备过程&#xff1a;初始化插入式注解处理器。 解析与填充符号表过程&#xff0c;包括&#xff1a; 词法、语法分析。将源代码的字符流…