从jvm虚拟机角度看Java多态 ->(重写override)的实现原理
工具与环境:
· Windows 7 x64企业版
· Cygwin x64
· jdk1.8.0_162
· openjdk-8u40-src-b25-10_feb_2015
· Vs2010 professional
0x00: Java多态简单介绍
1、多态的概念
JAVA类被jvm加载运行时根据调用该方法的对像实例的类型来决定选择调用哪个方法则被称为运行时多态。也叫动态绑定:是指在执行期间判断所引用对象实例的实际类型,根据其实际的类型调用其相应的方法。
2、多态的优点
a.可替换性:多态对已存在代码具有可替换性。
b.可扩充性:多态对代码具有可扩充性。
c.灵活性:它在应用中体现了灵活多样的操作,提高了使用效率。
d.简化性:多态简化对应用软件的代码编写和修改过程,尤其在处理大量对象的运算和操作时,这个特点尤为突出和重要。
3、示例代码(以下分析基于该代码)
public class Animal{
public void say(){
System.out.println("is animal");
}
public static void main(String[] args){
Animal animal = new Dog();
run(animal);
animal = new Cat();
run(animal);
}
public static void run(Animal animal){
animal.say();
}
}
class Dog extends Animal{
public void say(){
System.out.println("is Dog");
}
}
class Cat extends Animal{
public void say(){
System.out.println("is Cat");
}
}
编译:javac Animal.java 生成 .class 文件。
运行后如下图:
4. 上面示例程序中定义了类 Animal ,同时定义了 2 个子类 Dog 和 Cat,这 2 个子类都重写了基类中的 say()方法 。 在 main()函数中,将 animal 实例引用分别指向 Dog 和 Cat 的实例, 并分别调用 run(Animal)方法。 在本示例中,当在 Animal.run(Animal)方法中执行 animal.say()时, 因为在编译期并不知道 animal 这个引用到底指向哪个实例对象,所以编译期无法进行绑定,必须等到运行期才能确切知道最终调用哪个子类的 say()方法,这便是动态绑定,也即晚绑定,这是 Java语言以及绝大多数面向对象语言的动态机制最直接的体现。
0x01 C++的多态与vftable分析
(1)在分析JVM多态的实现原理之前,我们先一起看看 C++中虚方法表的实现机制,这两者有很紧密的联系,有助于我们理解JVM中的多态机制。
(2)示例代码:
class Cpuls{
public:
int x;
public:
void func(){
this->x = 2;
}
};
int main(){
Cpuls cplus;
return 0;
}
这个 C++示例很简单,类中包含一个 int 类型的变量和一个 run()方法,在 main函数中定义一个Cpuls对像。通过vs 调试时看内存情况,没有虚函数时对象的内存表现如下图:
由于 CPLUS 类中仅包含 l 个 int 类型的变量 ,因此观察结果中的 cplus 实例内存地址,只有变量 x 。
现在将 C++类中的 run方法修改一下,变成虚方法,在观察对象的内存表现:
注意看,现在的值变了,cplus 实例首地址不是其变量x了,而是一个vfable,这就是虚表,并且vfable中存放加了virtual关键字的虚函数func函数的地址,这是因为当 C++类中出现虚方法时,表示该方法拥有多态性,此时会根据类型指针所指向的实际对象而在运行期调用不同的方法。
C++为了实现多态,就在 C++类实例对象中嵌入虚函数表vfable ,通过虚函数表来实现运行期的方法分派 。 C++中所谓虚函数表,其实就是一个普通的表,表中存储的是方法指针, 方法指针会指向目标方法的内存地址,所以虚函数表就是一堆指针的集合而已。
详细的可以看这位大牛的分析:https://bbs.pediy.com/thread-221160.htm
0x02 JVM中函数重写实现机制
(1)Java中的多态在语义上与上面分析C++的原理是相同的,Java在JVM中的多态机制并没有跳出这个圈也采用了 vftable 来实现动态绑定。
JVM 的 vftable 机制与 C++的 vftable机制之间的不同点在于, C++的 vftable 在编译期间便由编译器完成分析和模型构建,而 JVM 的 vftable 则在 JVM 运行期类被加载时进行动态构建。下面通过hotspot源码来分析JVM中函数重写机制。
(2)当我们通过java 执行class文件时,JVM 会在第一次加载类时调用classFileParser.cpp::parseClassFile() 函数对 Java class 文件字节码进行解析,在 parseClassFile() 函数中会调用 parse_methods() 函数解析class文件类中的方法,parse_methods()函数执行完之后 ,会继续调用 klassVtable::compute_vtable_size_and_num_mirandas() 函数,计算当前类的vtable大小,下面看看该方法实现的主要逻辑:
判断是否有虚函数,如果有就将个数增加,src\share\vm\oops\klassVtable.cpp
上面这段代码计算 vftable 个数的思路主要分为两步 :
a:获取父类 vftable 的个数,并将当前类的 vftable 的个数设置为父类 vftable 的个数。
b:循环遍历当前 Java 类的每一个方法 ,调用 needs_new_vtable_entry() 函数进行判断,如果判断的结果是 true ,则将 vftable 的个数增 1 。
(3)现在看 needs_new_vtable_entry() 函数是如何判断虚函数的,判断条件是什么?
上面代码主要判断Java 类在运行期进行动态绑定的方法,一定会被声明为 public 或者 protected 的,并且没有 static 和 final 修饰,且 Java 类上也没有 final 修饰 。
(4)当class文件被分析完成后就要创建一个内存中的instanceKlass对象来存放class信息,这时就要用到上面分析的虚表个数了vtable_size。该变量值将在创建类所对应的instanceKlass对象时被保存到该对象中的一vtable_Ien 字段中。
// We can now create the basic Klass* for this klass
_klass = InstanceKlass::allocate_instance_klass(loader_data,
vtable_size,
itable_size,
info.static_field_size,
total_oop_map_size2,
rt,
access_flags,
name,
super_klass(),
!host_klass.is_null(),
CHECK_(nullHandle));
(5)当class分析并将相关的信息存放在instanceKlass实例对像中后就准备要执行函数了, 在分析重写之前我们来看看vtable在什么地方。
\src\share\vm\oops\instanceKlass.cpp
link_class->link_class_impl
//初始化虚表
// Initialize the vtable and interface table after
// methods have been rewritten since rewrite may
// fabricate new Method*s.
// also does loader constraint checking
if (!this_oop()->is_shared()) {
ResourceMark rm(THREAD);
this_oop->vtable()->initialize_vtable(true, CHECK_false);//初始化虚表
this_oop->itable()->initialize_itable(true, CHECK_false);
}
每一个 Java 类在 JVM 内部都有一个对应的instanceKlass, vtable 就被分配在这个 oop 内存区域的后面。
inline InstanceKlass* klassVtable::ik() const {
Klass* k = _klass();
assert(k->oop_is_instance(), "not an InstanceKlass");
return (InstanceKlass*)k;
}
klassVtable(KlassHandle h_klass, void* base, int length) : _klass(h_klass) {
_tableOffset = (address)base - (address)h_klass(); _length = length;//虚表偏移(InstanceKlass对像大小)
}
//虚表地址
vtableEntry* table() const {
return (vtableEntry*)(address(_klass()) + _tableOffset); //InstanceKlass对像基址加上InstanceKlass对像大小
}
instanceKlass大小在 windows64系统的大小为0x1b8如下,后面用hsdb查看vtable时会用到。
(6)方法的重写主要在该函数中:
klassVtable::initialize_vtable(bool checkconstraints, TRAPS) 函数主要逻辑:
以上代码逻辑主要是调用update_inherited_vtable函数判断子类中是否有与父类中方法名签名完全相同的方法,若该方法是对父类方法的重写,就调用klassVtable::put_method_at(Method* m, int index)函数进行重写操作,更新父类 vtable 表中指向父类被重写的方法的指针,使其指向子类中该方法的内存地址。 若该方法并不是对父类方法的重写,则会调用klassVtable::put_method_at(Method* m, int index)函数向该 Java 类的 vtable 中插入一个新的指针元素,使其指向该方法的内存地址,增加一个新的虚函数地址。
(7)用上面的Animal文件调试分析,看看vtable内存情况。
Animal 父类
say 0x14890250 index 5
table() 0x14890520 vtableEntry *
table()[index] {_method=0x14890250 } vtableEntry
Dog
say 0x148906e0 index 5
table() 0x14890920 vtableEntry *
table()[index] {_method=0x14890250 } vtableEntry //没有替换前与Animal中say函数地址相同
table()[index] {_method=0x148906e0 } vtableEntry //替换后为Dog的say函数地址
当Hotspot在运行期加载类Animal时,其 vtable 中将会有一个指针元素指向其say方法在Hotspot内部的内存首地址,当 Hotspot 加载类 Dog 时, 首先类 Dog 完全继承其父类 Animal 的 vtable,因此类 Dog 便也有一个 vtable ,并且 vtable 里有一个指针指向类 Animal 的 say方法的内存地址 。
Hotspot 遍历类 Dog 的所有方法,并发现say方法是 public 的,并且没有被 static 、 final 修饰,于是 HotSpot 去搜索其父类中名称相同、签名也相同的方法,结果发现父类中存在一个完全一样的方法,于是 HotSpot就会将类 Dog 的 vtable 中原本指向类 Animal 的 say方法的内存地址的指针值修改成指向类Dog 自己的say方法所在的内存地址 。
0x03: HSDB 查看java 类中的 vtable
(1)下面我们将通过hsdb来验证前面的分析。如前面所述,Java 类在 JVM 内部所对应的类型是 instanceKlass,根据上面一节的分析,我们知道vtable 便分配在 instanceKlass 对象实例的内存末尾 。
instanceKlass 对象实例在X64平台上内存中所占内存大小是 Oxlb8 字节(32位平台上 sizeof(InstanceKlass)=0x00000108),换算成十进制是 440。 根据这个特点,可以使用 HSDB获取到 Java 类所对应的 instanceKlass 在内存中的首地址,然后加上 Oxlb8 ,就得到 vtable 的内存地址 ,如此便可以查看这个内存位置上的 vtable 成员数据 。
还是用 Animal 文件做示例,类 Animal 中仅包含 1 个 Java 方法 ,因此类 Animal 的 vtable长度一共是 6 ,另外 5 个是超类 java.lang.Object 中的5个方法。使用JDB 调试(jdb -XX:-UseCompressedOops Animal),并运行至断点处使程序暂停(stop in Animal.main)->(run),jps查看ID,然后使用 HSDB 连接上测试程序(java -classpath "%JAVA_HOME%/lib/sa-jdi.jar" sun.jvm.hotspot.HSDB),打开 HSDB 的 Tools->Class Browser 功能,就能看到类 Animal 在 JVM 内部所对应的 instanceKlass 对象实例的内存地址,如图所示 。
由上图可知,类 Animal 在JVM内部所对应的 instanceKlass的内存首地址是 0x00000000320004a8,上一节分析知道vtable 被分配在 instanceKlass的末尾位置,因此 vtable 的内存首地址是 :
0x00000000320004a8 + Oxlb8 = Ox0000000032000660
这里的 Oxlb8 是 instanceKlass 对象实例所占的内存空间大小 。 得到 vtable 内存地址后,便可以使用 HSDB 的 mem 工具来查看这个地址处的内存数据。单击 HSDB 工具栏上的 Windows->Console 按钮,打开 HSDB 的终端控制台,按回车键,然后输入“ mem Ox32000660 6”命令,就可以查看从 vtable 内存首地址开始的连续 6 个双字内容,如下所示:
在 64 位平台上, 一个指针占 8 字节 ,而 vtable 里的每一个成员元素都是一个指针,因此这里 mem 所输出 的 6 行 ,正好是类 Animal 的 vtable 里的 6 个方法指针,每一个指针指向 l 个方法在内存中的位置。 类 A 的 vtable 总个数是 6 ,其中前面 5 个是基类 java.lang.Object 中的 5 个方法的指针 。上面 mem 命令所输出的第 6 行的指针, 一定就是指向类 Animal 自己的say方法的内存地址 。 使用HSDB 查看类 A 的方法的内存地址,如图中所示,地址刚好对应得上。其它的类也可以用同样的方式分析。
0x04 总结
前面对jvm 的vtable 进行了研究和验证,再总结下特点:
(1)vtable 分配在 instanceKlass 对象实例的内存末尾 。
(2)其实vtable可以看作是一个数组,数组中的每一项成员元素都是一个指针,指针指向 Java 方法在 JVM 内部所对应的 method 实例对象的内存首地址 。
(3)vtable是 Java 实现面向对象的多态性的机制,如果一个 Java 方法可以被继承和重写, 则最终通过 put_method_at函数将方法地址替换,完成 Java 方法的动态绑定。
(4)Java 子类会继承父类的 vtable,Java 中所有类都继承自 java.lang.Object, java .lang.Object 中有 5 个虚方法(可被继承和重写):
void finalize()
boolean equals(Object)
String toString()
int hashCode()
Object clone()
因此,如果一个 Java 类中不声明任何方法,则其 vtalbe 的长度默认为 5 。
(5)Java 类中不是每一个 Java 方法的内存地址都会保存到 vtable 表中,只有当 Java子类中声明的 Java 方法是 public 或者 protected 的,且没有 final 、 static 修饰,并且 Java 子类中的方法并非对父类方法的重写时, JVM 才会在 vtable 表中为该方法增加一个引用 。
(6)如果 Java 子类某个方法重写了父类方法,则子类的vtable 中原本对父类方法的指针会被替换成子类对应的方法指针,调用put_method_at函数替换vtable中对应的方法指针。
以上只是个人学习的一点总结,水平能力有限,如果有不对的地方还请多多指教,万分感谢。
本文由看雪论坛 我是小三 原创
转载请注明来自看雪社区
往期热门阅读:
点击阅读原文/read,
更多干货等着你~
扫描二维码关注我们,更多干货等你来拿!