本篇我们专注invokevirtual
这一条指令,先通过简单粗暴的方式实现指令的功能,然后探究如何通过著名的虚方法表(Virtual Method Table)来进行一些优化。
指令含义
invokevirtual
用于调用除静态方法、构造方法、私有方法、接口方法外的所有方法。其指令的格式为:
invokevirtual = 182 (0xb6)
Format:
invokevirtual indexbyte1 indexbyte2
Operand Stack:
..., objectref, [arg1, [arg2 ...]] →
其中indexbyte1
和indexbyte2
为一个uint16类型的无符号整数,表示常量池中方法引用常量的下标,这点跟invokestatic
是完全相同的。后面的"→"表示出栈顺序,即执行到invokevirtual
时操作数栈的状态是方法参数在上,对象引用在下,其中方法参数是可选的,而且出栈时参数的顺序会跟方法定义里的顺序相反。
这里最重要的就是在objectref
中查找目标方法的过程。我们先来一段简单的代码:
public class ClassExtendTest {
public static void main(String[] args) {
Person person = new Person();
Printer.print(person.say());
person = new Student(); // Student继承了Person
Printer.print(person.say());
}
}
注意最后两行,Student
是Person
的子类,如果使用Person类型的变量保存其子类Student对象的引用,然后调用被子类重写了的say()
方法,这时候编译出的字节码如下:
15: new #6 // class com/fh/Student
18: dup
19: invokespecial #7 // Method com/fh/Student."<init>":()V
22: astore_1
23: aload_1
24: invokevirtual #4 // Method com/fh/Person.say:()I
可以看到,偏移量为15 ~ 19的字节码用于创建Student对象并调用构造方法,然后astore_1
则将刚创建的Student对象的引用保存到了本地变量表下标为1的槽位中,紧接着aload_1
就将本地变量表里的Student引用压入栈顶,这个就是前面JVM规范里提到的objectref
,同时也是say()
方法的真实接收者。这样当我们就可以从刚创建的Student对象中查找say()
方法了,而不是其父类Person。对于后面invokevirtual
跟着的两个bytes所指向的常量池里的方法常量
// 方法引用常量
type MethodRefConstInfo struct {
Tag uint8
ClassIndex uint16
NameAndTypeIndex uint16
}
我们只关心里面的方法名和方法描述符,即NameAndTypeIndex
字段,忽略ClassIndex
,因为栈顶已经有方法真实接收者的引用了。
综上,invokevirtual
指令查找方法的过程如下(省略访问权限验证):
- 从操作数栈顶开始向下遍历,找到
objectref
元素,取出(不弹出) - 取出
objectref
的class元数据,遍历方法组数,查找方法名跟方法描述符都匹配的方法,如果找到了就直接返回,没找到进入下一步 - 判断有没有父类,如果有则在父类中执行同样的查找,如果都没找到则抛出
NoSuchMethodException
异常
虚方法表
从上面的步骤可以看出,每次执行invokevirtual
指令都要在class元数据中做大量的查找,并且由于MethodRefConstInfo
里并没有直接保存方法名和描述符本身,而是保存了他们在常量池的索引,因此整个流程下来需要多次访问常量池才能获取到定位方法所需要的全部信息。对此我们可以使用虚方法表(Virtual Method Table)加以优化。
VTable本质上就是一个数组,数组的每个元素都保存了目标方法的入口和一些其他方便JVM具体实现所使用的方法信息。对于Object类,他的虚方法表里就会只保存Object里的里的公开方法;对于子类来说,方法表里的数据项排序一定是父类方法在前,自己新定义的方法在后,如果自己重写了父类的方法,那么只需要将前面的父类方法所在的数据项里的方法入口替换为子类自己的方法入口即可。
这里额外解释一下到底什么是"方法入口"。方法入口的说法是一种宏观且抽象的叫法,具体到代码里以什么方式体现是因不同的JVM实现而不同的。例如,如果你用C实现JVM,那么方法入口可以是一个方法指针,不过在我们Go实现的Mini-JVM中,方法入口则是包含了字节码的
MethodInfo
结构体指针,这里面保存了字节码数组因而可以直接遍历解释执行。
那么虚方法表如何提高方法查找效率呢?具体有两个层次的实现,一是在查找方法时直接遍历虚方法表而不是class元数据,这样就省去了多次访问常量池的开销,但是仍然需要一个个对比虚方法表里的数据项看看是不是要找的目标方法;二是在方法第一次执行时,我们可以将方法的符号引用直接替换为虚方法表数组的下标,这样以后再调用就能一步到找到目标方法,不需要任何遍历操作了。
Mini-JVM暂未实现对虚方法表第二个层次的优化,狗头
代码实现
以下片段摘自 https://github.com/wanghongfei/mini-jvm/blob/master/vm/interpreted_execution_engine.go ;
解析指令:
func (i *InterpretedExecutionEngine) invokeVirtual(def *class.DefFile, frame *MethodStackFrame, codeAttr *class.CodeAttr) error {
twoByteNum := codeAttr.Code[frame.pc + 1 : frame.pc + 1 + 2]
frame.pc += 2
var methodRefCpIndex uint16
err := binary.Read(bytes.NewBuffer(twoByteNum), binary.BigEndian, &methodRefCpIndex)
if nil != err {
return fmt.Errorf("failed to read method_ref_cp_index: %w", err)
}
// 取出引用的方法
methodRef := def.ConstPool[methodRefCpIndex].