方法调用的底层实现之虚/非虚方法,动/静态分派

了解方法执行流程的意义

我们写的代码,经过编译,经过类加载的各种阶段,进入了JVM运行时数据区。但作为程序员,我么最关心的是代码的执行,代码的执行其实本质上是方法的执行。站在JVM的角度,归根到底还是字节码的执行,main函数是JVM指令执行的起点。JVM会创建main线程来执行main函数,以触发JVM的一系列指令的执行,真正的把JVM跑起来。接着,在我们的代码中,就是方法调用方法的过程,所以了解方法在JVM中的调用时非常必要的。

五条调用方法的字节码指令

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

1. Invokestatic 用来调用静态方法

2. Invokespecial用来调用私有实例方法,构造器以及super关键字等

3. Invokevirtual用于调用非私有实例方法,比如public和protected,大多数方法调用属于这一种。

4. InvokeInterface和上面这个指令类似,不过作用于接口类。

5. Invokedynamic用于调用动态方法。

静态类型语言

静态分派与动态分派

动态分派和静态分派机制是Java多态实现的原理,那么什么是动静态分派呢?我们从以方法调用的角度来分析。

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

在程序运行时,进行方法调用是最普遍,最频繁的操作。但是Class文件的编译过程不包括传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相对于之前说的直接引用)。

image.png

这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来。需要在类加载期间,甚至到运行期间才能确定目标的直接引用。

解析

还记得我们之前讲过的类加载的7个阶段么?

image

我们知道,解析阶段的作用就是把符号引用变为直接引用。

在此处,我们细谈解析阶段,对方法而言,并不是所有的符号引用都会转化为直接引用,而是将一部分将其中的一部分符号转化为直接引用。

我们在理解面向对象编程多态这一特性时,都知道多态可以给予我们庞大程序扩展能力,但是,这也给被调用层确认其实际运行版本增加了难度。来看一段简单的程序。

现有继承关系如下:

image.png

我们模拟一下程序编译的过程(假设我们自己变成了编译器),我们的sayHello属于被调用层。

image.png

身为编译器的我们。在编译时,我们看到了一段这个代码。此时我们心里一定会向!嘿!这代码,到底运行的时候给我传的是谁啊。由于程序执行的流程不一样,可能给我传个女人,也可能传的是个男人啊!正是因为这个问题,因此,确认该方法最终执行版本的事,就只能放在运行时期来进行最终确认。

对上面的结论总结一下,在编译期解析能成立的前提是:方法在程序真正执行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。显然,上面这个重载的例子就在编译时期确认不下来。

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

静态方法,私有方法,实例构造器,父类方法(super),还要一个final方法(但是final方法使用的是invokevirtual指令调用,但尽管如此,它依然可以在解析阶段确认唯一的版本号)这些方法称为非虚方法(实实在在不会变的方法),这五种放到调用会在类加载的解析阶段把符号引用解析称为该方法的直接引用。与之相反,其他方法称为虚方法(需要在运行时确认其实际版本)。

哦~读到这里我们才知道,解析阶段只能确定这些非虚方法的版本,那么这些方法在字节码的体现是怎样的呢?

分派

什么是分派?分派这个概念我的理解是,站在Class文件的角度,把Class文件中的方法数据往运行时数据区里"传递"的这么一个过程。静态分派,顾名思义就是,就是把"编译可知,运行期不可变"的方法结构给他丢进方法区。而动态分派反之,把"编译不确定,运行时方可知"的方法结构丢进方法区。因此,分派是个动词,描述方法结构从Class文件中转移到运行时数据区的过程。而虚/非虚方法则是名词,描述这个方法是否可以在解析阶段放入运行时数据区。

静态分派

引用类型为参数的重载

我们对刚才讲的例子再次进行分析。

图片1.png

image.png

诶?第一次看到这个结果是不是有点懵逼?我们来让字节码告诉我们答案。

image.png

很明显,我们可以看到这两个invokespecila指令,都是调用的非虚方法。就证明这个方法在编译期间就能定下来其唯一的版本了。

在编译阶段,Human man = new Man()虽然是一个再明显不过的多态,但这行代码在编译器却也只能确认其静态/外观类型(Human)。我们再将代码改造一下,帮助你理解。

image.png

可以看到,我们少重载了以外观类型为参数的sayHello方法,编译器直接会提示我们。"啊~~~,这两个子类我编译的时候我不认识啊~~~"。经过这样的描述,希望你对invokespecial指令有一个更加深刻的理解。

Human man = new Man();

我们把”Human”称为变量的静态类型,后面的”Man”称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化。区别是静态类型的变化仅仅在使用时发生,变量的静态类型不会被改变,并且最终的静态类型在编译期可知,而实际类型变化的结果再运行期才确定。编译期在编译并不知道对象的实际类型是什么。

image.png

image.png

编译器在重载时是通过参数的静态类型而不是实际类型作为判定的依据。并且静态类型在编译期可知,因此编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位的方法执行版本的分派动作称为静态分派。静态分派典型的应用就是方法的重载。

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

字面量为参数的重载

字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。

image.png

依次注释会得到不同的结果。

瞧瞧,这些基础类型作为参数,是不是更符合我们的常规思路一些呢?究其原因,还是多态导致的特性。

如果编译期无法确定转型为哪种类型,会提示类型模糊,拒绝编译。

image.png

这些类型虽然都有可重载的方法,但是编译器在编译阶段依旧无法确定其实际版本(r.nextInt()%2产生的随机数)。因此又开始报错了。"诶诶诶~~~我又搞不定了。谁来救救我???"

动态分派

父类/子类的动态分派

动态分配可就太常见啦!我们平时正常调用的方法,大多数都是都属于此类。

image.png

image.png

image.png

看懂了静态分派,再去看分动态分派就很清楚了。此时我们重点操作的对象已经不是Human man = new Man();中的man了,已经是man.sayHello()这个行为了。而这个行为的字节码指令时invokevirtual()了。这时调用的已经是虚方法,决定man.sayHello()方法意义的是实际类型Man。所以实际类型的变换(man=new Woman()),整个结果也会产生变化。这种在运行时期确定方法行为的过程就是动态分派。

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

我们从invokevirtual指令的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

1. 找到操作数栈顶的第一个元素所指向的实际类型,记作C。

2. 如果在类型C中找到与常量池中描述,符合简单名称相符合的方法(元数据:class方法,类,变量),然后进行访问,然后进行访问权限验证,如果验证通过则返回这个方法的直接引用,查找过程结束。如果不通过,则抛出java.lang.IllegalAccessError异常。

3. 如果未找到,就按照继承关系从上往下依次对类型C的各个父类进行第二步的搜索和验证过程。

4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

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

接口/实习类的动态分派

image.png

image.png

image.png

image.png

此处调用了接口的虚方法指令,也属于动态分派。

虚拟机动态分派的版本确认流程

 

图片3.png

前面介绍的分派的实现,作为对虚拟机概念模型的解析基本已经足够了,它已经解决了虚拟机在分派中”会做什么”这个问题。

但是虚拟机”具体是如何做到的”,可能各种虚拟机实现都会有差别。

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机的实际实现中基于性能的考虑,大部分都不会真正的进行如此频繁的搜索。面对这种情况,最常用的”稳定优化”手段就是为类在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能。

 

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

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

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

这么看下来,大家有没有联想起双亲委派模型。双亲委派模型的思想是从父类加载器依次向子类加载器去尝试加载这个类。而动态分派则有些从子类往父类去定位版本的意思。

动态类型语言

关于方法的五条字节码指令:

1. Invokestatic 用来调用静态方法

2. Invokespecial用来调用私有实例方法,构造器以及super关键字等

3. Invokevirtual用于调用非私有实例方法,比如public和protected,大多数方法调用属于这一种。

4. InvokeInterface和上面这个指令类似,不过作用于接口类。

5. Invokedynamic用于调用动态方法。

前四条都属于静态类型类型语言的操作指令,而Invokedynamic指令专门操作动态类型语言。

image.png

什么是动态类型语言?

自从Sun公司问世至今二十多年,Class字节码只新增过一条指令。就是——invokedynamic指令。这条语言的目标就是:实现动态语言类型。

在了解Java虚拟机的动态语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语言,Java虚拟机有什么关系?了解Java虚拟机提供动态语言支持的技术背景,对理解这个语言特性是非常有必要的。

何谓动态类型语言?动态类型语言的关键特性是它的类型检查的主体是在运行期而不是编译期进行的。

你这时候疑惑了?刚才说动态分派不是也是在运行期处理类型问题的么?

此处一定要清晰地认识到。由于多态的特性,动态分派在方法运行阶段,确认其具体执行的版本问题。

而动态类型语言......哎~您别提版本儿了,我连你是个啥类型都不知道。

动态类型语言的特性就是它的类型检查的主体是在运行期而不是编译期进行的。

我们不难发现,非虚方法->虚方法->动态类型语言的语言特性,让代码在书写时,类型越来越模糊。这也代表着程序的扩展性,动态性越来越强的一种理念。

什么是连接时,运行时?

我们用以下这个小例子来测试Java中编译期与运行期是如何处理问题的。

image.png

很明显,数组创建肯定大小不能是负数。而这里却没有报错。当运行程序之后。

image.png

程序报错了。

这是,就很好解释什么是运行时异常了。顾名思义,运行时异常就是指只要代码不执行到这一行就不会出现问题。与运行时相对的概念是连接时异常、例如很常见的NoClassDefFoundError便属于连接时异常。即使导致连接时异常的代码放在一条根本无法被执行到的路径分支上,类加载时也照样会抛出的异常。

由此可见,一门语言的哪一种行为要在运行期或编译期检查是完全没规律的,关键是语言规范中人为设定的。

类型检查

解答了什么是”连接时”,什么是”运行时”,那再来解释一下什么是”类型检查”。如下有这么一行代码:

obj.println(“hello world”);

虽然我们知道他是想干啥,但是对于计算机来说,它只是个没头没尾的话。它需要拥有具体的上下文环境(譬如程序语言是什么。obj是什么类型)讨论才有意义。

在Java中,变量obj的静态类型为java.io.PrintStream,那么obj的实际类型一定是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含println(String)方法相同签名方法的类型,程序依然不可能运行——因为类型检测不合法。换句话说,Java属于强类型语言。不把类型说明白,它就不能给你好好干活儿。

那么其他弱类型语言的会有怎样的表现形式呢?

相同的代码在JavaScript就不一样。无论obj具体时间何种类型,无论其继承关系如何。只要这种类型的方法定义中确实包含println(String)方法,能够找到相同签名的方法,调用便可成功。

产生这种差别的根本原因是,Java语言在编译期间就已经把pritln(String)方法完成的符号引用生成出来了,并作为方法调用指令的参数存储到Class文件中。

这个符号引用包含了该方法定义在哪个具体的类型中,方法名字以及参数顺序,参数类型,和方法返回值等信息。通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。

而JavaScript等动态语言与Java有一个核心的差异。就是变量obj本身并没有类型(如果学过C#语言,那么一定是用过var修饰符,专门修饰弱类型变量)。所以编译器在编译时顶多能确定方法名称,参数,返回值信息,而不会去确定方法所在的具体类型。“变量无类型而变量值有类型”。这个特点也正是动态类型语言的一个核心特征。

动态语言和静态语言对比

那么动态,静态语言哪个更好呢?各有各的好。静态语言能在编译期确定类型,因此编译器能提供全面严谨的类型检查。这样与数据类型相关的潜在问题就能在编码时解决。利用稳定性让项目更容易做大。而动态语言提供了极大地灵活性。某些在静态语言中要花大量臃肿代码来实现的功能,动态语言去做可能很简单清晰,也就意味着开发效率的提升。

Java的动态语言发展史

Java目前对动态类型的语言支持一直都还有所欠缺。JDK7以前的字节码指令集中,4条方法调用指令(invokevirtual,invokespecial,invokestatic,invokeinterface)的第一个参数都是被调用的方法的符号引用。前面已经提到过,方法的符号引用在编译时产生。而动态语言只有在运行期才会确定方法的接受者。Java虚拟机只能使用曲线救国的方式,例如编译时留个占位符类型,等到运行时动态生成字节码实现具体类型到占位符的适配。但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销,内存开销是很显然易见的。方法调用一大堆动态类就会出来。由于无法确定调用对象的静态类型,导致方法内的内联这么一项重要编译期优化无法做到改善,而导致性能下降。

 我们理论讲了不少,或许你没有弱类型开发的经验。我们举一些例子加深你的理解。

image.png

如以上案例,arrays中的元素可以是任意类型,即使它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪个类里。编译器只能不停地编译它所遇见的每一条sayHello()方法,并开辟内存供使用者使用。因此这个问题被归结到虚拟机层面去解决,也就是Invokeynamic和java.lang.invoke包出现的技术背景。

方法句柄(MethodHandle)的诞生

什么是方法句柄?我们知道JDK7之前我们是单靠符号引用来确认调用的目标方法。那 么JDK7之后就出现了方法句柄,它是一种动态确定目标方法的机制。

某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的。都是为了解决原有的4条”invoke”指令方法分派规则完全固化在虚拟机之中的问题。如何把查找目标方法的决定权从虚拟机转嫁到具体的用户代码中。

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

 

image.png

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

图片4.png

调用流程

1. 创建MethodType,获取指定方法的签名(出参和入参,也就是形参和返回值类型)

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

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

图片5.png


image.png

再总结一下。我们说Java中前四条invoke指令。无论是静态分派还是动态分派,它们在调用方法时,这个方法一定都是在编译期就被虚拟机把所属的类型,返回参数类型,形参类型都固定好了的,他们的区别在于引用指向的类型是静态/外观类型还是实际类型。

但是我们看!我们仔细看看!!方法句柄做到的是原本虚拟机应该做到的事情。将虚拟机原本在类加载时就该做好的事放到运行期由我们业务代码来控制。

流程细节分析

 

MethodType

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

image.png

Lookup

MethodHandle.Lookup可以通过相应的findxxx方法得到对用的MethodHandle,相当于MethodHandle的工厂方法。说白了就是我们需要调用的方法原本字节码中需要用哪种指令调用,我们就搬到运行期间自己这么调用。

例如findStatic相当于得到一个static方法的句柄(类似于Invokestatic的作用),findVirtual找的是普通方法(类似于invokevirtual的作用)。

Invoke

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

 image.png

反射与句柄

讲了方法句柄后,我们很多人都觉得似曾相识或者干脆就觉得这玩意儿和反射太像了。

确实,如果站在Java的角度来看,MethodHandle在使用方法和效果上与Reflection有众多相似之处。不过他们也是存在区别的:

1. Reflection和MethodHandle机制本质上都是在模拟方法调用。但是Reflection是在模拟Java层次的方法调用。而MethodHandle是在模拟字节码层次的方法调用。在MethodHadles.Lookup上的3个方法findStatic(),findVirtula(),findSpecal()正对应了invokestatic,invokevirtual,invkespecial这几条字节码指令的执行权限校验行为。这些底层细节在Reflection API是不需要关心的。

2. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映像,包含了方法签名,描述符以及方法属性表中的各种属性的Java端表示方式,还包含了执行权限等运行信息。而后者只包含了执行该方法的相关信息。用开发人员的俗话说,就是Reflection是重量级的,而MethodHandle是轻量级的。

3. 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上来说虚拟机在这方面做的各种优化,在MethodHandle上也应当可以采用类似的思路去支持(目前还在完善中)。如方法内联等。而通过反射则几乎不可能直接去实施各类的优化措施。

Reflection API的设计目标只是为了Java语言而服务的。而MethodHandle的设计理念是服务于所有Java虚拟机之上的语言。其中也包含了Java语言,而且Java语言在这里并不是主角。

Lambda表达式的捕获与非捕获

当Lambda表达式访问一个定义在Lambda表达式体外的非静态变量或者对象时(也就是说Lambda表达式内存在不固定的变量时),这时Lambda表达式称为”捕获的”。

image.png

非捕获的Lambda表达式就是Lambda表达式没有访问一个定义在Lambda表达式体外的非静态变量或者对象。(也就是说Lambda表达式内不存在不固定的变量时),这时Lambda表达式称为”非捕获的”。

image.png

Lambda表达式是否是捕获与性能息息相关。一个非捕获的lambda通常比捕获的效率更高。非捕获的只需要计算一次,然后每次使用都会返回唯一的实例。而捕获的因为是变量是动态的,因此每次都需要重新计算。而且从目前实现来看,他的实现很像匿名内部类。而Lambda表达式最差的情况性能和内部类一样,好的情况比内部类更快。

 

总结

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

如果我们做过C#语言开发,可以从本章节内容的角度去思考为什么会有委托的存在,而不仅仅局限于委托的用法。

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

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

我们在使用Lambda表达式时,并没有指定run方法名,捕获型lambda也并没有直接进行方法传值,由此可见底层都是lambda表达式进行处理的。

image.png

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

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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

大将黄猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值