本文部分摘自《深入理解 Java 虛擬機第三版》
概述
方法呼叫并不等同于方法中的代碼被執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),之前講過,一切方法呼叫在 Class 檔案里面都是以符號參考的形式存盤,而非方法在實際運行時記憶體布局中的入口地址(直接參考),這個特性給 Java 帶來強大的動態擴展能力,但也使得 Java 方法呼叫程序變得相對復雜,某些呼叫需要在類加載期間,甚至到運行期間才能確定目標方法的直接參考
決議
所有方法呼叫的目標方法在 Class 檔案里面都是一個常量池中的符號參考,在類加載的決議階段,會將其中的一部分參考轉化為直接參考,這種決議能成立的前提是:方法在程式真正運行之前就有一個可確定的呼叫版本,并且這個方法的呼叫版本在運行期是不可變的,換句話說,呼叫目標在程式代碼寫好、編譯器進行編譯那一刻就已經確定下來了,這類方法的呼叫被稱為決議(Resolution)
在 Java 中符合“編譯期可知,運行期不可變”要求的方法,主要有靜態方法和私有方法兩大類,前者和型別直接關聯,后者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫出其他版本,因此更適合在類加載階段進行決議
呼叫不同型別的方法,位元組碼指令集里設計了不同的指令,Java 虛擬機支持以下 5 條方法呼叫位元組碼指令:
-
invokestatic
用于呼叫靜態方法
-
invokespecial
用于呼叫實體構造器方法、私有方法和父類中的方法
-
invokevirtual
用于呼叫所有虛方法
-
invokeinterface
用于呼叫介面方法,會在運行時再確定一個實作該介面的物件
-
invokedynamic
先在運行時動態決議出呼叫點限定符所參考的方法,然后再執行該方法
只要能被 invokestatic 和 invokespecial 指令呼叫的方法,都可以在決議階段確定唯一的呼叫版本,被 final 修飾的方法也是如此(使用 invokevirtual 指令呼叫),能在類加載時就把符號參考決議為直接參考的方法統稱為非虛方法(Non-Virtual Method),其他方法則稱為虛方法(Virtual Method)
分派
決議呼叫是一個靜態的程序,在編譯期就完全確定,在類加載的決議階段就會把涉及的符號參考全部轉變為明確的直接參考,不必延遲到運行期再去完成,而另一種方法呼叫形式:分派(Dispatch)呼叫則要復雜許多,分派呼叫也是多型實作的基礎,比如多載和重寫,就是依靠分派呼叫機制來確定正確的目標方法
分派呼叫可能是靜態的也可能是動態的,按照分派依據的宗量數又可分為單分派和多分派,這兩類分派方式兩兩組合就構成了靜態單分派、靜態多分派、動態單分派、動態多分派四種分派組合情況:
1. 靜態型別與實際型別
為了了解分派,首先要清楚靜態型別和動態型別這兩個概念,代碼如下:
// Human 是 Man 的父類
Human man = new Man();
我們把 Human 稱為變數的靜態型別(Static Type),后面的 Man 則被稱為變數的實際型別(Actual Type)或者運行時型別(Runtime Type),靜態型別和實際型別在程式中都可能發生變化,區別在于靜態型別的變化僅僅在編譯期可知;而實際型別的變化的結果在運行期才可確定
// 實際型別變化,必須等到程式運行到這行才能確定
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 靜態型別變化,編譯期即可知
Man man = (Man)human;
Woman woman = (Woman)woman;
2. 靜態分派
所有依賴靜態型別來決定方法執行版本的分派動作,都稱為靜態分派,靜態分派最典型的應用表現就是方法多載,代碼如下:
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human {}
static class Woman extends Human {}
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 sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}
程式的運行結果是兩次列印內容都是“hello guy”,因為使用哪個多載版本,完全取決于傳入引數的數量和型別,代碼中故意定義了兩個靜態型別相同,但實際型別不同的變數,但虛擬機在多載時只通過引數的靜態型別而不是實際型別作為判定依據,靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行,而由編譯器來確定方法的多載版本,
3. 動態分派
動態分派與 Java 多型性的另外一個重要體現 —— 重寫(Override)有著很密切的關系,我們還是用前面的代碼為例:
public class StaticDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected abstract void sayHello() {
System.out.println("hello gentleman");
}
}
static class Woman extends Human {
@Override
protected abstract 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();
}
}
運行結果分別是“hello gentleman”和“hello lady”,對于習慣了 Java 思想的我們來說是很正常的事,但虛擬機是符合判斷應該呼叫哪個方法的呢?顯然這里不可能再根據靜態型別來決定了,而是兩個變數的實際型別,動態分派是由虛擬機執行的,上述 Java 代碼被編譯成 class 位元組碼后,對應的 man.sayHello() 和 woman.sayHello() 會被編譯成 invokevirtual 方法呼叫指令,并且 man 和 woman 兩個方法的所有者(接收者)的參考會被壓到堆疊頂,invokevirtual 指令的運行時決議程序大致可分為以下幾步:
- 找到運算元堆疊頂的第一個元素所指向物件的實際型別
- 如果在實際型別中找到與常量中的描述符和簡單名稱都相符的方法,則進行方法權限校驗,通過則回傳該方法的直接參考,查找程序結束;不通過則回傳 java.lang.IllegalAccessError 例外
- 否則,按照繼承關系從下往上依次對實際型別的各個父類進行第二步操作
- 如果始終沒有找到合適的方法,則拋出 java.lang.IllegalAccessError 例外
invokevirtual 指令執行的第一步就是在運行期確定方法所有者的實際型別,這也是 Java 中方法重寫的本質,我們把這種在運行期根據實際型別確定方法執行版本的分派程序稱為動態分派
4. 單分派與多分派
方法的接收者與方法的引數統稱為方法的宗量,根據分派基于多少種宗量,可以將分派劃分為單分派和多分派兩種,單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多于一個宗量對目標方法進行選擇
public class Dispatch {
static class Rice {}
static class Chocolate {}
public static class Father {
public void eat(Rice rice) {
System.out.println("father eat rice");
}
public void eat(Chocolate chocolate) {
System.out.println("father eat chocolate");
}
}
public static class Son extends Father {
public void eat(Rice rice) {
System.out.println("son eat rice");
}
public void eat(Chocolate chocolate) {
System.out.println("son eat chocolate");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.eat(new Rice());
son.eat(new Chocolate());
}
}
列印結果分別是“father eat rice”和“son eat chocolate”,我們可以發現,這里的方法選擇是基于方法接收者的不同和引數不同兩個因素而造成的結果,也就是我們說的宗量,這里實際上涉及兩個階段,第一個階段是靜態分派的程序,方法接收者型別是 Father 還是 Son,方法引數是 Rice 還是 Chocolate,產生的兩條 invokevirtual 指令的引數分別指向常量池中 Father::eat(Rice) 和 Father::eat(Chocolate) 方法的符號參考,因為是根據兩個宗量進行選擇,所以 Java 中的靜態分派屬于靜態多分派,再看動態分派階段,此時唯一可以影響虛擬機選擇的因素只有方法接收者的實際型別了,即實際型別是 Father 還是 Son,因為只有一個宗量作為選擇依據,所以 Java 的動態分派屬于單分派型別
4. 虛擬機動態分派的實作
動態分派是執行非常頻繁的動作,動態分派的方法版本選擇程序需要運行時在接收者型別的方法元資料中搜索合適的目標方法,比如實際型別是 Father,那么就要在 Father 型別的方法元資料中尋找 eat 方法,為了提高運行效率,Java 虛擬機為型別在方法區中建立了一個虛方法表,虛方法表存放著各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那么子類的虛方法表和父類的虛方法表中相同方法的地址入口是一致的,都指向父類的實作,如果子類重寫了這個方法,子類虛方法表中的地址就會被替換為指向子類實作版本的方法入口地址
為了程式實作方便,具有相同簽名的方法,在父類、子類的虛方法表中都應當具有一致的索引序號,這樣當型別轉換時,只需要變更要查找的虛方法表即可,虛方法表一般在類加載的連接階段初始化完成,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/254668.html
標籤:Java
上一篇:2021最新 Java基礎面試題精選(附刷題小程式)
下一篇:Java泛型
