前言
我們都知道多态是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中,隻有
private
、
static
、
final
修飾的方法以及構造方法是靜态綁定,這些方法都是不能被重寫的。
-
關鍵字标明的方法不能被繼承,自然不存在覆寫,是以其一開始就與定義該方法的類綁定在一起。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
-
:先在運作時動态解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條調用指令,分派邏輯都是固化在Java虛拟機中的,而invokedynamic
指令的分派邏輯是由使用者所設定的引導方法決定的。invokedynamic
顯而易見,前兩個位元組碼指令是屬于靜态綁定的指令,而其餘則是動态綁定的指令。
在類加載過程中,解析階段就是将符号引用轉換為直接引用的過程。
回歸正題,上述程式的解析過程又是什麼樣的?
我們先來看一張圖,上面說過,父類的方法比子類的方法先得到解析,是以在方法表中,父類的方法位于前面。
如果父類的方法被子類重寫,則在子類方法表中其指向為子類重寫的方法代碼。
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICM38FdsYkRGZkRG9lcvx2bjxiNx8VZ6l2csMTSq1UNBRlTzkleZlmRywEMW1mY1RzRapnTtxkb5ckYplTeMZTTINGMShUYfRHelRHLwEzX39GZhh2css2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PnBnauMTMwMzNycTMwITOwkTMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.jpg)
- 通過上面的位元組碼指令:
我們找到常量池中的第7個常量表索引項(9: invokevirtual #7 // Method Father.test:()V
指的就是#7
類常量池中的第7個常量表的索引項):Father
#7 = Methodref #8.#25 // Father.test:()V
(因篇幅緣故不列出全部的常量池資訊)
發現這裡記錄的是方法
的符号引用test()
- JVM會根據這個符号引用找到方法
所在的全限定名:test()
(我這裡沒有設定Father
,若有的話則隻需将package
替換為.
/
即可)
(例如:
,全限定名為com.jvm.Son
)com/jvm/Son
- 在
類型的方法表中查找方法Father
,如果找到,則将方法test
在方法表中的索引項(就是圖中的9,也被稱作偏移量)記錄到test
類的常量池的第Son
7
個常量表中。
(這裡為的是進行安全檢查,JVM會首先将
的方法表加載,之後從Father
方法表中查找對應方法,如果方法不存在,那麼即使Father
類型中方法表有,編譯也無法通過)Son
這時常量池解析結束,可是我們能确定調用
test
方法執行的是哪一塊位元組碼嗎?顯然是不能的,引用雖然是父類類型,但它的指向程式還是不清楚的,那麼如何确定呢?
- 我們看
上一條位元組碼指令:invokevirtual
簡單解釋一下,8: aload_1
aload
的意思是從局部變量表的相應位置裝載一個對象引用到操作數棧的棧頂。
這裡将開始建立在堆中的
對象的引用(也就是引用類型Son
)壓入操作數棧,LFather
會根據這個invokevirtual
對象的引用找到堆中的Son
對象,繼而找到Son
對象所屬類型的方法表。Son
t i p s : 在 編 譯 時 加 入 ‘ − g ‘ 生 成 所 有 調 試 信 息 , 在 反 編 譯 的 時 候 利 用 ‘ − l ‘ 就 可 以 查 看 本 地 變 量 表 \color{#FF0000}{tips: 在編譯時加入`-g`生成所有調試資訊,在反編譯的時候利用`-l`就可以檢視本地變量表} tips:在編譯時加入‘−g‘生成所有調試資訊,在反編譯的時候利用‘−l‘就可以檢視本地變量表
例如編譯時:
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
引用找到堆中的
Son
對象執行個體。
而
Son
對象執行個體中持有着指向方法區的類型資訊的引用(在資料區裡),方法區的類型資訊中存有一個指向記錄該類方法的方法表的指針,這樣通過執行個體通路方法區,繼而找到
Son
的方法表。
(堆中執行個體并不隻有局部變量的定義,這裡隻是列出一個架構作為參考)
- 通過
常量表中的方法表的索引項(就是第三步裡說的#7
)定位到9
類型方法表中的方法Son
test
,通過直接位址找到該方法位元組碼所在的記憶體空間。
(因為子類和父類相同方法的索引相同,這個下面會提到,是以通過父類的索引就能找到子類中重寫方法的位置)
這裡有幾個要注意的點:
- JVM是根據父類
來解析常量池的,用Father
方法表中的索引項來代替常量池中的符号引用。Father
- 方法表在類加載的連結階段進行初始化,存放着各個方法的實際入口位址,如果某個方法在子類中沒有被重寫,那麼子類的方法表中該方法的入口位址與父類保持一緻。
- 相同的方法(相同的方法簽名:方法名和參數清單)在所有類的方法表中的索引相同,如果
位于test
類方法表的第9項,那麼其在Father
Son
類的方法表中也位于第9項。
(這裡是為了當類型變換時,僅需要變更查找的方法表,就可以按索引轉換出需要的入口位址)
-
父類的方法表永遠比子類的方法表先加載,當子類的方法表生成時,方法表首先會繼承一份自父類(類似于複制),父類有的方法子類都會獲得且索引項(偏移量)完全相同。
此時根據子類的方法調整方法表,如果子類重寫了父類的方法,那麼指針就會修改為指向那條重寫後的方法。
如果子類新增了一個方法,那麼就會在方法表某處添加一個指針,指向新增的方法。
問題解決
對于一開始講述的問題,我們一步一步來分析,首先看
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出,其實際的初始化過程順序為:
- 父類static成員初始化
- 子類static成員初始化
- 父類普通成員初始化
- 父類構造方法
- 子類普通成員初始化
- 子類構造方
(例子中沒有描述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
*/
這裡根據多态調用了子類重寫的方法,這樣做也是推薦(為了安全性與私有性)的一種做法。
參考資料
- 方法的虛分派(virtual dispatch)和方法表(method table)
- 細說JVM(虛拟機實作多态)
- Java動态綁定機制的内幕
- Java多态的實作原理
- Java動态綁定和靜态綁定-多态
- 向上轉型底層原理分析
- Java重寫方法與初始化的隐患
- 多态性實作機制——靜态分派與動态分派
- 深入了解Jvm–Java靜态配置設定和動态配置設定完全解析