用 HSDB 来探究多态实现的原理
HSDB 全称:Hostspot Debugger,是 JVM 内置的工具,用于深入分析 JVM 运行时的内部状态,工具在 JDK 安装目录下 lib/sa-jdi.jar
一、启动 HSDB
java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
二、关联 JVM 进程
关联进程,就要获取到进程号,而我们知道,一个 Java 程序就是对应一个 JVM 进程,程序执行完 JVM 进程也就停止了,因此我们需要打断点,暂停程序,以便获取 JVMA 进程号
多态栗子
① 举个多态栗子
public abstract class A {
public void printMe() {
System.out.println("I love vim");
}
public avoid sayHello();
}
public class B extends A {
@Override
public void sayHello() {
System.out.println("hello, i am child B");
}
}
public class MyTest {
public static void main(String[] args) throws IOException {
A obj = new B();
System.in.read(); // 这句是阻塞的
System.out.println(obj);
}
}
上面 MyTest 类 main() 方法中的 System.in.read(); 是阻塞的
② 运行 MyTest,然后打开任务管理器,找到 javaw.exe 进程,进程号 PID 为 10104
③ 在 HSDB 中关联进程号
选择 File -> Attach to hotspot process
在弹出的窗口,输入 10104,然后点 OK
显示如下界面
选择 Tools -> Class Browser(对象列表)
在对象列表中,找到 B 对象的内存地址为 0x00000007c0060418
然后选择 Tools -> Inspector
可以看到它的 vtable 长度为 7,其中有 5 个是 Object 的方法,一个是 B 复写 A 的 sayHello() 方法,一个是继承 A 的 printMe() 方法
如上图所示,vtable 分配在 instanceKlass 对象实例的内存末尾,instanceKlass 大小在 64 位系统的大小为 0x1b8,因此 vtable 的起始地址:
// 十六进制加法
0x00000007c0060418 + 0x1b8 = 0x00000007c00605D0
然后选择 Windows -> Console
弹出右下角窗口,输入 mem 0x00000007c00605D0 7 后回车
// mem 命令接收的两个参数都是必选参数,前面的是起始地址,后面的是长度
再打开对象列表,Tools -> Class Browser,搜索 Object
显示如下界面,可以看到 vtable 的前 5 个函数地址指向 java.lang.Object 中的 5 个方法
void finalize();
boolean equals(java.lang.Object);
java.lang.String toString();
int hashCode();
java.lang.Object clone();
继续看剩下的两个函数
打开对象列表,Tools -> Class Browser,搜索 A
显示如下界面,可以看到 B 类的一个函数地址指向 A 类的方法 printMe(),因为 B 继承 A 的 printMe() 方法
打开对象列表,Tools -> Class Browser,搜索 B
显示如下界面,可以看到 vtable 最后一个函数地址指向 B 类的函数 sayHello()
小结一下上面的图
写了这么多,其实想说 vtable 是 Java 实现多态的基石,由上图可以看出,如果一个方法被继承后重写,会把 vtable 中指向父类的方法指针指向子类自己的实现
小结:
- Java 子类会继承父类的 vtable
- Java 所有的类都会继承 java.lang.Object 类,Object 类中有 5 个虚方法可以被继承和重写,当一个类不包含任何方法时,其 vtable 长度为 5,即 Object 类的 5 个虚方法
- final 和 static 修饰的方法不会被放到 vtable 表里
- 当子类重写了父类方法,子类 vtable 原本指向父类的方法指针会被替换为子类的方法指针
- 子类的 vtable 保持了父类的 vtable 的顺序
做个实验
下面做一些实验,让 B 实现接口 MyInterface,同时在 B 中新增一个 static 方法和一个 final 方法
public interface MyInterface {
public void testMe();
}
public class B extends A implements MyInterface {
@Override
public void sayHello() {
System.out.println("hello, i am child B");
}
@Override
public void testMe() {
System.out.println("test me");
}
public static void foo() {}
public final void testFinal() {}
}
重新运行 MyTest,然后打开任务管理器,找到 javaw.exe 进程,进程号 PID 为 16156
采用同样的方法,这时 B 类的 vtable 大小为 8
vatble 内存起始地址 = 0x00000007c0060828 + 0x1b8 = 0x00000007c00609E0
前 5 个不用看了,肯定是 Object 的,看看剩下的三个
打开对象列表,Tools -> Class Browser,搜索 A
可以看到 vtable 一个函数地址指向 A 类的函数 printMe()
打开对象列表,Tools -> Class Browser,搜索 B
可以看到 vtable 两个函数地址指向 A 类的函数 sayHello()、testMe()
B 类的 vtable 大小为 8,都看完了,可以看出,B 类的 static 和 final 的方法没有出现在 vtable 中
三、结论:方法继承的细节以及多态的原理
通过 HSDB 工具可以窥探 JVM 内存,通过 vtable 的例子深入理解方法继承的细节以及多态的原理,再贴一遍上面的小结
- Java 子类会继承父类的 vtable
- Java 所有的类都会继承 java.lang.Object 类,Object 类中有 5 个虚方法可以被继承和重写,当一个类不包含任何方法时,其 vtable 长度为 5,即 Object 类的 5 个虚方法
- final 和 static 修饰的方法不会被放到 vtable 表里
- 当子类重写了父类方法,子类 vtable 原本指向父类的方法指针会被替换为子类的方法指针
- 子类的 vtable 保持了父类的 vtable 的顺序
四、趁热打铁,你有这个时间是不是能做两道题?
class Animal {
String name = "我是动物";
static int age = 20;
public void eat() {
System.out.println("动物可以吃饭");
}
public static void sleep() {
System.out.println("动物可以睡觉");
}
public void run(){
System.out.println("动物可以奔跑");
}
}
class Dog extends Animal {
String name = "小狗";
static int age = 60;
public void eat() {
System.out.println("小狗可以吃饭");
}
public static void sleep() {
System.out.println("小狗可以睡觉");
}
public void watchdog() {
System.out.println("小狗可以看门");
}
}
public static void main(String[] args) {
Animal am = new Dog();
am.eat();
am.sleep();
am.run();
System.out.println(am.name);
System.out.println(am.age);
}
打印结果
小狗可以吃饭
动物可以睡觉
动物可以奔跑
我是动物
20
关于方法调用:
- 小狗可以吃饭:打印这句是因为 Dog 继承 Animal 并重写了 eat() 方法,根据上面我们通过 vtable 得出的结论,所以确实应该打印 Dog 的 eat()
- 动物可以睡觉:打印这句是因为虽然 Dog 继承 Animal 并重写了 sleep() 方法,但根据上面我们通过 vtable 得出的结论,static 方法指针指向的还是父类的方法,所以确实应该打印 Animal 的 sleep()
- 动物可以奔跑:打印这句是因为 Dog 继承 Animal 的 run() 方法,Dog 本身没有 run() 方法,所以确实应该打印 Animal 的 run()
关于成员属性(子类打印的都是父类的) - 我是动物:打印 Animal 的
- 20:打印 Animal 的
题外题(不是多态,类加载顺序)
public class SuperClass {
SuperClass() {
System.out.println("SuperClass");
}
}
public class SubClass extends SuperClass {
static {
System.out.println(1);
}
public SubClass() {
System.out.println(2);
}
}
public class App {
private static App d = new App();
private SubClass t = new SubClass();
static {
System.out.println(3);
}
public App() {
System.out.println(4);
}
public static void main(String[] args) {
System.out.println("Hello");
}
}
打印结果
1
SuperClass
2
4
3
Hello
我是这么想的
main() 方法告诉虚拟机加载它所在的类(即 App),也就是说你啥也不干,就会打印静态块的 3
既然加载类 App,那么按顺序来,先加载第一行的成员属性 private static App d = new App();,结果这个属性很特殊,它居然 new 了类 App
// 如果你这里不是 static,到这里就结束了,最终只会打印静态块的 3,然后打印 main() 方法中的 Hello
// 如果你这里是 static,但是不是 new 的 App,而是干别的,或者声明了一个 static 成员变量,同样最终只会打印静态块的 3,然后打印 main() 方法中的 Hello
所以要明白一个事情,你在类 App 加载的时候,你要想让成员属性都得到初始化,那么你就得去 new 它的对象,而你想要 new 对象的话,除了在 main() 方法中去手动的 new,你还可以在类加载的时候去 new,那么加个 static 就可以满足
那么上面两个成员属性的打印顺序是什么,我们说你在类 App 加载的时候,你去 new 了它的对象,确实会导致 new 了一个 App 的实例,但要意识到第二行的成员属性就是 App 的成员,你既然 new 了 App 的实例,那么就会导致第二行属性的加载,所以有了 new 这个操作,才会有第二行属性的加载过程,不然没有
所以上面两个成员属性的打印顺序应该是,main() 方法告诉虚拟机加载 main() 方法所在的类(即 App),这时会打印静态块的 3
然后加载类 App 的过程,按顺序来,先是第一行的成员属性,因为是 static 的,会随着类加载而加载,所以就开始加载属性 d
// 如果你不是 static,到这里就结束了,最终只会打印静态块的 3,然后打印 main() 方法中的 Hello
结果,你的属性 d 后面是个 new 操作,那么就开始 new 对象的过程,既然是 new 对象,那么类中的属性是不是都会参与 new 对象的过程,所以 new 对象的操作又导致了第二行属性的加载过程,也就是类 SubClass 的加载过程
所以,本来类 App 简简单单打印个静态块的 3 就完事了,现在有了 new 对象的操作,那么 3 就不能先打印了,会先执行 new 对象的过程,因为第二行的成员属性参与 new 对象的过程,并且按顺序来说第二行的成员属性是第一个参与的 new 对象的成员属性,那么自然打印顺序就是:
第二行的成员属性打印 -> new 对象过程的其它打印 -> 类 App 加载过程的打印 -> main() 方法的打印
下面是类 App 其它成员属性的加载过程,也就是类 App 的成员属性的第二行
private SubClass t = new SubClass(); // 这一行意味着,随着类 App 加载的时候,会开始 t 所属类 SubClass 的加载过程
目的是 new 一个 SubClass 类的实例,那么会先加载 SubClass 类,又因为 SubClass 类继承 SuperClass,那么又会去先加载类 SuperClass 类
所以这行代码的加载顺序是:SuperClass 类加载、SubClass 类加载、创建 SuperClass 实例、创建 SubClass 实例
所以第二行成员属性打印:SuperClass 类加载(没有打印)、SubClass 类加载(1)、创建 SuperClass 实例(构造SuperClass)、创建 SubClass 实例(2)
new 对象过程的其它打印:构造块 4
类 App 加载过程的打印:静态块 3
main() 方法的打印:Hello
综上所述,总的打印顺序是:1、构造SuperClass、2、4、3、Hello