[Golang实现JVM第七篇]实现invokevirtual和虚方法表

本文深入探讨JVM中`invokevirtual`指令的实现,介绍虚方法表(Virtual Method Table)如何优化方法查找效率。通过实例展示了在Golang中如何构建Mini-JVM,解析字节码并执行`invokevirtual`,同时讲解了虚方法表的构造和方法查找过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本篇我们专注invokevirtual这一条指令,先通过简单粗暴的方式实现指令的功能,然后探究如何通过著名的虚方法表(Virtual Method Table)来进行一些优化。

指令含义

invokevirtual用于调用除静态方法、构造方法、私有方法、接口方法外的所有方法。其指令的格式为:

invokevirtual = 182 (0xb6)

Format:
invokevirtual indexbyte1 indexbyte2

Operand Stack:
..., objectref, [arg1, [arg2 ...]] →

其中indexbyte1indexbyte2为一个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());
    }
}

注意最后两行,StudentPerson的子类,如果使用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].
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值