1. 程式人生 > >JVM--再談繼承與多型

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專欄的相關部落格中也有說明。

例項化後從記憶體上分析

首先問大家一個問題:建立子類物件的時候,會一同建立父類的物件嗎?

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

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

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

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虛擬機器》—周志明