Java靜態分派與動態分派
牛客題:
以下程式碼執行的結果是多少()?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
|
正確答案: C 你的答案: B (錯誤)
Sub:collection Sub:collection Sub:collection
Sub:hashSet Sub:arrayList Sub:collection
Super:collection Super:collection Super:collection
Super:hashSet Super:arrayList Super:collection
方法呼叫並不等於方法執行,方法呼叫階段唯一的任務就是確定被呼叫方法的版本(即呼叫哪一個方法),暫時還不涉及方法內部的具體執行過程。
在程式執行時,進行方法呼叫是最普遍、最頻繁的操作,但是Class檔案的編譯過程不包括傳統編譯中的連線步驟,一切方法呼叫在Class檔案裡面儲存的都只是符號引用,而不是方法在實際執行時記憶體佈局中的入口地址(相對於之前說的直接引用)。這個特性給Java帶來了更強大的動態擴充套件能力,但也使得Java方法呼叫過程變得相對複雜起來,需要在類載入期間,甚至到執行期間才能確定目標方法的直接引用。
解析
所有方法呼叫中的目標方法在Class檔案裡面都是一個常量池中的引用,在類載入的解析階段,會將其中的一部分符號引用轉化為直接引用。這種解析能成立的前提是:方法在程式真正執行之前就有一個可確定的呼叫版本,並且這個方法的呼叫版本在執行期是不可改變的。換句話說,呼叫目標在程式程式碼寫好、編譯器進行編譯時就必須確定下來,這類方法的呼叫稱為解析。
在Java語言中符合“編譯期可知,執行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與型別直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了他們不可能通過繼承或別的方式重寫其他版本,因此他們適合在類載入階段進行解析。
靜態方法、私有方法、例項構造器、父類方法。這些方法稱為非虛方法,它們在類載入的時候就會把符號引用解析為該方法的直接引用。與之相反,其他方法稱為虛方法(除去final方法)。
分派
靜態分派
-
public class StaticDispatch {
-
static abstract class Human{
-
}
-
static class Man extends Human{
-
}
-
static class Woman extends Human{
-
}
-
public static void sayHello(Human guy){
-
System.out.println("hello,guy!");
-
}
-
public static void sayHello(Man guy){
-
System.out.println("hello,gentlemen!");
-
}
-
public static void sayHello(Woman guy){
-
System.out.println("hello,lady!");
-
}
-
public static void main(String[] args) {
-
Human man=new Man();
-
Human woman=new Woman();
-
sayHello(man);
-
sayHello(woman);
-
}
-
}
輸出:
hello,guy!
hello,guy!
Human man=new Man();
我們把“Human”稱為變數的靜態型別,後面的“Man”稱為變數的實際型別,靜態型別和實際型別在程式中都可以發生一些變化,區別是靜態型別的變化僅僅在使用時發生,變數本身的靜態型別不會被改變,並且最終的靜態型別在編譯器可知;而實際型別變化的結果在執行期才確定,編譯器在編譯期並不知道一個物件的實際型別是什麼。
-
Human man=new Man();
-
sayHello(man);
-
sayHello((Man)man);//型別轉換,靜態型別變化,我們知道轉型後的靜態型別一定是Man
-
man=new Woman(); //實際型別變化,實際型別卻是不確定的
-
sayHello(man);
-
sayHello((Woman)man);//型別轉換,靜態型別變化
輸出:
hello,guy!
hello,gentlemen!
hello,guy!
hello,lady!
編譯器在過載時是通過引數的靜態型別而不是實際型別作為判定的依據。並且靜態型別在編譯期可知,因此,編譯階段,Javac編譯器會根據引數的靜態型別決定使用哪個過載版本。
所有依賴靜態型別來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用就是方法過載。
靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機器來執行的,而是由編譯器來完成。
但是,字面量沒有顯示的靜態型別,它的靜態型別只能通過語言上的規則去理解和推斷。
-
public class LiteralTest {
-
/**/
-
public static void sayHello(char arg){
-
System.out.println("hello char");
-
}
-
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 main(String[] args) {
-
sayHello('a');
-
}
-
}
輸出:
hello char
將過載方法從上向下依次註釋,將會得到不同的輸出。
如果編譯器無法確定要自定轉型為哪種型別,會提示型別模糊,拒絕編譯。
-
import java.util.Random;
-
public class LiteralTest {
-
/**/
-
public static void sayHello(String arg){//新增過載方法
-
System.out.println("hello String");
-
}
-
public static void sayHello(char arg){
-
System.out.println("hello char");
-
}
-
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 main(String[] args) {
-
Random r=new Random();
-
String s="abc";
-
int i=0;
-
sayHello(r.nextInt()%2!=0?s:i);//編譯錯誤
-
sayHello(r.nextInt()%2!=0?'a':false);//編譯錯誤
-
}
-
}
動態分派
-
public class DynamicDispatch {
-
static abstract class Human{
-
protected abstract void sayHello();
-
}
-
static class Man extends Human{
-
@Override
-
protected void sayHello() {
-
System.out.println("man say hello!");
-
}
-
}
-
static class Woman extends Human{
-
@Override
-
protected void sayHello() {
-
System.out.println("woman say hello!");
-
}
-
}
-
public static void main(String[] args) {
-
Human man=new Man();
-
Human woman=new Woman();
-
man.sayHello();
-
woman.sayHello();
-
man=new Woman();
-
man.sayHello();
-
}
-
}
輸出:
man say hello!
woman say hello!
woman say hello!
顯然,這裡不可能再根據靜態型別來決定,因為靜態型別同樣是Human的兩個變數man和woman在呼叫sayHello()方法時執行了不同的行為,並且變數man在兩次呼叫中執行了不同的方法。導致這個現象的原因很明顯,是這兩個變數的實際型別不同,Java虛擬機器是如何根據實際型別來分派方法執行版本的呢?
我們從invokevirtual指令的多型查詢過程開始說起,invokevirtual指令的執行時解析過程大致分為以下幾個步驟:
1、找到運算元棧頂的第一個元素所指向的物件的實際型別,記作C。
2、如果在型別C中找到與常量中的描述符和簡單名稱相符合的方法,然後進行訪問許可權驗證,如果驗證通過則返回這個方法的直接引用,查詢過程結束;如果驗證不通過,則丟擲java.lang.IllegalAccessError異常。
3、否則未找到,就按照繼承關係從下往上依次對型別C的各個父類進行第2步的搜尋和驗證過程。
4、如果始終沒有找到合適的方法,則跑出java.lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在執行期確定接收者的實際型別,所以兩次呼叫中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言方法重寫的本質。我們把這種在執行期根據實際型別確定方法執行版本的分派過程稱為動態分派。
虛擬機器動態分派的實現
前面介紹的分派過程,作為對虛擬機器概念模型的解析基本上已經足夠了,它已經解決了虛擬機器在分派中"會做什麼"這個問題。
但是,虛擬機器”具體是如何做到的“,可能各種虛擬機器實現都會有些差別。
由於動態分派是非常頻繁的動作,而且動態分派的方法版本選擇過程需要執行時在類的方法元資料中搜索合適的目標方法,因此虛擬機器的實際實現中基於效能的考慮,大部分實現都不會真正的進行如此頻繁的搜尋。面對這種情況,最常用的”穩定優化“手段就是為類在方法區中建立一個虛方法表(Virtual Method Table,也稱為vtable),使用虛方法表索引來代替元資料查詢以提高效能。
虛方法表中存放著各個方法的實際入口地址。如果某個方法在子類中沒有被重寫,那子類的虛方法表裡面的地址入口和父類相同方法的地址入口是一致的,都是指向父類的實際入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實際版本的入口地址。
為了程式實現上的方便,具有相同簽名的方法,在父類、子類的虛方法表中具有一樣的索引序號,這樣當型別變換時,僅僅需要變更查詢的方法表,就可以從不同的虛方法表中按索引轉換出所需要的入口地址。
方法表一般在類載入階段的連線階段進行初始化,準備了類的變數初始值後,虛擬機器會把該類的方法表也初始化完畢。
內容源自:
《深入理解Java虛擬機器》