方法調用並不等於方法執行,方法調用階段的唯一任務就是確定被調用方法的版本(即調用哪一方法),暫時還不涉及方法內部的具體運行過程。在程序運行時,進行方法調用是最普遍、最頻繁的操作。Class文件的編譯過程不包含編譯中的連接步驟,一切方法調用在Class文件里面存儲的都只是符號引用,而不是方法在實際運行時內存布局中的入口地址(相當於之前說的直接引用)。這個特性給Java帶來了強大的動態擴展能力,但也使得Java方法調用過程變得相對復雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。
1. 解析
所有方法調用中的目標方法在Class文件都是一個常量池中的符號引用,在類加載的解析階段,會將其中的一部分符號引用轉為直接引用,這種解析能成立的前提是:方法在程序真正運行之前就有一個可以確定的調用版本,並且這個方法的調用版本在運行期是不可變的。換句話說,調用目標在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱為解析(Resolution)。
在java中,符合"編譯期可知道,運行期不可變" 的方法主要包括:靜態方法和私有方法兩大類,靜態方法和類型直接關聯,私有方法在外部不可訪問,這兩種方法的特點決定了它們不可通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。
與之對應的是,在Java虛擬機里面提供了五條方法調用字節碼指令,分別如下:
invokestatic:調用靜態方法
invokespecial:調用實例構造器<init>方法、私有方法和類方法
invokevirtual:調用所有的虛方法。(虛方法出現在java的多態特性中,可以理解為java里所有被overriding的方法都是virtual的,所有重寫的方法都是override的)
invokeinterface:調用接口方法,會在運行時再確定一個實現此接口的對象。
invokedynamic:先在運行時動態解析出調用點限定符所引用的方法,然后再執行該方法,在此之前的4條調用指令,分派邏輯是固化在java虛擬機內部的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
只要能被invokestatic和invokespecial指令調用的方法,都可以在解析階段確定唯一的調用版本,符合這個條件的有靜態方法、私有方法、實例構造器、父類方法4類,它們在類加載的時候就會把符號引用解析為該方法的直接引用。這些方法可以稱為非虛方法,與之相反的方法稱為虛方法(除去final方法,final方法屬於非虛方法)。
如下是一個最常用的解析調用的例子:
public class Client { public static void sayHello() { System.out.println("hello"); } public static void main(String[] args) { Client.sayHello(); } }
用javap命令查看這段程序的字節碼,會發現是通過 invokestatic命令來調用sayHello()方法的。
PS C:\Users\Administrator\Desktop\新建文件夾> javap -c Client Compiled from "Client.java" public class Client { public Client(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void sayHello(); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String hello 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return public static void main(java.lang.String[]); Code: 0: invokestatic #5 // Method sayHello:()V 3: return }
更詳細的如下:
PS C:\Users\Administrator\Desktop\新建文件夾> javap -verbose Client Classfile /C:/Users/Administrator/Desktop/新建文件夾/Client.class Last modified 2019-11-5; size 478 bytes MD5 checksum c54103a6d56708bd2757c449315ebe03 Compiled from "Client.java" public class Client SourceFile: "Client.java" minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #7.#17 // java/lang/Object."<init>":()V #2 = Fieldref #18.#19 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #20 // hello #4 = Methodref #21.#22 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Methodref #6.#23 // Client.sayHello:()V #6 = Class #24 // Client #7 = Class #25 // java/lang/Object #8 = Utf8 <init> #9 = Utf8 ()V #10 = Utf8 Code #11 = Utf8 LineNumberTable #12 = Utf8 sayHello #13 = Utf8 main #14 = Utf8 ([Ljava/lang/String;)V #15 = Utf8 SourceFile #16 = Utf8 Client.java #17 = NameAndType #8:#9 // "<init>":()V #18 = Class #26 // java/lang/System #19 = NameAndType #27:#28 // out:Ljava/io/PrintStream; #20 = Utf8 hello #21 = Class #29 // java/io/PrintStream #22 = NameAndType #30:#31 // println:(Ljava/lang/String;)V #23 = NameAndType #12:#9 // sayHello:()V #24 = Utf8 Client #25 = Utf8 java/lang/Object #26 = Utf8 java/lang/System #27 = Utf8 out #28 = Utf8 Ljava/io/PrintStream; #29 = Utf8 java/io/PrintStream #30 = Utf8 println #31 = Utf8 (Ljava/lang/String;)V { public Client(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 2: 0 public static void sayHello(); flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=0, args_size=0 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String hello 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 5: 0 line 6: 8 public static void main(java.lang.String[]); flags: ACC_PUBLIC, ACC_STATIC Code: stack=0, locals=1, args_size=1 0: invokestatic #5 // Method sayHello:()V 3: return LineNumberTable: line 9: 0 line 10: 3 }
java中的非虛方法除了用invokestatic、invokespecial調用的方法之外還有一種就是被final修飾的方法。雖然final修飾的方法是通過invokevirtual方法指令調用的,但是由於它無法被覆蓋,沒有其他版本,所以也無需對方法接收者進行多態選擇,又或者說多態選擇的結果肯定是唯一的。在Java語言規范中明確說明了final方法是一種非虛方法。
解析調用是一個靜態的過程,在編譯期間就完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到運行期再去完成。而分派調用則可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派。兩兩組合就形成了四種:靜態單、靜態多、動態單、動態多分派四種分派組合情況。
2.分派
Java面向對象的3個基本特征:封裝、繼承、多態。分派調用的過程會揭示多態特性的一些基本體現,如重載和重寫。這里可以幫助我們理解虛擬機是如何正確的調用目標方法。
1.靜態分派--重載
下面是一段簡單的重載的代碼:
package zd.dms.test; /** * 靜態分配例子 * * @author Administrator * */ public class StaticDispatch { static abstract class Human { } static class Man extends Human { } static class Woman extends Human { } public static void sayHello(Human human) { System.out.println("human"); } public static void sayHello(Man man) { System.out.println("man"); } public static void sayHello(Woman woman) { System.out.println("woman"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); sayHello(man); sayHello(woman); } }
結果:
human
human
解釋:從結果看出執行的參數類型是Human,為什么會選擇Human的重載?
Human man = new Man();
我們把上面代碼的Human稱為變量的靜態類型(Static Type),或者叫做外觀類型(Apparent Type),后面的Man類稱為變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期才可確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什么。例如下面代碼:
// 靜態類型變化 Human man = new Man(); man = new Woman(); sayHello(man); // 實際類型變化 sayHello((Man) man); sayHello((Woman) man);
解釋了這兩個概念,main()方法的兩次sayHello()方法調用,使用哪個重載版本完全取決於傳入參數的數量和數據類型。代碼中定義了 兩個靜態類型相同但實際類型不同的變量,但虛擬機(准確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型來作為判定依據的。並且靜態類型是編譯器可知的,因此,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調用目標。
所有依賴靜態類型來定位方法執行版本的分派動作稱為靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。另外,編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本並不是唯一的,往往只能確定一個更加合適的版本。
用javap查看上述代碼編譯后的字節碼指令如下:
PS C:\Users\Administrator\Desktop\新建文件夾> javap -c StaticDispatch Compiled from "StaticDispatch.java" public class StaticDispatch { public StaticDispatch(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void sayHello(StaticDispatch$Human); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #3 // String human 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return public static void sayHello(StaticDispatch$Man); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #5 // String man 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return public static void sayHello(StaticDispatch$Woman); Code: 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #6 // String woman 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return public static void main(java.lang.String[]); Code: 0: new #7 // class StaticDispatch$Man 3: dup 4: invokespecial #8 // Method StaticDispatch$Man."<init>":()V 7: astore_1 8: new #9 // class StaticDispatch$Woman 11: dup 12: invokespecial #10 // Method StaticDispatch$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokestatic #11 // Method sayHello:(LStaticDispatch$Human;)V 20: aload_2 21: invokestatic #11 // Method sayHello:(LStaticDispatch$Human;)V 24: return }
可以看到是通過invokestatic調用靜態方法啊,而且參數類型是 LStaticDispatch$Human,代表引用類型 StaticDispatch$Human。
例如,下面的重載方法匹配優先級:
package zd.dms.test; import java.io.Serializable; public class Overload { // 1 public static void sayHello(char arg) { System.out.println("char"); } // 2 public static void sayHello(int arg) { System.out.println("int"); } // 3 public static void sayHello(long arg) { System.out.println("long"); } // 4 public static void sayHello(float arg) { System.out.println("float"); } // 5. public static void sayHello(double arg) { System.out.println("double"); } // 6. public static void sayHello(Character arg) { System.out.println("Character"); } // 7. public static void sayHello(Serializable arg) { System.out.println("Serializable"); } // 8. public static void sayHello(Object arg) { System.out.println("Object"); } // 9. public static void sayHello(char... arg) { System.out.println("char ..."); } public static void main(String[] args) { sayHello('a'); } }
實際會按照上面標注的順序進行匹配,如果注釋掉前面的代碼,會依次匹配后面的部分。
可變數組的優先級最低。
上面的順序解釋為:char先匹配,不存在會自動轉為int(ASCII碼進行轉換),接着會按照數字類型進行轉換 int -> long ->float -> double。如果還沒匹配到會轉為Character(發生一次自動裝箱),Character不會發生轉型到Integer。如果Character的參數類型也不存在,會轉為Serializable,因此Character實現了Serializable接口,所以會轉為其父接口。
Character還實現了一個Comparable<Character>接口,如果同時存在參數類型為Comparable<Character>和Serializable接口的方法,編譯器無法確定要轉為哪種類型,會提示類型模糊,拒絕編譯,解決辦法是調用時顯示地指定字面量的靜態類型,如: sayHello((Comparable<Character>)'a');
如果注釋掉sayHello(Serializable arg) 會調用Object方法,這時char裝箱后轉型為父類了,如果有多個父類,會從下往上開始搜索,越接近上層的優先級越低。即使傳入參數為null時,這個規則仍然試用sayHello(Object obj);優先級最低的就是可變長參數。
2.動態分派 --重寫
動態分派和多態的另一個特性----重寫有着密切的聯系。如下:
package zd.dms.test; 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類,所以是根據實際類型進行確定的。
我們采用Javap命令輸出字節碼查看:
PS E:\xiangmu\zdconpro\build\classes\zd\dms\test> javap -c '.\DynamicDispatch.class' Compiled from "DynamicDispatch.java" public class zd.dms.test.DynamicDispatch { public zd.dms.test.DynamicDispatch(); Code: 0: aload_0 1: invokespecial #8 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: new #16 // class zd/dms/test/DynamicDispatch$Man 3: dup 4: invokespecial #18 // Method zd/dms/test/DynamicDispatch$Man."<init>":()V 7: astore_1 8: new #19 // class zd/dms/test/DynamicDispatch$Woman 11: dup 12: invokespecial #21 // Method zd/dms/test/DynamicDispatch$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #22 // Method zd/dms/test/DynamicDispatch$Human.sayHello:()V 20: aload_2 21: invokevirtual #22 // Method zd/dms/test/DynamicDispatch$Human.sayHello:()V 24: new #19 // class zd/dms/test/DynamicDispatch$Woman 27: dup 28: invokespecial #21 // Method zd/dms/test/DynamicDispatch$Woman."<init>":()V 31: astore_1 32: aload_1 33: invokevirtual #22 // Method zd/dms/test/DynamicDispatch$Human.sayHello:()V 36: return }
0-15行的字節碼是准備動作,作用是建立man和woman的內存空間、調用Man和Woman類型的實例構造器,將這兩個實例的引用存放在第1、2個局部變量表Slot之中,對應下面代碼:
Human man = new Man(); Human woman = new Woman();
接下來的16-21句是關鍵部分,16、20分別把剛創建的兩個對象的引用壓到棧頂,這兩個對象是將要執行的sayHello()方法的所有者,稱為接收者;17和21句是方法調用指令,這兩條指令從字節碼角度看,無論是指令(invokevirtual)還是參數(都是常量池中第22項的常量,注釋顯示了這個常量是Human.sayHello()的符號引用)完全一樣,但是這兩句指令最終執行的目標方法並不相同。
原因就需要從invokevirtual的多態查找過程開始,invokevirtual指令的運行時解析過程大致分為以下幾步:
1)找到操作數棧頂的第一個元素所指向的實際類型,記作C
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回Java.lang.IllegalAccessError異常。
3)否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出 java.lang.AbstractMethodError 異常。
由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質。我們把這種運行在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
3. 單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量。根據分派基於多少種宗量,可以將分派划分為單分派和多分派。單分派是根據一個宗量對目標方法進行選擇,多分派是根據多於一個宗量對目標方法進行選擇。
如下:
package zd.dms.test; public class Dispatch { static class QQ { } static class _360 { } public static class Father { public void hardChoice(QQ qq) { System.out.println("father qq"); } public void hardChoice(_360 ars) { System.out.println("father _360"); } } public static class Son extends Father { public void hardChoice(QQ qq) { System.out.println("Son qq"); } public void hardChoice(_360 ars) { System.out.println("Son _360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
結果:
father _360
Son qq
首先查看編譯階段編譯器的選擇過程,也就是靜態分派的過程:這時選擇目標方法的依據有兩點:一是靜態類型是Father還是Son,二是方法參數是QQ還是_360。這次選擇的結果是產生了兩條invokevirtual指令,兩條指令分別指向Father.hardChoice(_360)和Father.hardChoice(QQ)方法的符號引用。因為是根據兩個宗量進行選擇,所以Java語言的靜態分派屬於多分派類型。
再看運行階段虛擬機的選擇,也就是動態分派的過程,在執行son.hardChoice(new QQ()); 代碼,更准確的說是執行代碼對應的invokevirtual指令時,由於編譯器已經決定目標方法的簽名必須為hardChoice(QQ),所以決定虛擬機選擇方法的因素只有此方法的接收者的實際類型是Father還是Son。因為只有一個宗量作為選擇依據,所以Java語言的動態分派屬於單分派類型。
所以上面結果可以總結為:Java是一門靜態多分派、動態單分派的語言。
上面對應的虛方法表如下:
虛方法表中存放着各個方法的實際入口地址。如果某個方法在子類沒有被重寫,那么子類的虛方法表里面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口;如果子類重寫了這個方法,子類方法表中的地址將會轉換為指向子類實現版本的入口地址。
正如上面所示,子類重寫了父類的全部方法,所以Son的方法表中沒有指向Father類數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以他們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。
補充:方法和字段的描述符用來描述字段的數據類型、方法的參數列表(包括數量、類型以及順序)和返回值,規則如下
對於數組類型,每一維度將使用一個前置的"["字符來描述,如描述一個"String[][]"將被記錄為"[[Ljava/lang/String",一個int型數組將被記錄為"[I"。
描述方法時,按照先參數后返回值的順序描述,參數列表按照參數順序放在一組括號內,如下:
public class client { public String[] toStringArrays(String str, int num) { return null; } public boolean hasValue(String[] strs, String str) { return false; } }
用javap查看如下:
PS C:\Users\Administrator\Desktop\新建文件夾> javap -v Client 警告: 二進制文件Client包含client Classfile /C:/Users/Administrator/Desktop/新建文件夾/Client.class Last modified 2019-11-5; size 380 bytes MD5 checksum f254abfdb23818c7259fe5d507c2c005 Compiled from "Client.java" public class client SourceFile: "Client.java" minor version: 0 major version: 51 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #3.#14 // java/lang/Object."<init>":()V #2 = Class #15 // client #3 = Class #16 // java/lang/Object #4 = Utf8 <init> #5 = Utf8 ()V #6 = Utf8 Code #7 = Utf8 LineNumberTable #8 = Utf8 toStringArrays #9 = Utf8 (Ljava/lang/String;I)[Ljava/lang/String; #10 = Utf8 hasValue #11 = Utf8 ([Ljava/lang/String;Ljava/lang/String;)Z #12 = Utf8 SourceFile #13 = Utf8 Client.java #14 = NameAndType #4:#5 // "<init>":()V #15 = Utf8 client #16 = Utf8 java/lang/Object { public client(); flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 2: 0 public java.lang.String[] toStringArrays(java.lang.String, int); flags: ACC_PUBLIC Code: stack=1, locals=3, args_size=3 0: aconst_null 1: areturn LineNumberTable: line 5: 0 public boolean hasValue(java.lang.String[], java.lang.String); flags: ACC_PUBLIC Code: stack=1, locals=3, args_size=3 0: iconst_0 1: ireturn LineNumberTable: line 9: 0 }