夯實Java基礎系列23:一文讀懂繼承、封裝、多態的底層實現原理

本系列文章將整理到我在GitHub上的《Java面試指南》倉庫,更多精彩內容請到我的倉庫裏查看

https://github.com/h2pl/Java-Tutorial

喜歡的話麻煩點下Star哈

文章首發於我的個人博客:

www.how2playlife.com

<!-- more -->

從JVM結構開始談多態

Java 對於方法調用動態綁定的實現主要依賴於方法表,但通過類引用調用和接口引用調用的實現則有所不同。總體而言,當某個方法被調用時,JVM 首先要查找相應的常量池,得到方法的符號引用,並查找調用類的方法表以確定該方法的直接引用,最後才真正調用該方法。以下分別對該過程中涉及到的相關部分做詳細介紹。

JVM 的結構

典型的 Java 虛擬機的運行時結構如下圖所示

圖 1.JVM 運行時結構

圖 1.JVM 運行時結構

此結構中,我們只探討和本文密切相關的方法區 (method area)。當程序運行需要某個類的定義時,載入子系統 (class loader subsystem) 裝入所需的 class 文件,並在內部建立該類的類型信息,這個類型信息就存貯在方法區。類型信息一般包括該類的方法代碼、類變量、成員變量的定義等等。可以說,類型信息就是類的 Java 文件在運行時的內部結構,包含了改類的所有在 Java 文件中定義的信息。

注意到,該類型信息和 class 對象是不同的。class 對象是 JVM 在載入某個類後於堆 (heap) 中創建的代表該類的對象,可以通過該 class 對象訪問到該類型信息。比如最典型的應用,在 Java 反射中應用 class 對象訪問到該類支持的所有方法,定義的成員變量等等。可以想象,JVM 在類型信息和 class 對象中維護着它們彼此的引用以便互相訪問。兩者的關係可以類比於進程對象與真正的進程之間的關係。

Java 的方法調用方式

Java 的方法調用有兩類,動態方法調用與靜態方法調用。靜態方法調用是指對於類的靜態方法的調用方式,是靜態綁定的;而動態方法調用需要有方法調用所作用的對象,是動態綁定的。類調用 (invokestatic) 是在編譯時刻就已經確定好具體調用方法的情況,而實例調用 (invokevirtual) 則是在調用的時候才確定具體的調用方法,這就是動態綁定,也是多態要解決的核心問題。

JVM 的方法調用指令有四個,分別是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前兩個是靜態綁定,後兩個是動態綁定的。本文也可以說是對於 JVM 後兩種調用實現的考察。

常量池(constant pool)

常量池中保存的是一個 Java 類引用的一些常量信息,包含一些字符串常量及對於類的符號引用信息等。Java 代碼編譯生成的類文件中的常量池是靜態常量池,當類被載入到虛擬機內部的時候,在內存中產生類的常量池叫運行時常量池。

常量池在邏輯上可以分成多個表,每個表包含一類的常量信息,本文只探討對於 Java 調用相關的常量池表。

CONSTANT_Utf8_info

字符串常量表,該表包含該類所使用的所有字符串常量,比如代碼中的字符串引用、引用的類名、方法的名字、其他引用的類與方法的字符串描述等等。其餘常量池表中所涉及到的任何常量字符串都被索引至該表。

CONSTANT_Class_info

類信息表,包含任何被引用的類或接口的符號引用,每一個條目主要包含一個索引,指向 CONSTANT_Utf8_info 表,表示該類或接口的全限定名。

CONSTANT_NameAndType_info

名字類型表,包含引用的任意方法或字段的名稱和描述符信息在字符串常量表中的索引。

CONSTANT_InterfaceMethodref_info

接口方法引用表,包含引用的任何接口方法的描述信息,主要包括類信息索引和名字類型索引。

CONSTANT_Methodref_info

類方法引用表,包含引用的任何類型方法的描述信息,主要包括類信息索引和名字類型索引。

圖 2. 常量池各表的關係

圖 2\. 常量池各表的關係

可以看到,給定任意一個方法的索引,在常量池中找到對應的條目後,可以得到該方法的類索引(class_index)和名字類型索引 (name_and_type_index), 進而得到該方法所屬的類型信息和名稱及描述符信息(參數,返回值等)。注意到所有的常量字符串都是存儲在 CONSTANT_Utf8_info 中供其他表索引的。

方法表與方法調用

方法表是動態調用的核心,也是 Java 實現動態調用的主要方式。它被存儲於方法區中的類型信息,包含有該類型所定義的所有方法及指向這些方法代碼的指針,注意這些具體的方法代碼可能是被覆寫的方法,也可能是繼承自基類的方法。

如有類定義 Person, Girl, Boy,

清單 1

<pre name="code"> class Person {
public String toString(){
return "I'm a person.";
}
public void eat(){}
public void speak(){}

}

class Boy extends Person{
public String toString(){
return "I'm a boy";
}
public void speak(){}
public void fight(){}
}

class Girl extends Person{
public String toString(){
return "I'm a girl";
}
public void speak(){}
public void sing(){}
}</pre>

當這三個類被載入到 Java 虛擬機之後,方法區中就包含了各自的類的信息。Girl 和 Boy 在方法區中的方法表可表示如下:

圖 3.Boy 和 Girl 的方法表

圖 3.Boy 和 Girl 的方法表

可以看到,Girl 和 Boy 的方法表包含繼承自 Object 的方法,繼承自直接父類 Person 的方法及各自新定義的方法。注意方法表條目指向的具體的方法地址,如 Girl 的繼承自 Object 的方法中,只有 toString() 指向自己的實現(Girl 的方法代碼),其餘皆指向 Object 的方法代碼;其繼承自於 Person 的方法 eat() 和 speak() 分別指向 Person 的方法實現和本身的實現。

Person 或 Object 的任意一個方法,在它們的方法表和其子類 Girl 和 Boy 的方法表中的位置 (index) 是一樣的。這樣 JVM 在調用實例方法其實只需要指定調用方法表中的第幾個方法即可。

如調用如下:

清單 2

<pre name="code"> class Party{

void happyHour(){
Person girl = new Girl();
girl.speak();

}
}</pre>

當編譯 Party 類的時候,生成 girl.speak()的方法調用假設爲:

Invokevirtual #12

設該調用代碼對應着 girl.speak(); #12 是 Party 類的常量池的索引。JVM 執行該調用指令的過程如下所示:

圖 4. 解析調用過程

圖 4\. 解析調用過程

JVM 首先查看 Party 的常量池索引爲 12 的條目(應爲 CONSTANT_Methodref_info 類型,可視爲方法調用的符號引用),進一步查看常量池(CONSTANT_Class_info,CONSTANT_NameAndType_info ,CONSTANT_Utf8_info)可得出要調用的方法是 Person 的 speak 方法(注意引用 girl 是其基類 Person 類型),查看 Person 的方法表,得出 speak 方法在該方法表中的偏移量 15(offset),這就是該方法調用的直接引用。

當解析出方法調用的直接引用後(方法表偏移量 15),JVM 執行真正的方法調用:根據實例方法調用的參數 this 得到具體的對象(即 girl 所指向的位於堆中的對象),據此得到該對象對應的方法表 (Girl 的方法表 ),進而調用方法表中的某個偏移量所指向的方法(Girl 的 speak() 方法的實現)。

接口調用

因爲 Java 類是可以同時實現多個接口的,而當用接口引用調用某個方法的時候,情況就有所不同了。Java 允許一個類實現多個接口,從某種意義上來說相當於多繼承,這樣同樣的方法在基類和派生類的方法表的位置就可能不一樣了。

清單 3

<pre name="code">interface IDance{
void dance();
}

class Person {
public String toString(){
return "I'm a person.";
}
public void eat(){}
public void speak(){}

}

class Dancer extends Person
implements IDance {
public String toString(){
return "I'm a dancer.";
}
public void dance(){}
}

class Snake implements IDance{
public String toString(){
return "A snake.";
}
public void dance(){
//snake dance
}
}</pre>

圖 5.Dancer 的方法表(查看大圖

圖 5.Dancer 的方法表

可以看到,由於接口的介入,繼承自於接口 IDance 的方法 dance()在類 Dancer 和 Snake 的方法表中的位置已經不一樣了,顯然我們無法通過給出方法表的偏移量來正確調用 Dancer 和 Snake 的這個方法。這也是 Java 中調用接口方法有其專有的調用指令(invokeinterface)的原因。

Java 對於接口方法的調用是採用搜索方法表的方式,對如下的方法調用

invokeinterface #13

JVM 首先查看常量池,確定方法調用的符號引用(名稱、返回值等等),然後利用 this 指向的實例得到該實例的方法表,進而搜索方法表來找到合適的方法地址。

因爲每次接口調用都要搜索方法表,所以從效率上來說,接口方法的調用總是慢於類方法的調用的。

執行結果如下:
這裏寫圖片描述
可以看到System.out.println(dancer); 調用的是Person的toString方法。

繼承的實現原理

Java 的繼承機制是一種複用類的技術,從原理上來說,是更好的使用了組合技術,因此要理解繼承,首先需要了解類的組合技術是如何實現類的複用的。

使用組合技術複用類
假設現在的需求是要創建一個具有基本類型,String 類型以及一個其他非基本類型的對象。該如何處理呢?

對於基本類型的變量,在新類中成員變量處直接定義即可,但對於非基本類型變量,不僅需要在類中聲明其引用,並且還需要手動初始化這個對象。

這裏需要注意的是,編譯器並不會默認將所有的引用都創建對象,因爲這樣的話在很多情況下會增加不必要的負擔,因此,在合適的時機初始化合適的對象,可以通過以下幾個位置做初始化操作:

在定義對象的地方,先於構造方法執行。
在構造方法中。
在正要使用之前,這個被稱爲惰性初始化。
使用實例初始化。

class Soap {
    private String s;
    Soap() {
        System.out.println("Soap()");
        s = "Constructed";
    }
    public String tiString(){
        return s;
    }
}

public class Bath {
    // s1 初始化先於構造函數
    private String s1 = "Happy", s2 = "Happy", s3, s4;
    private Soap soap;
    private int i;
    private float f;

    public Both() {
        System.out.println("inSide Both");
        s3 = "Joy";
        f = 3.14f;
        soap = new Soap();
    }

    {
        i = 88;
    }

    public String toString() {
        if(s4 == null){
            s4 = "Joy"
        }
        return "s1 = " + s1 +"\n" +
               "s2 = " + s2 +"\n" +
               "s3 = " + s3 +"\n" +
               "s4 = " + s4 +"\n" +
               "i = " + i +"\n" +
               "f = " + f +"\n" +
               "soap = " + soap;
    }
}

繼承
Java 中的繼承由 extend 關鍵字實現,組合的語法比較平實,而繼承是一種特殊的語法。當一個類繼承自另一個類時,那麼這個類就可以擁有另一個類的域和方法。

class Cleanser{
    private String s = "Cleanser";

    public void append(String a){
        s += a;
    }
    public void apply(){
        append("apply");
    }
    public void scrub(){
        append("scrub");
    }
    public String toString(){
        return s;
    }
    public static void main(String args){
        Cleanser c = new Cleanser();

        c.apply();
        System.out.println(c);
    }
}

public class Deter extends Cleanser{
    public void apply(){
        append("Deter.apply");
        super.scrub();
    }
    public void foam(){
        append("foam");
    }
    public static void main(String args){
        Deter d = new Deter();

        d.apply();
        d.scrub();
        d.foam();
        System.out.println(d);
        Cleanser.main(args);
    }
}

上面的代碼中,展示了繼承語法中的一些特性:

子類可以直接使用父類中公共的方法和成員變量(通常爲了保護數據域,成員變量均爲私有)
子類中可以覆蓋父類中的方法,也就是子類重寫了父類的方法,此時若還需要調用被覆蓋的父類的方法,則需要用到 super 來指定是調用父類中的方法。
子類中可以自定義父類中沒有的方法。
可以發現上面兩個類中均有 main 方法,命令行中調用的哪個類就執行哪個類的 main 方法,例如:java Deter。
繼承語法的原理
接下來我們將通過創建子類對象來分析繼承語法在我們看不到的地方做了什麼樣的操作。

可以先思考一下,如何理解使用子類創建的對象呢,首先這個對象中包含子類的所有信息,但是也包含父類的所有公共的信息。

下面來看一段代碼,觀察一下子類在創建對象初始化的時候,會不會用到父類相關的方法。

class Art{
    Art() {
        System.out.println("Art Construct");
    }
}

class Drawing extends Art {
    Drawing() {
        System.out.println("Drawing Construct");
    }
}

public class Cartoon extends Drawing {
    public Cartoon() {
        System.out.println("Cartoon construct");
    }
    public void static main(String args) {
        Cartoon c = new Cartoon();
    }
}
/*output:
Art Construct
Drawing Construct
Cartoon construct
*/

通過觀察代碼可以發現,在實例化Cartoon時,事實上是從最頂層的父類開始向下逐個實例化,也就是最終實例化了三個對象。編譯器會默認在子類的構造方法中增加調用父類默認構造方法的代碼。

因此,繼承可以理解爲編譯器幫我們完成了類的特殊組合技術,即在子類中存在一個父類的對象,使得我們可以用子類對象調用父類的方法。而在開發者看來只不過是使用了一個關鍵字。

注意:雖然繼承很接近組合技術,但是繼承擁有其他更多的區別於組合的特性,例如父類的對象我們是不可見的,對於父類中的方法也做了相應的權限校驗等。

那麼,如果類中的構造方法是帶參的,該如何操作呢?(使用super關鍵字顯示調用)

見代碼:

class Game {
    Game(int i){
        System.out.println("Game Construct");
    }
}

class BoardGame extends Game {
    BoardGame(int j){
        super(j);
        System.out.println("BoardGame Construct");
    }
}
public class Chess extends BoardGame{
    Chess(){
        super(99);
        System.out.println("Chess construct");
    }
    public static void main(String args) {
        Chess c = new Chess();
    }
}
/*output:
Game Construct
BoardGame Construct
Chess construc
*/

重載和重寫的實現原理

    剛開始學習Java的時候,就瞭解了Java這個比較有意思的特性:重寫 和 重載。開始的有時候從名字上還總是容易弄混。我相信熟悉Java這門語言的同學都應該瞭解這兩個特性,可能只是從語言層面上了解這種寫法,但是jvm是如何實現他們的呢 ?

重載官方給出的介紹:

一.  overload:
The Java programming language supports overloading methods, and Java can distinguish between methods with different method signatures. This means that methods within a class can have the same name if they have different parameter lists .

Overloaded methods are differentiated by the number and the type of the arguments passed into the method.

You cannot declare more than one method with the same name and the same number and type of arguments, because the compiler cannot tell them apart.

The compiler does not consider return type when differentiating methods, so you cannot declare two methods with the same signature even if they have a different return type.

首先看一段代碼,來看看代碼的執行結果:

public class OverrideTest {

    class Father{}

    class Sun extends Father {}

    public void doSomething(Father father){
        System.out.println("Father do something");
    }

    public void doSomething(Sun father){
        System.out.println("Sun do something");
    }

    public static void main(String [] args){
        OverrideTest overrideTest = new OverrideTest();
        Father sun = overrideTest.new Sun();
        Father father = overrideTest.new Father();
        overrideTest.doSomething(father);
        overrideTest.doSomething(sun);
    }
}

看下這段代碼的執行結果,最後會打印:

Father do something
Father do something

爲什麼會打印出這樣的結果呢? 首先要介紹兩個概念:靜態分派和動態分派

靜態分派:依賴靜態類型來定位方法執行版本的分派動作稱爲靜態分派

動態分派:運行期根據實際類型確定方法執行版本的分派過程。

他們的區別是:

1.  靜態分派發生在編譯期,動態分派發生在運行期;

2.  private,static,final 方法發生在編譯期,並且不能被重寫,一旦發生了重寫,將會在運行期處理。

3.  重載是靜態分派,重寫是動態分派

回到上面的問題,因爲重載是發生在編譯期,所以在編譯期已經確定兩次 doSomething 方法的參數都是Father類型,在class文件中已經指向了Father類的符號引用,所以最後會打印兩次Father do something。

二. override:
An instance method in a subclass with the same signature (name, plus the number and the type of its parameters) and return type as an instance method in the superclass overrides the superclass's method.

The ability of a subclass to override a method allows a class to inherit from a superclass whose behavior is "close enough" and then to modify behavior as needed. The overriding method has the same name, number and type of parameters, and return type as the method that it overrides. An overriding method can also return a subtype of the type returned by the overridden method. This subtype is called a covariant return type.

還是上面那個代碼,稍微改動下

public class OverrideTest {

    class Father{}

    class Sun extends Father {}

    public void doSomething(){
        System.out.println("Father do something");
    }

    public void doSomething(){
        System.out.println("Sun do something");
    }

    public static void main(String [] args){
        OverrideTest overrideTest = new OverrideTest();
        Father sun = overrideTest.new Sun();
        Father father = overrideTest.new Father();
        overrideTest.doSomething();
        overrideTest.doSomething();
    }
}


最後會打印:

Father do something

Sun do something

 

相信大家都會知道這個結果,那麼這個結果jvm是怎麼實現的呢?

在編譯期,只會識別到是調用Father類的doSomething方法,到運行期纔會真正找到對象的實際類型。

首先該方法的執行,jvm會調用invokevirtual指令,該指令會找棧頂第一個元素所指向的對象的實際類型,如果該類型存在調用的方法,則會走驗證流程,否則繼續找其父類。這也是爲什麼子類可以直接調用父類具有訪問權限的方法的原因。簡而言之,就是在運行期纔會去確定對象的實際類型,根據這個實際類型確定方法執行版本,這個過程稱爲動態分派。override 的實現依賴jvm的動態分派。

參考文章

https://blog.csdn.net/dj_dengjian/article/details/80811348

https://blog.csdn.net/chenssy/article/details/12757911

https://blog.csdn.net/fan2012huan/article/details/51007517

https://blog.csdn.net/fan2012huan/article/details/50999777

https://www.cnblogs.com/serendipity-fly/p/9469289.html

https://blog.csdn.net/m0_37264516/article/details/86709537

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章