JVM--再談繼承與多態

此文試圖從JVM層面深刻剖析Java中的繼承與多態,知識面覆蓋class字節碼文件,對象的內存佈局,JVM的內存區域、分派,方法表等相關知識,內容整合於大量博客,知乎,書籍,並加上博主自己的理解,相信看完會對你大有裨益!

即使博主在JVM專欄已經有兩篇博客對多態的實現機制進行了分析,但是今天在分析了一波繼承的原理之後,發覺之前對於多態的講述還不完整,在查閱的相關資料之後,決定在這一篇博客真正的將繼承與多態講透徹!

注:本篇博客有部分內容摘抄自:從JVM角度看Java多態。表示感謝~


先來看一份代碼:

class Parent {
    protected int age;

    public Parent() {
        age = 40;
    }

    void eat() {
        System.out.println("父親在吃飯");
    }
}

class Child extends Parent {
    protected int age;

    public Child() {
        age = 18;
    }

    void eat() {
        System.out.println("孩子在吃飯");
    }

    void play() {
        System.out.println("孩子在打CS");
    }
}

public class TestPolymorphic {
    public static void main(String[] args) {
        Parent c = new Child();

        c.eat();
//      c.play();
        System.out.println("年齡:" + c.age);
    }
}

運行結果:

孩子在吃飯
年齡:40

並且如果我在代碼中沒有將c.play()進行註釋的話,將會編譯錯誤。

對於這些結果,我會在隨後給大家進行說明。我將以問答的形式來組成這篇博客的架構。隨着問題的深入這些疑惑都會被解決。


類之間的繼承,都繼承了哪些東西?

既然要談多態,就不能繞開繼承。那就從繼承開始講起。很經典也很值得思考的問題,子類從父類上都繼承了哪些東西?在類的字節碼文件中是怎麼體現的呢?實例化後在內存中又是怎麼體現的呢?

從語言層面上分析

我們先來說清楚子類到底都繼承了父類的哪些東西,當然這都是語言層面上的繼承,不涉及它的具體實現:

所有的東西,所有的父類的成員,包括變量(靜態變量)、常量和方法(存儲在方法表中),都成爲了子類的成員,除了構造方法。構造方法是父類所獨有的,因爲它的名字就是類的名字,所以父類的構造方法在子類中不存在。除此之外,子類繼承得到了父類所有的成員。

網上有些博客給出,子類沒有繼承父類的private成員,這種說法是錯誤的。我們只能說子類不能覆蓋且訪問父類的private變量,所以當我們試圖在子類中覆蓋或訪問父類的private變量的時候,編譯器會給我們報錯,但這並不意味着子類並沒有繼承父類的private變量與常量(隱藏了而已)。


從字節碼文件上分析

也許你會憑藉上面所述的子類會繼承父類的一切東西(除了構造器)而感覺在子類的字節碼文件中也會包含父類的所有屬性和方法。很遺憾,這種想法並不正確。先不說上面的例子,我們知道在Java中所有的類都默認繼承自Object,你可以嘗試使用javap命令編譯一個普通類的class文件,看看其產生的字節碼文件中是否含有Object中默認定義的方法信息,好比toString,equals方法等,如果你並沒有重寫這些方法的話。

那麼在字節碼文件中是如何表示兩個類之間的繼承關係呢?如果你對class文件熟悉的話,應該知道字節碼中含有字段表集合,方法表集合與父類索引和接口索引集合。

字段表集合用於描述接口或類中聲明的變量。方法表用於描述接口或類中所定義的方法。而父類索引與接口索引(implement也是一種繼承)則是用來確定這個類的繼承關係。父類索引用兩個u2類型(表示兩個字節)的索引值表示,它指向一個類型爲CONSTANT_Class_info的類描述符常量,這個類型常量存儲於字節碼的常量池中,通過CONSTANT_Class_info類型常量中的索引值可以找到定義在CONSTANT_Utf8_info類型常量中的全限定名字符串。CONSTANT_Utf8_info在常量池中表示的就是UTF-8編碼的字符串,也就是父類的名稱。而接口索引的索引表之前還有一個接口計數器,也是u2類型的,之所以有計數器,我們也知道,在Java中,類都是單根繼承,但是可以同時操作多個接口。索引表的內容則和父類索引相似,就不再贅述了。

因此在子類的字節碼文件中,它的字段表集合中不會列出從基類或父接口中繼承而來的字段。與字段表相對應,如果父類方法在子類中沒有被重寫,方法表集合中也不會出現來自父類的方法信息。我們在語言層面上所使用的繼承,對應到字節碼文件中,只不過是子類的字節碼文件中含有父類的索引罷了,父類中的屬性,方法都是通過這個索引找到指定的父類從而解析出來的。至於怎麼找,怎麼解析,則是類加載器與類加載機制部分的知識了,我在JVM專欄的相關博客中也有說明。


實例化後從內存上分析

首先問大家一個問題:創建子類對象的時候,會一同創建父類的對象嗎?

我沒有查閱過官方文檔,但是我在網絡上搜索了大量的相關資料,並且與學長也進行了討論,我目前偏向於,我覺得的確也是這樣設計的:創建子類對象的時候不會一同創建父類的對象

在知乎上,對這個問題進行了激烈的探討:java中,創建子類對象時,父類對象會也被一起創建麼?

首先我先說支撐自己觀點的原因:

引用一下知乎網友的回答:

new指令開闢空間,用於存放對象的各個屬性,方法等,反編譯字節碼你會發現只有一個new指令,所以開闢的是一塊空間,一塊空間就放一個對象。然後,子類調用父類的屬性,方法啥的,那並不是一個實例化的對象。並且如果一個子類繼承自抽象類或着接口,那麼實例化這個子類的時候還能實例化抽象類與接口不成?

而像一些博客與書籍所說的“子類對象的一部分空間存放的是父類對象”,我覺得這涉及到對象的內存佈局,等下在說這個問題。

現在解答一下上面代碼中的部分運行結果吧:c.eat()。我之前已經寫了兩篇關於多態的文章,具體的鏈接我不再貼出,直接在我的JVM專欄中尋找就可以。看過我前兩篇博客的讀者對這個代碼的運行結果應該不會有太大的疑惑,也就是我們前面講述的那些動態分派invokevirtual指令,但是在這篇博客中,對於多態的實現性機制,我還要再闡述一個關於虛方法表的概念。

在《深入理解Java虛擬機》這本書中,關於多態的實現機制也是講述了這三方面的內容,我之所以將三個東西分開講,是覺得沒有前面兩篇博客的沉澱,這三個東西還真的是不好串起來。當初博主看這部分內容的時候是一種似懂非懂的狀態,完全對這個三個東西沒有明確的認識,我昨天對這三個東西做了如下總結,覺得大概可以將多態的實現機制概括清楚:

1.動態分派能夠讓我們從語言層面正確辨析重寫(多態),我覺得它是Java語義上多態的實現;

2.invokevirtual指令則是對動態分派這個概念在JVM層面上功能的具體實現,即在JVM中是用怎樣一種邏輯實現了動態分派。明白了這個指令,感覺也就體現了多態實現代碼中的實現邏輯;

3.虛方法表則是支撐着invokevirtual指令的實現,我們知道invokevirtual指令代表了遞歸查找當前棧幀操作數棧頂上引用所代表的實際類型的過程,而虛方法表的實現就是讓invokevirtual指令有地方可查。

而且《深入理解Java虛擬機》一書中,也稱虛方法表是“虛擬機動態分派”的實現。由此可見虛方法表對於多態的重要意義。

說了這麼多,到底什麼是虛方法表呢?

虛方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值之後,虛擬機會把該類的虛方法表也初始化完畢。虛方法表存儲在方法區,虛方法表中存放的都是類中的實例方法,不會存儲靜態方法,因爲靜態方法屬於非虛方法,會在類加載的解析階段就將符號引用解析爲直接引用,因此不需要虛方法表。關於非虛方法的描述請參考這篇博客:JVM–詳解虛擬機字節碼執行引擎之靜態鏈接、動態鏈接與分派

虛方法表中的這些直接引用會指向JVM中相關類Class對象相應的方法信息上,當然這只是本類的方法,表中還有父類的方法,相應地指向父類類型Class對象的具體位置。

如果與上述代碼對應的話,應該是這樣:

這裏寫圖片描述

如上圖所示,Parent,Child都沒有重寫來自Object的方法,所以它們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。然後再各自指向本類中方法所存在的數據類型。但是這裏有兩點需要注意:

1.如果子類重寫了父類的方法,如上面中的eat方法,則子類方法表中的地址將會替換爲指向子類實現版本的入口地址,對應至上圖就是父類中有屬於自己的eat方法入口地址,子類也有屬於自己的eat方法入口地址。因此invokevirtual指令才能正確的找到重寫方法後的地址入口。

2.我們從上圖中可以看出,相同的方法,在子類和父類的虛方法表中都具有一樣的索引序號,這主要是爲了程序實現上的方便,因爲當實際類型發生變化時,僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。

好了,如果將此篇博客中的虛方法表和前兩篇博客中的動態分派與invokevirtual指令的查找過程完全弄明白的話,我覺的在理論方面你的多態已經算是完全沒有問題了,如果你還想更加深入,我覺得無非就是看JVM中多態的實現源碼了。

談到這,我覺得c.eat()方法的運行結果不用我說你們也完全明白了吧。

那麼接着上面所遺留的一個問題,對象的內存佈局,解決掉這個東西,c.play()爲什麼會編譯錯誤以及System.out.println("年齡:" + c.age)等於40的真相也將慢慢浮上水面。

以下內容引入自知乎用戶祖春雷

Java對象的內存佈局是由對象所屬的類確定。也可以這麼說,當一個類被加載到虛擬機中時,由這個類創建的對象的佈局就已經確定下來了。

Hotspot中Java對象的內存佈局:

每個Java對象在內存中都由對象頭和對象體組成。

對象頭是存放對象的元信息,包括該對象所屬類對象Class的引用以及hashcode和monitor的一些信息。關於對象頭的介紹,這篇博客有些許說明JVM–詳解創建對象與類加載的區別與聯繫

對象體主要存放的是Java對象自身的實例域以及從父類繼承過來的實例域,並且內部佈局滿足以下規則(從我所標出的重點來看,創建子對象的時候,確實不是真正意義上的同時創建一個基類對象):

規則1:任何對象都是8個字節爲粒度進行對齊的。
規則2:實例域按照如下優先級進行排列:長整型和雙精度類型;整型和浮點型;字符和短整型;字節類型和布爾類型,最後是引用類型。這些實例域都按照各自的單位對齊。
規則3:不同類繼承關係中的實例域不能混合排列。首先按照規則2處理父類中的實例域,接着纔是子類的實例域。
規則4:當父類中最後一個成員和子類第一個成員的間隔如果不夠4個字節的話,就必須擴展到4個字節的基本單位。
規則5:如果子類第一個實例域是一個雙精度或者長整型,並且父類並沒有用完8個字節,JVM會破壞規則2,按照整形(int),短整型(short),字節型(byte),引用類型(reference)的順序,向未填滿的空間填充。

還是以一個例子說明一下:

class Parent {
    private short six;
    private int age;
}

class Sub extend Parent {
    private String name;
    private int age;    
    private float price;
}

當前Sub對象的內存佈局由下:
這裏寫圖片描述

但是這些東西還不足以解釋爲什麼上述代碼中c.play()會報錯以及爲什麼System.out.println("年齡:" + c.age)的答案是40。繼續往下看。

我們需要注意這一句代碼:Parent c = new Child(),可以發現,c的實際類型雖然是Child,但它的靜態類型卻是Parent,問題就出在了靜態類型上!

學了這麼長時間的Java,博主一直沒有搞懂靜態類型存在的真實意義,在網上查到的都是以面向對象的思想給你解釋爲什麼Java中存在實際類型的同時還要存在靜態類型,而沒有從根本上說明靜態類型到底會對變量產生什麼樣的影響。

博主目前查閱到的設計靜態類型的真正作用有如下兩點(也許還有更多):

1.Java的類型檢查機制是靜態類型檢查
2.規定了引用能夠訪問內存空間的大小

對於第一點,不是本文的重點,直接給大家貼一篇相關博客深入分析Java的靜態類型檢查

我們直接來討論第二點。

我們都知道在C中有void類型的指針,而給指針前面限定一個類型就限制了指針訪問內存的方式,比如char *p就表示p只能一個字節一個字節地訪問內存,但是int *p中p就必須四個字節四個字節地訪問內存。但是我們都知道指針是不安全的,其中一個不安全因素就是指針可能訪問到沒有分配的內存空間,也就是說char *p雖然限制了p指針訪問內存的方式,但是沒有限制能訪問內存的大小,這一點要完全靠程序員自己掌握。

但是在Java中的靜態類型不但指定了以何種方式訪問內存,也規定了能夠訪問內存空間的大小。

對應於剛開始貼出得代碼:

我們看Parent實例對象的大小是佔兩行,但Child實例對象佔三行(這裏就是簡單量化一下)。

如下圖:

這裏寫圖片描述

所以雖然引用c指向的是Child實例對象,但是前面有Parent修飾它,它也只能訪問兩行的數據,也就是說c根本訪問不到Child類中的age!!!只能訪問到Parent類的age,所以輸出40。你也可以對照着我上面貼出的“Sub對象的內存佈局”那張圖來對剛開始貼出的代碼進行分析。

而且我們注意兩個類的方法表:

這裏寫圖片描述

我們看到Parent的方法表佔三行,Child的方法表佔4行,c雖然指向了Child類的實例對象,而對象中也有指針指向Child類的方法表,但是由於c受到了Parent的修飾,通過c也只能訪問到Child方法表中前3行的內容!!!!因此c.play()編譯會出錯。就是這個原因,它在方法表中根本找不到play方法。

前面說過,在方法表的形成過程中,子類重寫的方法會覆蓋掉表中原來的數據,也就是Child類的方法表的第三行是指向Child.eat的引用,而不是指向Parent.eat(因爲方法表產生了覆蓋),所以c訪問到的是Child.eat。也就是子類的方法(這也是作爲多態的一種解釋,比invokevirtual指令更加深入)!!!這種情況下,c是沒有辦法直接訪問到父類的eat方法的。

好了,本篇博客的內容已結束,對開頭的代碼也做出了完整的解釋。但是我們還是有一些地方沒有涵蓋,比如super關鍵字。對於super關鍵字的使用,我覺得如果你已經將我寫的三篇有關於多態的博客吸收與消化,那麼,對於super關鍵字的使用與基本理解,應該是沒有問題的,至於對它的深入研究,我們以後再說~~~


參考閱讀

《深入理解Java虛擬機》—周志明

JAVA基礎探究:子類與父類

從JVM角度看Java多態

java中,創建子類對象時,父類對象會也被一起創建麼?

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