原始博客的地址:
该项目的地址:
前面我们基本完成了方法区的实现,现在我们需要搞定方法的调用。
在Java虚拟机支持以下5条方法调用字节码指令,分别是:
  • invokestatic:用于调用静态方法。
  • invokespecial:用于调用实例构造器<init>()方法、私有方法和父类中的方法。
  • invokevirtual:用于调用所有的虚方法。
  • invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。这个比较麻烦,我们后面单独开一篇来说。
只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。

invokestatic

假定解析符号引用后得到方法M。M必须是静态方法,否则抛出Incompatible-ClassChangeError异常。M不能是类初始化方法。类初始化方法只能由Java虚拟机调用,不能使用invokestatic指令调用。这一规则由class文件验证器保证,这里不做检查。如果声明M的类还没有被初始化,则要先初始化该类。
代码实现:
这里我们封装了一些方法调用的逻辑 InvokeMethod ,它主要是处理栈帧与参数的传递。

参数传递

当调用一个方法的时候,所以我们根据方法的 descriptor 算出参数的个数,由于解析比较麻烦,具体可看 MethodDescriptorParser里面的逻辑,简单来说就是分割字符串。举个例子,对于下面的方法:
编译后的 descriptor(ILjava/lang/String;D[F)V 。将这个字符串分割后,就可以拿到方法的参数类型与个数,也可以拿到返回值类型。
参数的传递过程,其实就是将调用者栈帧的操作数栈中 pop 出数据,放入到被调用者栈帧的局部变量表中:
notion image
计算的时候,注意区分以下静态方法与实例方法,因为实例方法是有隐藏的 this 参数的,所以它的参数个数需要加1。
将参数放入被调用方法的局部变量表中,被调用方法就能正确使用吗?回想一下我们实现解释器的时候,有一些指令都是从局部变量表中取出值来使用,所以参数会被正确使用,当然这里面有一半的功劳要给编译器。
代码实现:
里面除了参数传递之外,额外做了 一件事情,就是创建被调用方法的栈帧
 

invokespecial

invokespecialinvokestatic 类似,它调用的方法是在解析的时候就确定的,不需要等到运行时再确定。所以,其实现逻辑与invokestatic 也差不多。
相比invokestatic 来说,多了一些校验代码,做了一些条件检查,为啥要做这些条件检查呢?我们可以看看官方文档的说明,截取一个片段:
notion image

invokevirtual

这个指令就比较麻烦了,涉及到一个叫做“分派”的概念。分派又分为两种,一种是静态分派,一种是动态分派。

静态分派

看一个例子:
直接看编译后的字节码:
notion image
可以看到mian方法中调用 sayHello 时,编译器选择的是 Human 作为参数的函数。为啥会这样呢?
我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),把后面的“Man”则被称为变量的“实际类型”(Actual Type)。虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。
重载方法还有优先级一说,比如一个参数是 int,一个参数是short,这个时候该选择哪个函数就需要一些规则来判断。具体的内容可以自行查询。

动态分派

动态分派与Java语言多态性的另外一个重要特性——重写(Override)有着很密切的关联。看例子:
看编译后的字节码:
notion image
发现三个 sayHello 方法的调用都是 Human 方法的。但是我们知道,这肯定是不对的。sayHello 的输出是与实际的调用对象有关的。
静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时产生了不同的行为。这是如何做到的呢?我们需要从 invokevirtual 指令入手:
根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:
  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  1. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  1. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  1. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
再看上面的字节码图,第16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,也就是 C。由于C重写了 sayHello 方法,所以我们就可以将重写后的方法作为目标方法进行调用,这样就调用到了不同对象上的方法。
因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

代码实现

其核心逻辑就在 MethodRef.lookupMethodInClass 里面,它会从下往上的从类的继承结构里面找匹配的方法,找到就返回。这里就又不得不提到另外一个知识点,就是 vtable。我们会发现,每次执行一个invokevirtual 指令的时候,都需要从下往上的寻找匹配方法,这岂不是显得很傻逼,所以呢我们可以做一个方法表。在我们的实现里面,方法表其实就可以看成一个缓存来实现,或者参考虚拟机规范来实现。但是项目里面并没有实现这个vtable逻辑,有兴趣的可以pr。

invokeinterface

和其他三条方法调用指令略有不同,在字节码中,invokeinterface指令的操作码后面跟着4字节而非2字节。前两字节的含义和其他指令相同,是个uint16运行时常量池索引。第3字节的值是给方法传递参数需要的slot数,其含义和给Method结构体定义的argSlotCount字段相同。这个数是可以根据方法描述符计算出来的,它的存在仅仅是因为历史原因。第4字节是留给Oracle的某些Java虚拟机实现用的,它的值必须是0。该字节的存在是为了保证Java虚拟机可以向后兼容。
具体的核心执行逻辑其实是与 invokevirtual 一样的,只不过校验的东西不一样:
那么为什么要单独定义invokeinterface指令呢?统一使用invokevirtual指令不行吗?答案是,可以,但是可能会影响效率。这两条指令的区别在于:当Java虚拟机通过invokevirtual调用方法时,this引用指向某个类(或其子类)的实例。因为类的继承层次是固定的,所以虚拟机可以使用一种叫作vtable的技术加速方法查找。但是当通过invokeinterface指令调用接口方法时,因为this引用可以指向任何实现了该接口的类的实例,所以无法使用vtable技术。

测试

程序编译无异常。再测试另外一个程序:
输出:
notion image
没有问题!!!
 
Loading...
二手的程序员
二手的程序员
路漫漫其修远兮
公告
 
可以占个坑,虽然现在没人说话。
QQ群:
notion image
Telegram群:
notion image
 
我的公众号:
notion image
我的微信:
notion image