方法調用是否是很熟悉?那你真的瞭解它嗎?今天就讓咱們來盤一下它。java
首先你們要明確一個概念,此處的方法調用並非方法中的代碼被執行,而是要肯定被調用方法的版本,即最終會調用哪個方法。編程
上篇文章中咱們瞭解到,class
字節碼文件中的方法的調用都只是符號引用,而不是直接引用(方法在實際運行時內存佈局中的入口地址),要實現二者的轉化,就不得不提到解析和分派了。微信
解析
咱們以前說過在類加載的解析階段,會將一部分的符號引用轉化爲直接引用,該解析成立的前提是:方法在程序真正運行以前就已經有一個可肯定的調用版本,而且這個方法的調用版本在運行期是不可改變的。咱們把這類方法的調用稱爲解析(Resolution
)。jvm
看到這個前提條件,有沒有小夥伴聯想到對象的多態性?ide
沒錯,就是這樣,在java
中能知足不被重寫的方法有靜態方法、私有方法(不能被外部訪問)、實例構造器和被final
修飾的方法,所以它們都適合在類加載階段進行解析,另外經過this
或者super
調用的父類方法也是在類加載階段進行解析的。工具
指令集
調用不一樣類型的方法,字節碼指令集裏設置了不一樣的指令,在jvm
裏面提供了5條方法調用字節碼指令:佈局
invokestatic
:調用靜態方法,解析階段肯定惟一方法版本invokespecial
:實例構造器init
方法、私有及父類方法,解析階段肯定惟一方法版本invokevirtual
:調用全部虛方法invokeinterface
:調用接口方法,在運行時再肯定一個實現該接口的對象invokedynamic
:先在運行時動態解析出調用點限定符所引用的方法,而後再執行該方法,在此以前的4條調用指令,分派邏輯是固化在Java
虛擬機內部的,而invokedynamic
指令的分派邏輯是由用戶所設定的引導方法決定的。
invokedynamic
指令是Java7
中增長的,是爲實現動態類型的語言作的一種改進,可是在java7
中並無直接提供生成該指令的方法,須要藉助ASM
底層字節碼工具來產生指令,直到java8
的lambda
表達式的出現,該指令纔有了直接的生成方式。性能
小知識點:靜態類型語言與動態類型語言測試
它們的區別就在於對類型的檢查是在編譯期仍是在運行期,知足前者就是靜態類型語言,反之是動態類型語言。即靜態類型語言是判斷變量自身的類型信息,動態類型語言是判斷變量值的類型信息,變量沒有類型信息,變量值纔有類型信息,這是動態語言的一個重要特徵。this
例java
類中定義的基本數據類型,在聲明時就已經肯定了他的具體類型了;而JS
中用var
來定義類型,值是什麼類型就會在調用時使用什麼類型。
虛方法與非虛方法
字節碼指令集爲invokestatic
、invokespecial
或者是用final修飾的invokevirtual
的方法的話,均可以在解析階段中肯定惟一的調用版本,符合這個條件的就是咱們上邊提到的五類方法。它們在類加載的時候就會把符號引用解析爲該方法的直接引用,這些方法能夠稱爲非虛方法。與之相反,不是非虛方法的方法是虛方法。
分派
若是咱們在編譯期間沒有將方法的符號引用轉化爲直接引用,而是在運行期間根據方法的實際類型綁定相關的方法,咱們把這種方法的調用稱爲分派。其中分派又分爲靜態分派和動態分派。
靜態分派
不知道你對重載瞭解多少?爲了解釋靜態分派,咱們先來個重載的小測試:
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 sr = new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); } }
請考慮一下輸出結果,沉默兩分鐘。答案是
hello,guy! hello,guy!
你答對了嘛?首先咱們來了解兩個概念:靜態類型和實際類型。拿Human man = new Man();
來講Human
稱爲變量的靜態類型,而Man
咱們稱爲變量的實際類型,區別以下:
- 靜態類型的變化僅僅在使用時才發生,變量自己的靜態類型是不會被改變,而且最終靜態類型在編譯期是可知的。
- 實際類型的變化是在運行期才知道,編譯器在編譯程序時並不知道一個對象的具體類型是什麼。
此處之因此執行的是Human
類型的方法,是由於編譯器在重載時,會經過參數的靜態類型來做爲斷定執行方法的依據,而不是使用實際類型。
全部依賴靜態類型來定位方法執行版本的分派動做稱爲靜態分派。靜態分派的典型應用就是方法重載。靜態分派發生在編譯階段,所以肯定靜態分派的動做實際上不是由虛擬機來執行的,而是由編譯器來完成。
動態分派
瞭解了重載以後再來了解下重寫?案例走起:
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!
此次相信你們的結果都對了吧?咱們先來補充一個知識點:
父類引用指向子類時,若是執行的父類方法在子類中未被重寫,則調用自身的方法;若是被子類重寫了,則調用子類的方法。若是要使用子類特有的屬性和方法,須要向下轉型。
根據這個結論咱們反向推理一下:man
和women
是靜態類型相同的變量,它們在調用相同的方法sayHello()
時返回了不一樣的結果,而且在變量man
的兩次調用中執行了不一樣的方法。致使這個現象的緣由很明顯,是這兩個變量的實際類型不一樣,Java
虛擬機是如何根據實際類型來分派方法執行版本的呢?咱們看下字節碼文件:
man.sayHello(); woman.sayHello();
咱們關注的是以上兩行代碼,他們對應的分別是17和21行的字節碼指令。單從字節碼指令角度來看,它倆的指令invokevirtual
和常量$Human.sayHello:()V
是徹底同樣的,可是執行的結果確是不一樣的,因此咱們得研究下invokevirtual
指令了,操做流程以下:
- 找到操做數棧頂的第一個元素所指向的對象的實際類型,記做C。
- 若是在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,若是經過則返回這個方法的直接引用,查找過程結束;若是不經過,則返回
java.lang.IllegalAccessError
異常(假如不在一同一個jar包下就會報非法訪問異常)。 - 不然,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
- 若是始終沒有找到合適的方法,則拋出
java.lang.AbstractMethodError
異常。
因爲invokevirtual
指令執行的第一步就是在運行期肯定接收者的實際類型,因此兩次調用中的invokevirtual
指令並非把常量池中方法的符號引用解析到直接引用上就結束了,還會根據接收者的實際類型來選擇方法版本(案例中的實際類型爲Man
和Woman
),這個過程就是Java
語言中方法重寫的本質。
咱們把這種在運行期根據實際類型肯定方法執行版本的分派過程稱爲動態分派。
單分派與多分派
方法的接收者與方法的參數統稱爲方法的宗量,這個定義最先應該來源於《Java與模式》一書。根據分派基於多少種宗量,能夠將分派劃分爲單分派和多分派兩種。單分派是根據一個宗量對目標方法進行選擇,多分派則是根據多於一個宗量對目標方法進行選擇。
舉例說明
public class Dispatch{ static class QQ{} static class_360{} public static class Father{ public void hardChoice(QQ arg){ System.out.println("father choose qq"); } public void hardChoice(_360 arg){ System.out.println("father choose 360"); } } public static class Son extends Father{ public void hardChoice(QQ arg){ System.out.println("son choose qq"); } public void hardChoice(_360 arg){ System.out.println("son choose 360"); } } public static void main(String[]args){ Father father=new Father(); Father son=new Son(); father.hardChoice(new_360()); son.hardChoice(new QQ()); } }
請考慮一下輸出結果,繼續沉默兩分鐘。答案是:
father choose 360 son choose qq
咱們來看看編譯階段編譯器的選擇過程,也就是靜態分派的過程。這時選擇目標方法的依據有兩點:一是靜態類型是Father
仍是Son
,二是方法參數是QQ
仍是360
。此次選擇結果的最終產物是產生了兩條invokevirtual
指令,兩條指令的參數分別爲常量池中指向Father.hardChoice(360)
及Father.hardChoice(QQ)
方法的符號引用。由於是根據兩個宗量進行選擇,因此Java語言的靜態分派屬於多分派類型。
再看看運行階段虛擬機的選擇,也就是動態分派的過程。在執行「son.hardChoice(new QQ())」
這句代碼時,更準確地說,是在執行這句代碼所對應的invokevirtual
指令時,因爲編譯期已經決定目標方法的簽名必須爲hardChoice(QQ)
,虛擬機此時不會關心傳遞過來的參數「QQ」究竟是「騰訊QQ」仍是「奇瑞QQ」,由於這時參數的靜態類型、實際類型都對方法的選擇不會構成任何影響,惟一能夠影響虛擬機選擇的因素只有此方法的接受者的實際類型是Father
仍是Son
。由於只有一個宗量做爲選擇依據,因此Java
語言的動態分派屬於單分派類型。
虛方法表
在面向對象的編程中,會很頻繁的使用到動態分派,若是在每次動態分派的過程當中都要從新在類的方法元數據中搜索合適的目標的話就極可能影響到執行效率。所以,爲了提升性能,jvm
採用在類的方法區創建一個虛方法表(Vritual Method Table
,也稱爲vtable
,與此對應的,在invokeinterface
執行時也會用到接口方法表——Inteface Method Table
,簡稱itable
)來實現,使用虛方法表索引來代替元數據查找以提升性能。
每個類中都有一個虛方法表,表中存放着各類方法的實際入口:
- 若是某個方法在子類中沒有被重寫,那子類的虛方法表裏面的地址入口和父類相同方法的地址入口是一致的,都指向父類的實現入口。
- 若是子類中重寫了這個方法,子類方法表中的地址將會替換爲指向子類實現版本的入口地址。
Son
重寫了來自Father
的所有方法,所以Son
的方法表沒有指向Father
類型數據的箭頭。可是Son
和Father
都沒有重寫來自Object
的方法,因此它們的方法表中全部從Object
繼承來的方法都指向了Object
的數據類型。
爲了程序實現上的方便,具備相同簽名的方法,在父類、子類的虛方法表中都應當具備同樣的索引序號,這樣當類型變換時,僅須要變動查找的方法表,就能夠從不一樣的虛方法表中按索引轉換出所需的入口地址。方法表通常在類加載的鏈接階段進行初始化,準備了類的變量初始值後,虛擬機會把該類的方法表也初始化完畢。
綁定機制
解析調用必定是個靜態的過程,在編譯期間就徹底肯定,在類裝載的解析階段就會把涉及的符號引用所有轉變爲可肯定的直接引用,不會延遲到運行期再去完成。分派(Dispatch
)調用則多是靜態的也多是動態的。所以咱們把 解析 和 靜態分派 這倆在編譯期間就肯定了被調用的方法,且在運行期間不變的調用稱之爲靜態連接,而在運行期才肯定下來調用方法的稱之爲動態連接。
咱們把在靜態連接過程當中的轉換成爲早期綁定,將動態連接過程當中的轉換稱之爲晚期綁定。
看到這,方法的調用你搞懂了嗎?若是你還有什麼困惑的話,能夠關注微信公衆號「阿Q說代碼」,也能夠加阿Q好友qingqing-4132,阿Q期待你的到來!