我们都知道,Java源代码需要编译成字节码文件,由JVM解释执行,而方法调用可以说是很常见的操作。Java不同于C++,Java中的实例方法默认是虚方法,因此父类引用调用被子类覆盖的方法时能体现多态性。下面我们来看看JVM是如何完成方法调用操作并实现动态绑定的。
栈帧结构
为了能高效地管理程序方法调用,有条不紊地进行嵌套的方法调用和方法返回,JVM维护了一个栈结构,称为虚拟机方法栈(这里没考虑Native方法)。栈里面存放的一个个实体称为栈帧,每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息。在编译时,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中。
局部变量表
局部变量表用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以Slot为最小单位,一个Slot可以存放一个32位以内的数据类型,long和double需要两个Slot存放。
如果执行的方法是非static方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用(this)。
为了节省栈帧空间,局部变量表中的Slot是可以重用的。如果一个局部变量定义了但没有赋初值是不能使用的。
操作数栈
JVM解析执行字节码是基于栈结构的。比如做算术运算时是通过操作数栈来进行的,在调用其他方法时是通过操作数栈来进行参数的传递。
方法调用大致过程
- 除非被调用的方法是类方法,每一次方法调用指令之前,JVM先会把方法被调用的对象引用压入操作数栈中,除了对象的引用之外,JVM还会把方法的参数依次压入操作数栈。
- 在执行方法调用指令时,JVM会将函数参数和对象引用依次从操作数栈弹出,并新建一个栈帧,把对象引用和函数参数分别放入新栈帧的局部变量表slot0,1,2…。
- JVM把新栈帧push入虚拟机方法栈,并把PC指向函数的第一条待执行的指令。
到此,有人可能会问,JVM是如何得到被调用方法的地址呢?两种方式,一种是编译期的静态绑定,另一种是运行期的动态绑定。不同类型的方法用不同的绑定方式。
方法调用的字节码指令
JVM里面提供了4条方法调用字节码指令。分别如下:
- invokestatic:调用静态方法
- invokespecial:调用实例构造器
<init>
方法、私有方法和父类方法(super(),super.method()) - invokevirtual:调用所有的虚方法(静态方法、私有方法、实例构造器、父类方法、final方法都是非虚方法)
- invokeinterface:调用接口方法,会在运行时期再确定一个实现此接口的对象
invokestatic和invokespecial指令调用的方法都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载阶段就会把符号引用解析为该方法的直接引用。直接引用就是一个指针或偏移量,可以让JVM快速定位到具体要调用的方法。
invokevirtual和invokeinterface指令调用的方法是在运行时确定具体的方法地址,接口方法和实例对象公有方法可以用这两个指令来调用。
下面我们通过一个代码示例来展现这几种方法调用: