Java调用重载方法(invokevirtual)和接口方法(invokeinterface)的解析

    多态,作为面向对象的重要概念之一,是多数的高级语言都有的特性。C++利用编译期间确定的虚表的offset来进行虚函数的调用,从而实现多态。虽然性能高效,但在升级时很容易造成二进制兼容性的问题。Java则在编译期确定的函数签名,通过全局符号表的定位,从而在运行期间再确定真正的虚表索引,来实现多态。经过解析后会把index存放到cache里为下次调用加速。这样就减少了由于索引的更改带来的二进制兼容的问题。

 

C++

    在C++里,大多数的编译器,对于调用重载的虚方法,都保留在类的一个叫做虚表(vtable)的地方,这个虚表其实是一个函数指针数组,函数是从父类的虚方法到子类的虚方法按照顺序排序,如果子类重载了基类的虚方法,则会覆盖父类的虚函数的指针。如:

Class BBase
{
    virtual f1() { printf("Base::f1!"); }
    virtual f2(int i) = 0;
}
 
Class B : publicBBase
{
    Virtual f2(int i) { printf(“f2!”); }
   
    Virtual e1() { printf(“e1”); }
}

    类B的内存大致为:

Java调用重载方法(invokevirtual)和接口方法(invokeinterface)的解析_第1张图片

    当编译器遇到调用虚方法的代码时,是通过vtable指针以及对应方法在虚表里的offset,然后获取对应的函数指针实现的,由于offset在编译过程就已经固定了,这样在执行过程中几乎没有产生任何额外的计算就实现了多态调用,效率相当高。

    但凡事都有两面性,这样的做法就有较大的缺陷,如组件升级时的二进制兼容性带来了很大的麻烦。假设在A.dll的类A调用了在B.dll里的类B的一个虚方法,如果此时由于需求更改,我们需要对类B增加了虚方法,但不幸地,我们的修改不小心导致了原来的虚方法的offset产生了变化(如果是VS编译器,则有一些修改的原则可以避免),那么此时在运行A.dll里的类A则会产生无法预知的后果(有可能调用了类B的其它虚方法,但此时堆栈会被破坏,最终还是会崩溃,而且崩溃堆栈会很让人费解)。

 

Java

    在Java里,则可以不用担心像C++的虚方法修改所带来offset影响的问题(除非你把原来的虚方法删除或者修改了签名)。首先,Java同样也有vtable和offset的概念,并且最终也是通过在虚表的索引来获取最终调用函数的地址,但不同的是,Java并不是在编译过程中就确定了vtable的offset(暂时忽略非重载方法的调用invokestatic/invokespecial)。

    假设有这样的调用:

BBase base = BBase.getBase();
base.f1();

    Java每个class文件都有一个常量池的概念,主要是关于类、方法、接口等中的常量,也包括字符串常量和符号引用。Java在调用虚函数的地方都保留了调用函数签名字符值,包括函数的返回值、函数名、参数列表。这些字符值都存放到class文件的常量池中。然后生成对应的字节码,对于普通的虚方法,则是invokevirtual。另外class文件里的类本身定义的虚函数的函数签名也会保留到常量池中。

    加载类

   


    在加载该类的时候,常量池的所有虚函数的签名(包括调用的以及自身定义的)都会添加到全局的符号表(事实上是一个HashTable)。首先对字符值进行Hash值计算,然后在全局HashTable进行查找,如果发现已经存在对应的Hash值,则返回对应的符号指针Symbol *,否则创建新的Symbol并添加到HashTable中,然后返回新创建的Symbol *。这样常量池就把字符串的引用转换成符号的引用。另外这个过程可以确保所有字符串在jvm只存有一个引用。

    第一次调用方法

Java调用重载方法(invokevirtual)和接口方法(invokeinterface)的解析_第2张图片


    然后当在某个类对象调用虚方法的时候,通过调用函数的符号和自身定义的符号进行比较(由于这里都是引用全局符号表的唯一符号,因此可以通过内存地址进行快速比较),就会解析出调用虚函数的信息,通过信息就可以获取虚表的索引,然后调用对应的虚函数字节码。另外,为了提高调用时的性能,Java采用的是Lazy解析,第一次解析出虚表的索引后,则会保留到cache里面,这样下次调用就可以从缓存直接获取索引。

    不过,如果是调用接口,则需要每次都要进行解析来获取索引。这是由于Java可以实现多个接口,不同的类可能会实现了多个或者不同的接口,在虚表里该接口所实现方法的索引会不一致。这样每次解析的虚表索引都可能会不同,因此不能进行缓存,需要每次都进行重新的解析。因此,接口的方法调用会比普通的子类继承的虚函数调用要慢。另外,为了表现接口调用的不同解析做法,JVM会插入另外的字节码invokeinterface来指示需要每次调用解析。

    源码路径

 有兴趣查看具体实现的同学可以下载openjdk的源码查看jvm的具体C++实现。路径在openjdk/hotspot/src下。

 从BytecodeInterpreter::run()方法的CASE(_invokevirtual):开始。重点是LinkResolver::resolve_invokevirtual()方法,具体里面会调用到resolve_pool(在常量池解析索引到Symbol*)和resolve_virtual_call(解析具体的类方法,获取虚表索引)。

你可能感兴趣的:(java)