C++中的多态与vtable
JVM实现晚绑定的机制基于vtable,即 virtual table ,也即虚方法表。JVM通过虚方法表在运行期动态确定所调用的目标类的目标方法。在讲解JVM的vtable概念之前,先一起品味 C++ 中虚方法表的实现机制,这两者有很紧密的联系。
有如下C++类:
这个C++示例很简单,类中包含一个short类型的变量和一个 run()方法 ,在 main()函数中 打印 3 个信息:CPLUS 类型宽度 、其实例的内存首地址和其变量x的内存地址。编译并执行,输出结果如下:
sizeof(CPLUS) = 2
&cplus = Ox7fff5ef57998
&(cplus.x) = Ox7fff5ef57998
由于CPLUS类中仅包含 1 个 short 类型的变量,因此该类型的数据宽度自然是2。另外注 意观察结果中的 cplus 实例和其变量x的内存地址,两者是相等的。
现在将C++类中的run()方法修改一下,变成虚方法,修改后的程序如下:
注意看,现在sizeof(CPLUS)的值变成16了,并且 cplus 实例和其变量 x 的内存地址也不再相等了。这是咋回事呢?这是因为当 C++类中出现虚方法时,表示该方法拥有多态性,此时会根据类型指针所指向的实际对象而在运行期调用不同的方法,这与 Java 中的多态在语义上是完全一致的。
C++为了实现多态,就在C++类实例对象中嵌入虚函数表vtalbe,通过虚函数表来实现运行 期的方法分派。C++中所谓虚函数表,其实就是一个普通的表,表中存储的是方法指针,方法指针会指向目标方法的内存地址。所以虚函数表就是一堆指针的集合而已。
对于大部分C++编译器而言,其实现虚函数表的机制都大同小异,都会将虚函数表分配在C++对象实例的起始位置,当C++类中出现虚函数表时,其内存分配就是先分配虚函数表,再分配类中的字段空间。以本示例程序而言,CPLUS的实例对象 cplus 的实际内存结构如图所示。
由于CPLUS类中仅包含一个虚函数,因此虚函数表中只有一个指针。
注意,在cplus实例的末尾有一段补白空间,这是因为C++编译器会对类型做对齐处理,整个C++类实例对象所占的内存空间必须是其中宽度最大的字段所占内存空间的整数倍,而CPLUS类中由于嵌入了虚函数表,表中元素是指针类型,在 64 位平台上,1 个指针占 8 字节内存空间,因此 CPLUS 类实例对象所占的内存空间就是 16 字节,这就是上面运行示例程序后输出结果中的 sizeof(CPLUS)的值变成 16 的原因所在。同时,字段 x 被安排在虚函数表之后,因此 x 的内存地址也不再与cplus 实例对象的内存首地址相等,并且根据上述程序运行结果可见,这两者的内存地址相差 8字节,这正好是一个指针的宽度。
Java中的多态实现机制
Java的多态机制并没有跳出这个圈,也采用了 vtable 来实现动态绑定。Java 类在 JVM内部对应的对象是instanceKlassOop(JDK 8中是instanceKlass)。在JVM加载Java类的过程中,JVM会动态解析Java类的方法及其对父类方法的重写,进而构建出一个 vtable ,并将 vtable 分配到 instanceKlassOop 内存区的末尾,从而支持运行期的方法动态绑定。
JVM的vtable机制与 C++的vtable机制之间最大之不同在于,C++的vtable在编译期间便由编译器完成分析和模型构建 ,而JVM的vtable则在JVM运行期、Java类被加载时进行动态构建。其实也可以认为JVM在运行期做了C++编译器在静态编译期所做的事情。
Java类在运行期间进行动态绑定的方法,一定会被声明为 public 或者 protected的,并且没有 static 和 final 修饰,且Java 类上也没有 final 修饰。道理很简单,阐述如下:
如果一个Java方法被static 修饰,则压根儿不会参与到整个 Java 类的继承体系中 , 所以静态的Java方法不会参与到运行期的动态绑定机制 。所谓的动态绑定,是指将 Java 类实例与 Java 方法搭配,而静态方法的调用压根儿不需要经过类实例,只需要有类型名 即可。
如果一个Java方法被 private 修饰 ,则外部根本无法调用该方法,只能被该类内部的其他方法所调用,因此也不会参与到运行期的动态绑定。
如果一个Java方法被 final 修饰,则其子类无法重写该方法(如果非要重写,则 IDE 会报错的,并且编译也不会通过。既然子类无法重写该方法,则该方法仅为 Java 类所固有 不会出现多态性。
如果一个Java类被 final 修饰,则该 Java 类中的所有非静态方法都会隐式地被 final 修饰,参考第 3 条,则该 Java 类中的所有非静态方法都不会被子类重写,因此 都不会出现多态性,不会发生运行期动态绑定。
只有满足以上4个条件的 Java 方法,才有可能参与到运行期的动态绑定,而满足了以上 4 个条件的 Java 方法,其一定只被 public 或者 protected 关键字修饰。而满足了这 4 个条件的 Java 方法只是有可能参与动态绑定,这是因为仅仅满足了这 4 个条件还不够,还得有别的条件,其余的条件包括以下:
父类中必须包含名称相同的Java方法。这里所谓的父类,并不一定是 Java 类的直接父类,也可能是间接的父类。例如 A 类继承了 B 类,B 类继承了 C 类,则 A类间接继承了 C 类。
父类中名称相同的Java类,其签名也必须完全一致。
以上这两点其实换而言之,就是Java类中的方法必须重写了父类的 Java 方法,这样的 Java 类方法才会参与到运行期动态绑定。而这其实正是多态的含义 :父类与子类中包含一个完全一样的行为(即 Java 方法的名称和签名完全相同,在运行期这一行为将根据不同的条件与具体的类型对象相绑定(父类或子类实例。
而父类与子类都同时拥有的相同的行为,从继承的角度看,就是子类对父类的重写,并且重写的前提是,这一行为不能是private的,不能是 static 的,不能是 final 的,否则父类的行为无法被子类继承,所谓的重写更无从谈起了。所以上面这段代码的逻辑就是在进行这一系列的判断,只有最终满足条件的,才会返回 true 。
现在我们描述JVM内部vtable的实现机制。每一个 Java 类在JVM内部都有一个对应的instanceKlassOop , vtable就被分配在这个 oop 内存区域的后面。vtable 表中的每一个位置存放一个指针,指向 Java 方法在内存中所对应的methodOop 的内存首地址。如果一个 Java 类继承了父类,则该 Java 类就会直接继承父类的 vtable 。若该 Java 类中声明了一个非 private 、非 final 、 非 static 的方法,若该方法是对父类方法的重写,则只叫会更新父类 vtable 表中指向父类被重写的方法的指针,使其指向子类中该方法的内存地址。若该方法并不是对父类方法的重写,则 JVM 会向该 Java 类的 vtable 中插入一个新的指针元素,使其指向该方法的内存位置。
相信很多人对这段文字看得有些晕,还是举个例子。假设有下面一个超类:
接着定义一个子类:
子类B继承于类 A,并且重写了类 A 的void print()方法。由于类 B 和类 A 中的 print()方法名称相同,签名也完全相同,并且都没有 private 、static 、final 这 3 个关键字修饰,因此该方法将会在运行期进行动态绑定。
当HotSpot在运行期加载类 A 时,其 vtable 中将会有一个指针元素指向其 void print()方法 在 Hotspot 内部的内存首地址。当 HotSpot 加载类 B 时,首先类 B 完全继承其父类 A 的vtable , 因此类 B 便也有一个 vtable ,并且 vtable里有一个指针指向类 A的 print()方法的内存地址。
Hotspot遍历类 B 的所有方法,并发现 print()方法是 public 的,并且没有被 static 、final 修饰,于是 HotSpot 去搜索其父类中名称相同、签名也相同的方法,结果发现父类中存在一个完全一样的方法 ,于是 Hotspot 就会将类 B 的vtable 中原本指向类 A 的print()方法的内存地址的指针值修改成指向类B自己的 print()方法所在的内存地址。
而当Hotspot解析类 B 的 newFun()方法时,由于该方法并没有在父类中出现,并且也是 public 的,同时没有 static 和 mal 修饰,满足 vtable 的条件,于是 Hotspot 将类 B 原本继承于 A 的 vtable 的长度增 1,并将新增的 vtable 的指针元素指向 newFun()方法在内存中的位置。
在Java语言中,所有 Java 类都隐式继承了顶级父类 java.lang.Object,类 A 的 vtable 的另外 5 个方法指针元素便指向 java.lang.Object 中的 5 个方法。java.lang.Object 中一共定义了如下 12 个方法 :
而其中只有如下5个方法可以被子类重写,因此 java.lang.Object 类的 vtable 大小为 5,这 5个方法如下:
这5个方法都是 public 或者 protected 的,并且没有用 static 和 final修饰,而java.lang.Object中的其他方法要么被 final 修饰,要么被 static 修饰,因此都不能被子类继承所以其方法指针不会被JVM添加到vtable虚函数表中。因此,并不是 Java 类中的所有方法都会被放入 vtable中。使用同样的方式可以看到类 B 的 vtable 大小是 7。
vtable与 invokevirtual 指令
当一个Java方法调用了另一个 Java 方法时,是如何实现 Java 方法的调用的?这得从 Java的字节码指令开始说起。
Java的字节码指令中方法的调用实现分为 4 种指令 :
Invokevirtual,为最常见的情况,包含 virtual dispatch(虚方法分发)机制。
Invokespecial,调用 private 和构造方法 ,绕过了 virtual dispatch。
lnvokeinterface,其实现与 invokevirtual类似。
Invokestatic,调用静态方法。 .
其中最复杂的要属invokevirtual指令,它涉及多态的特性,凡是 Java 类中需要在运行期动态绑定的方法调用,都通过 invokevirtual 指令,该指令将实现方法的分发,因此 vtable 与该指令之间有莫大的联系,而事实上,在 Hotspot 执行 invokevirtual 指令的过程中,最终会读取被调用的类的 vtable 虚函数表,并据此决定真实的目标调用方法。只叫内部实现 virtual dispatch 机制时,会首先从 receiver(被调用方法的对象) 的类的实现中查找对应的方法,如果没找到,则去父类查找,直到找到函数并实现调用,而不是依赖于引用的类型。这里还是先来看一个例子,感受一下上面这 4 个指令的用法 :
编译这段程序,并使用javap命令分别分析 Test.main()函数中的3个方法调用的字节码指令,以及类 B.print()方法中调用 newFun()和 privateFunO()时所使用的字节码指令。
首先看B类的 print()方法调用 newFun()和 privateFun()方法时的字节码指令,javap分析的结果如下所示 :
由javap分析的结果可知 ,在 B.print()方法中调用 newFun()和 privateFun()方法时所使用的字节码指令分别是 invokevirtual和 invokespecial,为何在类 B 内部调用自己的这两个方法 ,所使用的指令竟然还有所不同呢?
这是因为newFun()是 public 的,并且没有 static 和final修饰,因此这个方法是可以被继承的,并且是可以被子类重写的。而编译器在编译期间并不知道类 B 有没有子类,因此这里只能使用invokevirtual指令去调用newFun()方法,从而使newFun()方法支持在运行期进行动态绑定。 虽然编译器在编译期间可以分析整个工程以确定类 B 到底有无子类,但是别忘了JVM可是能够支持在运行期动态创建新的类型(例如,使用cglib)的,编译器根本无法得知在运行期会不会突然冒出个类去继承类 B 并重写类 B 的newFun()方法。
而privateFun()方法则不一样,其为类 B 的私有方法,就算有子类继承于类 B,也无法重写该方法,因此该方法不需要参与动态绑定,在编译期间便能直接确定其调用者,所以其对应的字节码指令是 invokespecial。
与B.printQ方法中调用 newFun()方法使用 invokevirtual 指令同样的道理,在 Test 类的 main() 主函数中调用 a.print() 、b.print()和 b.newFun()这 3 个方法时,所对应的字节码指令也都是 invokevirtual 这是因为这 3 个方法都是可以被子类所重写的,所以编译器在编译期间无法确定其真实的调用方到底是谁只能通过 invokevirtual 指令在运行期进行动态绑定。
vtable特点总结
前文对vtable进行了比较全面的研究和验证这里再次总结下其特点:
1.vtable分配在 instanceKlassOop对象实例的内存末尾。
2.所谓vtable,可以看作是一个数组,数组中的每一项成员元素都是一个指针,指
针指向Java方法在 JVM 内部所对应的 method 实例对象的内存首地址。
3.vtable是 Java 实现面向对象的多态性的机制如果一个 Java 方法可以被继承和重写,则最终通过 invokevirtu al字节码指令完成 Java 方法的动态绑定和分发。事实上,很多面向对象的语言都基于 vtable 机制去实现多态性,例如 C++。
4.Java子类会继承父类的 vtable。
5.Java中所有类都继承自 java.lang.Object,java.lang.Object 中有 5 个虚方法 (可被继承和重写):
void finalize()
boolean equals(Object)
String toString()
int hashCode()
Object clone()
因此,如果一个Java类中不声明任何方法,则其 vtalbe 的长度默认为 5。
6.Java类中不是每一个 Java 方法的内存地址都会保存到 vtable 表中。只有当 Java 子类中声明的 Java 方法是 public 或者 protected 的,且没有 final 、static 修饰,并且Java子类中的方法并非对父类方法的重写时,JVM 才会在 vtable 表中为该方法增加一个引用。
如果Java子类某个方法重写了父类方法,则子类的 vtable中原本对父类方法的指针引用会被替换为对子类的方法引用。
vtable机制逻辑验证
上文一直在讲Java中实现多态是通过 vtable 这个机制,但是 vtable 到底是如何实现多态机制的呢?这在JVM内部颇为复杂,这里先对其进行逻辑上的推理让各位可以在不用知道具体源码实现细节的前提下,也能够知道如何“玩转”多态。
本示例在本节开始讲解vtable的时候提到过,不过稍微做了一点修改。在本示例中,有父 类 Animal 和子类 Dog,子类 Dog 重写了父类的 say()方法。
根据前文所讲的vtable的构成原理,类 Animal的 vtable的长度应该为 6,除了所继承的 java.lang.Object 中的 5 个虚方法(即可被重写的方法)外,其自身仅包含 1 个虚方法。并且其 vtable 中的第 6 个指针元素指向 say()方法在JVM内部所对应的method实例对象的内存地址。 同理,子类 Dog 的 vtable 的长度也应该等于 6 ,因为前文讲过,子类会完全继承父类的 vtable, 并且如果子类重写了父类的方法,则JVM会将子类vtable中原本指向父类方法的指针成员修改成重新指向子类的方法。
类Animal和子类 Dog 的 vtable 结构分别如图中左图和右图所示。
在JVM运行期,会根据对象引用所执行的实际的实例调用实例的方法。不过,这首先得从编译器说起。上面示例中Animal.run(Animal)方法所对应的字节码指令如下(使用javap 命令显示):
Animal.run(Animal方法的字节码指令主要的逻辑包含两步,第一步是 aload_O,第二步是 invokevirtual。字节码指令 aload O 表示从第 0 个 slot 位置加载 Java 引用对象。由于Animal.run(Animal)方法是一个 static 静态方法 ,其人参并没有隐式的 this 指针,所以 slot 中的第一个局部变量就是 Animal.run (Animal)的第一个人参 animal 引用对象。接着第二步执行 invokevirtual 指令,Java 多态的秘密就隐藏在该指令后面所跟的操作数 operand 中。invokevirtual 指令后面的操作数是常量池的索引值,该值为 10, javap 命令显示索引为 10 的常量池所代表的字符串是 Method say:()V,这表示 invokevirtual 在运行期调用的方法是 void say(。在运行期,JVM 将首先确定被调用的方法所属的 Java 类实例对象,JVM会读取被调用的方法的堆钱,并获取堆栈中的局部变量表的第0个 slot 位置的数据,该数据一定是指向被调用的方法所属的 Java 类实例,原因很简单,凡是所对应的字节码指令为 invokevirtual 的 Java 方法,其必定是 Java 类的成员方法,而非静态方法,而 Java 类的成员方法的第一个人参一定是隐式的 this 指针,该指针就指向Java 类的对象实例。同时,Java 类的第一个入参一定位于局部变量表的第0个位置,因此JVM可以从被invokevirtual所调用的方法的局部变量表中读取到 this 指针,从而得知被调用的 Java 类实例到底是哪一个。
以本示例为例,当animal引用变量指向 new Dog()对象实例时,则 say()方法的局部变量表第一个人参便指向 new Dog()这个对象实例。同理,当 animal 引用变量指向 new Animal()对象实例时,则 sayO方法的局部变量表的第一个入参便指向 new Animal ()实例对象。
JVM获取到被 invokevirtual 指令所调用的方法所属的实际的类对象时,接着便能够通过对象获取到其对应的 vtable 方法分发表。vtable 表中保存当前类的每一个方法的指针。JVM会遍历vtable中的每一个指针成员,并根据指针读取到其对应的 method 对象,判断 invokevirtual 指令所调用的方法名称和签名与 vtable 表中指针所指向的方法的名称和签名是否一致,如果方法名称和签名完全一致,则算是找到了 invokevirtual 所实际调用的目标方法,于是只叫定位到目标方法的第一条字节码指令并开始执行。如此便完成了方法在运行期的动态分发和执行。
还是以本示例为例说明,当animal引用变量指向 new Dog()对象实例时,JVM会遍历Dog类所对应的vtable 表,并搜索其中名称为“say”且签名为“void()”的方法,很显然,Dog类中存在该方法,于是 JVM 最终执行 Dog 类的 say()方法。同理,当 animal 引用变量指向 new Animal()对象实例时,JVM最终执行的就是Animal类中的 say()方法。
事实上,C++的虚方法分发的原理与此完全类似,所不同的是C++的vtable由编译器在编译期间完成构建,而Java 类的 vtable 则由JVM在运行期进行构建。
网友评论