一、前言
我們在初學Java的時候就知道Java是一門面向對象的編程語言,而面向對象的編程語言有三大特性:多態、繼承、封裝。封裝繼承自不必說,那么大家在初學Java的時候想過Java是如何實現多態的嗎,說實話我就沒有想過,畢竟這些實現對我來說是透明的,我只要會用多態就可以了,但是隨著學習的深入,發現在不清楚原理的情況下,對于多態的運用總是感覺很陌生,終于在學習《深入理解Java虛擬機》這本書時,書中給出了解答,所以寫一篇文章,增加一下印象,也希望能幫助到他人吧。
二、方法調用
在介紹Class文件的時候我們知道,Class文件的編譯過程并不包含傳統編譯的連接階段,Class文件中方法都是以符號引用的形式存儲的,而不是方法的入口地址(直接引用)。這個特性使得Java具有強大的動態擴展的能力,但同時也增加了Java方法調用過程的復雜性,因為方法需要在類加載期間甚至是運行時才能確定真正的入口地址,即將符號引用轉換為直接引用。
這里所說的方法調用并不等同于方法執行,這個階段的唯一目的就是確定被調用方法的版本,還不涉及方法內部的具體運行過程。對于方法的版本,需要解釋的就是由于重載與多態的存在,一個符號引用可能對應多個真正的方法,這就是方法的版本。
在Java虛擬機中提供了5條方法調用的字節碼指令,分別是:
-
invokestatic
:調用靜態方法; -
invokespecial
:調用實例構造器<init>
方法、私有方法和父類方法; -
invokevirtual
:調用所有的虛方法; -
invokeinterface
:調用接口方法,會在運行時再確定一個實現此接口的對象; -
invokedynamic
:先在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條調用指令,分派邏輯都是固化在Java虛擬機中的,而invokedynamic
指令的分派邏輯是由用戶所設定的引導方法決定的。
只要能被invokestatic
和invokespecial
指令調用的方法,都可以在類加載過程中的解析階段中確定唯一的調用版本,符合這個條件的方法有靜態方法、私有方法、實例構造器和父類方法四種,它們在類加載過程中的解析階段就會將符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法(也就是不涉及到多態的方法),與之對應的就是虛方法(也就是涉及到多態的方法)(除去final
方法,后面會有介紹)。虛方法需要在運行階段才能確定目標方法的直接引用。這樣,對于方法的調用就分為兩種,一種可以在類加載過程中的解析階段完成,另一種要在運行時完成,叫做分派。
三、解析
在類加載過程中,我們知道解析階段就是將符號引用轉換為直接引用的過程,在這個階段,會將Class文件中的一部分方法的符號引用解析為直接引用,這種解析能夠成立的條件是,方法在程序真正運行之前就有一個可確定的調用版本,并且這個方法的調用版本在運行期間是不變的。也就是說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。很明顯復合條件的方法就是非虛方法。
下面的代碼演示了一個最常見的解析調用的例子,代碼如下:
package temp;
public class HelloWprld {
public HelloWprld() x{
}
private void myMethod() {
System.out.println("My Method");
}
public static void sayHello() {
System.out.println("Hellow");
}
public static void main(String[] args) {
new HelloWprld().myMethod();
sayHello();
HelloWprld helloWprld = new HelloWprld();
}
}
這里測試了實例構造器方法、靜態方法、私有方法三種的方法調用,程序編譯后,可以使用javap -verbose HelloWprld.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 #6 // class temp/HelloWprld
3: dup
4: invokespecial #7 // Method "<init>":()V
7: invokespecial #8 // Method myMethod:()V
10: invokestatic #9 // Method sayHello:()V
13: new #6 // class temp/HelloWprld
16: dup
17: invokespecial #7 // Method "<init>":()V
20: astore_1
21: return
我們可以發現,對于非虛方法,確實使用invokespecial
和invokestatic
調用的。
四、分派
正是由于多態的存在,使得在判斷方法調用的版本的時候會存在選擇的問題,這也正是分派階段存在的原因。這一部分會在Java虛擬機的角度介紹重載和重寫的底層實現原理。
分派調用既可能是靜態的也可能是動態的,根據分派的宗量數可以分為單分派和多分派,這兩類分派方法的兩兩組合就構成了靜態單分派、靜態多分派、動態單分派和動態多分派四種。我們首先講解靜態分派和動態分派。
1、靜態分派
首先先看一段代碼:
package temp;
public class StaticDispatch {
static class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human human){
System.out.println("hello,guy!");
}
public void sayHello(Man man){
System.out.println("Hello,gentleman!");
}
public void sayHello(Woman woman){
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);
}
}
運行結果是:
我們來探究一下為啥是這樣的結果:
首先需要了解兩個概念:靜態類型、實際類型,靜態類型可以理解為變量聲明的類型,比如上面的man這個變量,它的靜態類型就是Human。而實際類型就是創建這個對象的類型,man這個變量的實際類型就是Man。這兩種類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會發生變化,并且最終的靜態類型是編譯期間可知的。而實際類型變化的結果在運行期才可以確定,編譯器在編譯程序時并不知道一個對象的實際類型是什么。比如下面的代碼:
//實際類型變化
Human man=new Man();
man=new Woman();
//靜態類型變化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);
我們只有在還沒有編譯代碼的時候可以修改變量的靜態類型,比如類型的強制轉化,但是我們卻可能在運行期間修改變量的實際類型,比如在代碼中將引用指向一個其他的對象。
了解了這兩個概念之后,回頭看看上面的代碼。main
方法里的兩次sayHello
方法調用,在方法接收者已經確定是對象sr
的前提下,使用哪個重載版本,就完全取決于傳入參數的數量和數據類型。但是這里的代碼定義了兩個靜態類型相同但實際類型不同的變量,編譯器在重載時是通過靜態類型而不是實際類型作為判斷依據的。并且靜態類型是編譯期間可知的,因此,在編譯階段,Javac
編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)
這個版本作為調用目標,并把這個方法的符號引用寫到main
方法里的invokevirtual
指令的參數中。下面是虛擬機執行的字節碼指令:
我們可以看到調用的確實是sayHello(Human)
這個版本的方法。
所有依賴靜態類型來定位方法版本的分派動作叫做靜態分派,靜態分派的典型應用是方法重載。靜態分派發生在編譯期間,因此確定靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本并不是唯一的,往往只是一個相對來說更加合適的版本。上面介紹了對于有明顯的靜態類型的情況下編譯器進行靜態分派是如何選擇的,那么對于沒有顯式的靜態類型的字面量時,虛擬機會如何應對呢?我們先看一下程序:
public class Overload {
public static void sayHello(Object arg){
System.out.println("Hello Object");
}
public static void sayHello(int arg){
System.out.println("Hello Int");
}
public static void sayHello(long arg){
System.out.println("Hello Long");
}
public static void sayHello(Character arg){
System.out.println("Hello Character");
}
public static void sayHello(char arg){
System.out.println("Hello Char");
}
public static void sayHello(char...arg){
System.out.println("Hello Char ...");
}
public static void sayHello(Serializable arg){
System.out.println("Hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
執行結果:
在字節碼中的調用情況:
我們的參數是字符類型,那么調用參數是字符類型的是很正常的情況。如果我們把參數是字符類型的方法注釋掉會怎么樣呢?
執行結果:
在字節碼中的調用情況:
結果變成了Hello Int
。這就是說在確定方法時,如果靜態類型沒有匹配的,可以發生類型轉換,這里將a
轉換為了數字97
,然后調用參數是int
類型的版本。
那么我們再把這個方法也注釋掉
執行結果:
結果又變為了Hello Long
。這又發生了一次類型轉換,將97
轉換為了long
。
這種類型轉換會按照char->int->long->float->double
的順序繼續下去。但不會轉換到byte
和short
,因為這種轉換是不安全的。
這里就不一一的實驗了,直接總結一下規律:
字面量會先按照char->int->long->float->double
這樣的順序去查找相應的方法,如果找不到,會按照自動裝箱的類型(int
對應Integer
、char
對應Character
)進行查找,如果還沒有相應的方法,會找自動裝箱后的對象的接口作為參數的方法,如果還沒有,會找相應的父類作為參數的方法,直到Object
,如果還沒有,則會選擇變長參數的方法。
上面演示了編譯期間選擇靜態分派的目標的過程,這也是Java語言實現方法重載的本質。這里需要注意,靜態分配和解析之間并不是排他的關系,而是不同層次上的篩選,靜態方法也是可以擁有重載版本的,也是通過靜態分派來實現重載的。
2、動態分配
在了解了靜態分派后,再看看動態分派的過程,它和多態性的另一個重要的特性重寫有關。下面用一個例子來介紹,代碼如下:
package temp;
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
public void sayHello(){
System.out.println("Hello gentleman");
}
}
static class Woman extends Human{
public void sayHello(){
System.out.println("Hello lady");
}
}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
運行結果:
這個結果對于熟悉Java面向對象編程的人來說都不陌生。這里要說明的是,虛擬機是如何知道要調用哪個版本的。
顯然這不是根據靜態類型決定的,因為兩個對象的靜態類型都是Human。但是調用的結果卻不同,這是因為這兩個對象的實際類型不同。所以,Java虛擬機是通過實際類型來判斷要調用方法的版本的。
不過Java虛擬機又是如何做到的呢?我們來看一下字節碼指令:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class temp/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method temp/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class temp/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method temp/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
24: new #4 // class temp/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method temp/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
36: return
0~15行是準備階段,是為了建立man
和woman
的內存空間,調用man
和woman
的類實例構造器,然后將這兩個實例的引用放在局部變量表中的第一和第二的位置。
接下來的16~21是方法調用的關鍵。16、20兩句分別把剛才創建的兩個對象的引用壓入棧頂,這兩個對象是將要執行的sayHello
方法的所有者,稱為接收者;17和21兩句詩方法調用指令,這兩條指令在這里看來都是一樣的,指令都是invokevirtual
,參數也都是一樣的,但這兩條指令最終執行的結果卻不同。原因就在invokevirtual
指令的多態查找過程上。invokevirtual
指令的運行時解析過程大致分為以下幾個步驟:
- 找到操作數棧頂的第一個元素所指向的對象的實際類型,記為C;
- 如果在類型C中找到與常量中的描述符和簡單名稱一樣的方法,,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,返回
java.lang.IllegalAccessError
異常; - 否則,按照繼承關系從下到上依次對C的各個父類進行搜索和驗證;
- 如果還沒有找到合適的方法,拋出
java.lang.AbstractMethodError
異常。
由于invokevirtual
指令執行的第一步就是在運行期間確定接收者的實際類型,所以兩次調用中的invokevirtual
指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。這種在運行期根據實際類型確定方法執行版本的分派過程叫做動態分派。
3、單分派和多分派
方法的接收者與方法的參數統稱為方法的宗量。根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是基于多個宗量。
下面以一個例子介紹一下單分派或多分派,代碼如下:
package temp;
public class Dispatch {
static class Football{}
static class Basketball{}
public static class Father{
public void like(Football f){
System.out.println("父親喜歡足球");
}
public void like(Basketball b){
System.out.println("父親喜歡籃球");
}
}
public static class Son extends Father{
public void like(Football f){
System.out.println("兒子喜歡足球");
}
public void like(Basketball b){
System.out.println("兒子喜歡籃球");
}
}
public static void main(String[] args) {
Father father=new Father();
Father son=new Son();
father.like(new Basketball());
son.like(new Football());
}
}
運行結果:
先看看靜態分派過程,這個時候選擇的依據有兩個:靜態類型是Father
還是Son
,方法參數是還Football
是Basketball
。這次選擇產生了兩個invokevirtual
指令,兩條指令的參數分別為常量池中指向Father.like(Football)
和Father.like(Basketball)
方法的符號引用。
因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬于多分派類型。
然后看看運行時虛擬機的選擇,即動態分派過程。在執行son.like(new Football());
時,也就是說在執行invokevirtual
指令時,由于編譯期間已經決定目標方法的簽名必須是like(Football)
,虛擬機此時不會關心傳遞過來的參數是什么,因為這時參數的靜態類型、實際類型都對方法的選擇不會構成影響,唯一有影響的就是方法的接收者的實際類型是Father
還是Son
。因為只有一個宗量,所以Java的動態分派屬于單分派。
四、虛擬機如何實現動態分派
由于動態分派是非常頻繁的操作,而且動態分派的方法版本選擇過程需要運行時在類的方法元數據中搜索合適的目標方法,因此虛擬機會進行優化。常用的方法就是為類在方法區中建立一個虛方法表(Virtual Method Table
,在invokeinterface
執行時也會用到接口方法表,Interface Method Table
),使用虛方法表索引來替代元數據查找以提升性能。
虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類重寫了父類的方法,子類方法表中的地址會替換為指向子類實現版本的入口地址。
為了程序實現上的方便,具有相同簽名的方法,在父類和子類的虛方法表中都應該具有一樣的索引號,這樣當類型變換時,僅僅需要變更查找的方法表,就可以從不同的虛方法表中按索引轉換出所需的入口地址。
方法表一般在類加載的連接階段進行初始化,準備了類的變量初始值后,虛擬機會把該類的方法表也初始化完畢。