从JVM层面对Java多态机制深入探寻

4 篇文章 0 订阅

前言

我们都知道多态是Java中最重要的特性之一,而是什么让我对于深入探寻多态的机制起了好奇之心?

我们先来看下面的一段代码:

class People {
    protected void test() {
        System.out.println("This is People.test().");
    }
}
public class Girl extends People {

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
    }

    public static void main(String[] args) {
        People people = new Girl();
        people.test();
    }
} /* Output
This is Girl.test().
*/

这是多态的基本应用,父类引用指向子类对象,调用的是子类覆写的方法。

可是如果我们加上构造方法呢?
当执行子类对象的构造方法时,将会先执行其父类的构造方法,那么如果我在父类的构造方法中调用子类覆写的方法,效果会是怎样?

class People {
    People()  {
        System.out.println("This is People init().");
        test();
    }
    protected void test() {
        System.out.println("This is People.test().");
    }
}
public class Girl extends People {

    Girl() {
        System.out.println("This is Girl init().");
    }

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
    }

    public static void main(String[] args) {
        People people = new Girl();
        people.test();
    }
} /* Output
This is People init().
This is Girl.test().
This is Girl init().
This is Girl.test().
*/

我们发现,结果似乎没有什么变化。
当我们在基类的构造方法中调用被子类覆写的方法时,在main中通过向上转型创建子类对象后,其父类中构造方法中执行的仍旧是子类对象的方法

基于此,出现了一个很大的问题:如果父类中存在着一个属性,并且这个属性在被子类覆写的方法中得到应用,那么,这个属性的值究竟是子类中的值,还是父类中的值?

同样,通过程序来验证一下:

class People {
    protected Integer testNum = 10;

    People()  {
        System.out.println("This is People init().");
        test();
    }
    protected void test() {
        System.out.println("This is People.test().");
        System.out.println("People.testNum = " + testNum);
    }
}
public class Girl extends People {
    protected Integer testNum = 20;

    Girl() {
        System.out.println("This is Girl init().");
    }

    @Override
    protected void test() {
        System.out.println("This is Girl.test().");
        System.out.println("Girl.testNum = " + testNum);
    }

    public static void main(String[] args) {
        People people = new Girl();
    }
} /* Output
This is People init().
This is Girl.test().
Girl.testNum = null
This is Girl init().
*/

咦,程序输出结果中,testNum的值既不是People中的10,也不是Girl中的20,而是…null?
这究竟是为什么?Java中的多态机制到底是怎样的?
这就是今天我要探寻的主要目的。

注:读本篇文章需要一定的JVM基础,对内存区域尤其是方法区(元空间)、堆和栈有一定的了解。

绑定

了解多态,有一个名词的意思必须了解,那就是绑定

绑定,简单来说就是程序在运行时调用何种方法的操作,也就是将方法的调用与方法所在的类“绑”起来。
在Java中,绑定主要分为静态绑定和动态绑定。

而我们所说的多态,就是通过动态绑定来实现的。

静态绑定

静态绑定是指,在程序运行前就已经被绑定了,也就是编译的时候就知道方法是哪个类的方法
而在Java中,只有privatestaticfinal修饰的方法以及构造方法是静态绑定,这些方法都是不能被重写的。

  • private 关键字标明的方法不能被继承,自然不存在覆写,所以其一开始就与定义该方法的类绑定在一起。
  • static关键字标明的方法是静态方法,其同样不可被继承,且不依赖对象而存在,调用的时候就是定义它的类的方法。
  • final关键字标明的方法是无法被覆写的,同样与定义它的类绑定在一起。

所以,静态绑定其实就是那些不可被覆写的方法采用的绑定机制

动态绑定

动态绑定是指,在程序运行过程中执行的绑定,在程序开始前是不知道方法属于哪个类的。
也就是说,动态绑定是在运行时根据具体对象的类型进行绑定。

方法表

在说动态绑定的过程之前,我们先要明白一个概念,那就是方法表

方法表是动态绑定的核心,其存放在方法区(JDK 1.7 及以前称为方法区,有些人也将其称作永久代,JDK 1.8 称作元空间,这里以JDK 1.7 的说法为准)的类型信息中。
也可以这么说,方法区的类型信息中存有一个指向记录该类方法的方法表的指针,而方法表中的每一项都是对应方法的指针

方法表在类加载的连接阶段进行初始化,以数组的形式记录了当前类及其所有超类的可见方法字节码在内存中的直接地址。
如果某个方法在子类中没有被重写,那么子类的方法表中该方法的地址和父类保持一致。

方法表的实现

父类的方法会比子类的方法先得到解析,相比子类的方法位于表的前列。
而如果子类重写了父类中某个方法的代码,则该方法在方法表中的指向更换到子类的实现代码上,而不会在方法表中出现新的项。

那么方法调用的具体过程是怎样的呢?
JVM首先根据class文件找到调用方法的符号引用,然后在静态类型的方法表中找到偏移量,根据this指针确定对象的实际类型,使用实际类型的方法表。
如果在实际类型的方法表中找到该方法,则直接调用,否则按照继承关系从下往上搜索。

这么说可能有点抽象,我们根据实际的例子来一步步分析其过程:

class Father {

    protected void test() {
        System.out.println("This is Father." );
    }

}
public class Son extends Father {

    @Override
    protected void test() {
        System.out.println("This is Son.");
    }

    public static void main(String[] args) {

        Father s = new Son();
        s.test();

    }
}/* Output
This is Son.
*/

程序通过编译后,我们可以用 javap -verbose Son.class 指令得到这个类的字节码指令(因篇幅缘故只截取部分):

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #5                  // class Son
         3: dup
         4: invokespecial #6                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #7                  // Method Father.test:()V
        12: return

这里简单解释一下关于静态绑定动态绑定的指令:
在Java虚拟机中提供了5条方法调用的字节码指令,分别是:

  • invokestatic:调用静态方法;
  • invokespecial:调用实例构造器方法、私有方法和父类方法;
  • invokevirtual:调用所有的虚方法(简单来说就是涉及到多态的方法);
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑都是固化在Java虚拟机中的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

显而易见,前两个字节码指令是属于静态绑定的指令,而其余则是动态绑定的指令。

在类加载过程中,解析阶段就是将符号引用转换为直接引用的过程。
回归正题,上述程序的解析过程又是什么样的?
我们先来看一张图,上面说过,父类的方法比子类的方法先得到解析,所以在方法表中,父类的方法位于前面。
如果父类的方法被子类重写,则在子类方法表中其指向为子类重写的方法代码。
方法状态

  1. 通过上面的字节码指令:
    9: invokevirtual #7 // Method Father.test:()V
    我们找到常量池中的第7个常量表索引项(#7指的就是Father类常量池中的第7个常量表的索引项):
    #7 = Methodref #8.#25 // Father.test:()V
    (因篇幅缘故不列出全部的常量池信息)
    发现这里记录的是方法test()的符号引用

  2. JVM会根据这个符号引用找到方法test()所在的全限定名:Father
    (我这里没有设置package,若有的话则只需将.替换为/即可)
    (例如:com.jvm.Son ,全限定名为 com/jvm/Son

  3. Father类型的方法表中查找方法test,如果找到,则将方法test在方法表中的索引项就是图中的9,也被称作偏移量)记录到Son类的常量池的第7个常量表中。
    (这里为的是进行安全检查,JVM会首先将Father的方法表加载,之后从Father方法表中查找对应方法,如果方法不存在,那么即使Son类型中方法表有,编译也无法通过)

这时常量池解析结束,可是我们能确定调用test方法执行的是哪一块字节码吗?显然是不能的,引用虽然是父类类型,但它的指向程序还是不清楚的,那么如何确定呢?

  1. 我们看invokevirtual上一条字节码指令:
    8: aload_1
    简单解释一下,aload的意思是从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶。
    这里将开始创建在堆中的Son对象的引用(也就是引用类型LFather)压入操作数栈invokevirtual会根据这个Son对象的引用找到中的Son对象,继而找到Son对象所属类型的方法表。

t i p s : 在 编 译 时 加 入 ‘ − g ‘ 生 成 所 有 调 试 信 息 , 在 反 编 译 的 时 候 利 用 ‘ − l ‘ 就 可 以 查 看 本 地 变 量 表 \color{#FF0000}{tips: 在编译时加入`-g`生成所有调试信息,在反编译的时候利用`-l`就可以查看本地变量表} tips:gl
例如编译时:javac -g Son.java ,反编译命令:javap -c -l Son.java,就可以观察到LocalVariableTable

 LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0      13     0  args   [Ljava/lang/String;
          8       5     1     s   LFather;

可以清楚的看到slot=1的位置存放的是引用类型LFather

当我们在向上转型的时候:

Father s = new Son();

内存中是什么样的呢,请看下面这张图:
堆栈内存图
这样就能很清楚的解释父类引用与子类对象的关系,就如上文所说,通过Father引用找到堆中的Son对象实例。
Son对象实例中持有着指向方法区的类型信息的引用(在数据区里),方法区的类型信息中存有一个指向记录该类方法的方法表的指针,这样通过实例访问方法区,继而找到Son的方法表。
(堆中实例并不只有局部变量的定义,这里只是列出一个框架作为参考)

  1. 通过#7常量表中的方法表的索引项(就是第三步里说的9)定位到Son类型方法表中的方法test,通过直接地址找到该方法字节码所在的内存空间。
    (因为子类和父类相同方法的索引相同,这个下面会提到,所以通过父类的索引就能找到子类中重写方法的位置)

这里有几个要注意的点:

  1. JVM是根据父类Father来解析常量池的,用Father方法表中的索引项来代替常量池中的符号引用。
  2. 方法表在类加载的链接阶段进行初始化,存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那么子类的方法表中该方法的入口地址与父类保持一致。
  3. 相同的方法(相同的方法签名:方法名和参数列表)在所有类的方法表中的索引相同,如果test位于Father类方法表的第9项,那么其在Son类的方法表中也位于第9项。
    (这里是为了当类型变换时,仅需要变更查找的方法表,就可以按索引转换出需要的入口地址)
  4. 父类的方法表永远比子类的方法表先加载,当子类的方法表生成时,方法表首先会继承一份自父类(类似于复制),父类有的方法子类都会获得且索引项偏移量)完全相同。
    此时根据子类的方法调整方法表,如果子类重写了父类的方法,那么指针就会修改为指向那条重写后的方法。
    如果子类新增了一个方法,那么就会在方法表某处添加一个指针,指向新增的方法。

问题解决

对于一开始讲述的问题,我们一步一步来分析,首先看main方法的字节码部分:

Code:
      stack=2, locals=2, args_size=1
         0: new           #14                 // class Girl
         3: dup
         4: invokespecial #15                 // Method "<init>":()V
         ...
--------------------------------------------------------------------------------------------
 #15 = Methodref          #14.#35        // Girl."<init>":()V

可以看出,程序首先new了一个Girl实例,对其进行默认初始化,并且将指向该实例的一个引用压入操作数栈顶。
dup的意思是把栈顶复制一份入栈,为什么要这么做?
因为程序要对Girl进行初始化,执行invokespecial指令,而invokespecial消耗掉操作数栈顶的引用作为传给构造器的this参数。
所以如果我们希望在invokespecial调用后在操作数栈顶还维持有一个指向新建对象的引用,就得在invokespecial之前先复制一份引用以供invokespecial来消耗。

接下来我们来看Girl初始化部分的字节码指令:

 Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method People."<init>":()V
         ...

发现,在将this入栈后,进行了父类的初始化,一步步向上追踪,看People类的初始化过程:

 Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        10
         7: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        10: putfield      #3                  // Field testNum:Ljava/lang/Integer;
        13: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #5                  // String This is People init().
        18: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: aload_0
        22: invokevirtual #7                  // Method test:()V
        25: return

主要看调用顺序,程序是先将10入栈5: bipush 10),并进行一系列的初始化操作(此时在父类中testNum的值为10),之后才执行的构造方法,根据上文我们知道,22: invokevirtual #7执行的是子类的test方法(因为new的是子类实例),所以接下来我们看子类中test方法是如何执行的:

 Code:
      stack=3, locals=1, args_size=1
         0: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #7                  // String This is Girl.test().
         5: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        11: new           #8                  // class java/lang/StringBuilder
        14: dup
        15: invokespecial #9                  // Method java/lang/StringBuilder."<init>":()V
        18: ldc           #10                 // String Girl.testNum =
        20: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        23: aload_0
        24: getfield      #3                  // Field testNum:Ljava/lang/Integer;
        27: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
        30: invokevirtual #13                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        33: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        36: return

主要看这一个指令:24: getfield #3 // Field testNum:Ljava/lang/Integer;
getfield的意思是:获取指定类的实例变量,将结果压入栈顶
也就是说,这一步将testNum压入栈顶,可是此时Girl 这个类的testNum还没有进行初始化!
因为现在仍处于父类初始化的阶段,所以testNum的值打印出来就是默认的null

执行完test方法后,父类初始化完毕,继续子类的初始化过程:

		...
         4: aload_0
         5: bipush        20
         7: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        10: putfield      #3                  // Field testNum:Ljava/lang/Integer;
        13: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #5                  // String This is Girl init().
        18: invokevirtual #6                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: return

此时才为testNum进行初始化(10: putfield #3 ),之后执行Girl的构造方法。

问题总结

通过上面的例子,我们知道了当一个子类被new出,其实际的初始化过程顺序为:

  1. 父类static成员初始化
  2. 子类static成员初始化
  3. 父类普通成员初始化
  4. 父类构造方法
  5. 子类普通成员初始化
  6. 子类构造方

(例子中没有描述static成员初始化过程,但static成员要比普通成员先初始化)

当我们在父类的构造方法中调用了被子类重写的方法,并new出一个子类实例时,你就要小心了!
因为父类的构造方法比子类普通成员初始化先执行,所以如果在子类重写的方法中使用了子类中的属性,那么这个属性的值往往会与你预期的有所偏差
所以在构造方法中,唯一能安全调用的就是父类本身私有的方法(或者声明为final的方法),如果调用子类重写的方法,一定要小心小心再小心。

拓展(静态多分派)

JVM中除了静态分配和动态分配外,还分单分派和多分派。

根据一个宗量的类型进行方法的选择称为单分派。
根据多于一个宗量的类型对方法的选择称为多分派。

宗量又是什么?
方法的接受者与方法的参数统称为方法的宗量。

在Java中实行的是静态多分派动态单分派,上面介绍的就是动态单分派,主要应用为方法的重写。

而静态多分派的典型应用是方法的重载。
下面我们通过一个例子来简单介绍一下静态多分派的机制:

class Human{}
class Man extends Human{}
class Woman extends Human{}

public class StaticDispatch{

    public void sayHello(Human guy){
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy){
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy){
        System.out.println("hello,lady!");
    }

    public static void main(String[]args){
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}/* Output
hello,guy!
hello,guy!
*/

我们把上面的 Human 称为变量的静态类型,把 Man 称为变量的实际类型
可以很清楚的看到结果是由静态类型来决定的。

在方法的调用者都为sr的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。
编译器不是虚拟机,因为如果是根据静态类型做出的判断,那么在编译期就确定了)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
因为静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本。

拓展2(关于属性调用)

对于父类引用指向子类对象,对于方法调用的是子类中重写的方法,那变量名相同的属性呢?

我们先来看一段代码:

class Father {
    protected Integer x = 10;
}

public class Son extends Father{

    protected Integer x = 20;

    public static void main(String[]args){
        Father s = new Son();

        System.out.println("This is x = " + s.x);
    }
}/* Output
This is x = 10
*/

很神奇对不对?对属性的访问,竟然是基于变量的静态类型的,这又是为什么呢?
很简单,属性的绑定是在编译期间完成的,编译期间是父类的类型,所以在我们调用属性时调用的也是父类的属性值。

而如果改用方法去访问就不会出现这个问题:

class Father {
    private Integer x = 10;

    public Integer getX() {
        return this.x;
    }
}

public class Son extends Father{

    private Integer x = 20;

    public Integer getX() {
        return this.x;
    }


    public static void main(String[]args){
        Father s = new Son();

        System.out.println("This is x = " + s.getX());
    }
}/* Output
This is x = 20
*/

这里根据多态调用了子类重写的方法,这样做也是推荐(为了安全性与私有性)的一种做法。

参考资料

  1. 方法的虚分派(virtual dispatch)和方法表(method table)
  2. 细说JVM(虚拟机实现多态)
  3. Java动态绑定机制的内幕
  4. Java多态的实现原理
  5. Java动态绑定和静态绑定-多态
  6. 向上转型底层原理分析
  7. Java重写方法与初始化的隐患
  8. 多态性实现机制——静态分派与动态分派
  9. 深入理解Jvm–Java静态分配和动态分配完全解析
  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值