JVM理論:(三/4)方法調用
本文主要總結虛擬機調用方法的過程是怎樣的,JAVA虛擬機裏面提供了5條方法調用的字節碼指令。分別如下:
invokestatic:調用靜態方法
invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
invokevirtual:調用所有的虛方法。
invokeinterface:調用接口方法,會在運行時期再確定一個實現此接口的對象。
invokedynamic:現在運行時期動態解析出調用點限定符所引用的方法,然後再執行該方法,在此之前的4條指令,分派邏輯都是固化在虛擬機裏面的,而invokedynamic指令的分派邏輯是由用戶所設定的引導方法決定的。
方法調用並不等同於方法執行,方法調用階段唯一的任務就是確定被調用方法的版本(即真正要調用哪一個方法),暫時還不涉及方法內部的具體運行過程。一切方法調用在Class文件裏面存儲的都是符號引用,而不是方法在實際運行時內存布局中的入口地址,這個特性給Java帶來了更強大的動態擴展能力,但也使得方法調用過程變得相對復雜起來,需要在類加載期間,甚至到運行期間才能確定目標方法的直接引用。
一、解析
所有方法調用中的目標方法在Class文件裏都是一個常量池中的符號引用,有這樣一種情況,在編譯器進行編譯時就有一個可確定的調用版本,並且這個方法的調用版本在運行期間是不可變的,對於這樣的方法,在類加載的解析階段就會將符號引用轉化為直接引用。對這類方法的調用過程稱為解析,稱這些方法為非虛方法。
非虛方法(在解析階段就可以確定唯一的調用版本)包括:
(1)能被invokestatic和invokespecial指令調用的方法,有靜態方法、私有方法、實例構造器、父類方法4類。對於靜態方法、私有方法,前者與類型直接關聯,後者在外部不可被訪問,這就決定了它們都不可能通過繼承或別的方式重寫其版本。靜態方法和私有方法為什麽不能實現多態呢?分享這兩篇文章https://blog.csdn.net/zhouhong1026/article/details/19114589、https://blog.csdn.net/zhousenshan/article/details/51222908。
(2)被final修飾的方法,雖然final方法使用invokevirtual指令調用,但由於它無法被覆蓋,沒有其他版本,所以也無須對方法接收者進行多態選擇。
解析調用一定是一個靜態的過程,在編譯期間就可以完全確定,在類裝載的解析階段就會把涉及的符號引用全部轉變為可確定的直接引用,不會延遲到運行期去完成。
二、分派
解析調用描述的是那些在編譯期就能確定唯一調用版本的方法,但對於那些有多個版本的方法,調用時又是如何確定該調用方法的哪一個版本呢?
分派調用可能是靜態的也可能是動態的,根據分派依據的宗量數可分為單分派和多分派,因此分派可分為:靜態單分派、靜態多分派、動態單分派、動態多分派。分派涉及到Java面向對象3個基本特征:繼承、封裝、多態。特別是多態性特征的一些體現——重載、重寫。
1、靜態分派
所有依賴變量的靜態類型來定位方法執行版本的分派動作稱為靜態分派,靜態分派的典型應用是方法重載,靜態分派發生在編譯階段。先通過以下例代碼來說明方法重載。
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 man"); } public void sayHello(Woman guy){ System.out.println("Hello woman"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); } } 運行結果: Hello guy Hello guy
要解釋上面的現象,先要說明幾個概念。
Human man = new Man();
上面一行代碼中,Human稱為變量的靜態類型,後面的Man則稱為變量的實際類型,靜態類型在編譯期就可知,而實際類型變化的結果在運行期才可以確定。main()裏面兩次sayHello()方法調用,在方法接收者已經確定是對象“sr”的前提下,使用哪個重載版本,就完全取決於傳入參數的數量和數據類型,代碼中刻意地定義了兩個靜態類型相同但實際類型不同的變量,但Javac編譯器在重載時是根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作為調用目標。
編譯器雖然能確定出方法的重載版本,但在很多情況下這個重載版本也不是唯一的,往往只能確定一個更加合適的版本。
解析與分派這二者並不是二選一的排他關系,它們是在不同層次上去篩選、確定目標方法的過程。例如,靜態方法會在類加載期就進行解析,但靜態方法也可能擁有重載版本,選擇重載版本的過程也是通過靜態分派完成的。
2、動態分派
動態分派和“重寫”有著密切聯系,以下列代碼為例來說明重寫。
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,兩個變量的實際類型不同,最終執行的目標方法也不同。兩條調用都用到了invokevirtual指令,invokevirtual指令的運行解析過程大致分為以下幾個步驟:
1)找到操作數棧頂的第一個元素所指向的對象的實際類型,記作C;
2)如果在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,如果通過則返回這個方法的直接引用,查找過程結束;如果不通過,則返回java.lang.IllegalAccessError。
3)否則,按照繼承關系從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
4)如果始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
由於invokevirtual指令執行的第一步就是在運行期確定接收者的實際類型,所以兩次調用中的invokevirtual指令把常量池中的類方法符號引用解析到了不同的直接引用上,這個過程就是Java語言中方法重寫的本質,我們把這種在運行期根據實際類型確定方法執行版本的分派過程稱為動態分派。
3、單分派與多分派
方法的接收者與方法的參數統稱為方法的宗量,根據分派基於多少宗量,可以將分派劃分為單分派和多分派兩種。像上面兩個例子如果既需要確定方法調用者的實際類型,又需要通過參數的靜態類型來確定使用哪種重載方法,屬於多分派,若只以一個宗量對目標方法進行選擇就是單分派。
4、虛擬機是如何具體實現動態分配的
動態分派對方法版本選擇時不會在運行期對類的方法元數據進行頻繁搜索以找到合適的目標方法,而是為類在方法區中建立一個虛方法表,如果是接口,在invokeinterface執行時也會用到接口方法表,使用虛方法表索引來代替元數據查找以提高性能。
虛方法表中存放著各個方法的實際入口地址,如果某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。如果子類中重寫了這個方法,子類方法表中的地址將會替換為指向子類實現版本的入口地址。如上圖所示,Son重寫了來自Father的全部方法,因此Son的方法表沒有指向Father類型數據的箭頭。但是Son和Father都沒有重寫來自Object的方法,所以他們的方法表中所有從Object繼承來的方法都指向了Object的數據類型。方法表一般在類加載的連接階段進行初始化,準備了類變量的初始值後,虛擬機會把該類的方法表也初始化完畢。
三、 invokedynamic對動態類型語言的支持
像Java、C++等這些在編譯期就進行類型檢查過程的語言屬於靜態類型語言,像PHP、Python、Javascript等這些在運行期才進行類型檢查的語言為動態類型語言。
JDK1.7以前,4條方法調用指令invokevirtual、invokespecial、invokestatic、invokeinterface的第一個參數都是被調用的方法的符號引用,符號引用包含了此方法定義在哪個具體類型之中、方法的名字以及參數順序、參數類型和方法返回值等信息,通過這個符號引用,虛擬機就可以翻譯出這個方法的直接引用。方法的符號引用在編譯時產生,而動態類型語言只有在運行期才能確定接收者類型。所以這4條指令無法很好地支持動態語言。
JDK1.7後,為了更好地支持動態類型語言,引入了第五條方法調用的字節碼指令invokedynamic。某種程度上可以說invokedynamic指令與MethodHandle機制的作用是一樣的。invokedynamic指令與前面4條“invoke*”指令的最大差別就是它的分派邏輯不是由虛擬機決定的,而是由程序員決定。在MethodHandles.Lookup中的三個方法findStatic()、findVirtual()、findSpecial()正是為了對應於invokestatic、invokevirtual & invokeinterface和invokespecial這幾條字節碼指令的執行權限校驗行為。
import static java.lang.invoke.MethodHandles.lookup; import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodType; public class Test{ class GrandFather{ void thinking(){ System.out.println("i am grandfather"); } } class Father extends GrandFather{ void thinking(){ System.out.println("i am father"); } } class Son extends Father{ void thinking(){ try{ MethodType mt=MethodType.methodType(void.class); MethodHandle mh=lookup().findSpecial(GrandFather.class, "thinking", mt, getClass()); mh.invoke(this); }catch(Throwable e){ } } } public static void main(String[] args){ (new Test().new Son()).thinking(); } }
上面這段代碼,可以通過“super”關鍵字很方便地調用到父類中的方法,但如果要訪問祖類的方法呢?在JDK 1.7之前,在Son類的thinking()方法中無法獲取一個實際類型是GrandFather的對象引用,而invokevirtual指令的分派邏輯就是按照方法接收者的實際類型進行分派,這個邏輯是固化在虛擬機中的,程序員無法改變。在JDK 1.7後,通過invokedynamic的支持可以用上面的代碼實現。
JVM理論:(三/4)方法調用