面向對象的三大特性:封裝、繼承、多態。在這三個特性中,若是沒有封裝和繼承,也不會有多態。html
那麼多態實現的途徑和必要條件是什麼呢?以及多態中的重寫和重載在JVM中的表現是怎麼樣?java
(若文章有不正之處,或難以理解的地方,請多多諒解,歡迎指正)segmentfault
多態是同一個行爲具備多個不一樣表現形式或形態的能力。
舉個栗子,一隻雞能夠作成白切雞、豉油雞、吊燒雞、茶油雞、鹽焗雞、蔥油雞、手撕雞、清蒸雞、叫花雞、啤酒雞、口水雞、香菇滑雞、鹽水雞、啫啫滑雞、雞公煲等等。ide
用上面的「雞的十八種吃法「來舉個栗子。spa
首先,咱們先給出一隻雞:code
class Chicken{ public void live(){ System.out.println("這是一隻雞"); } }
對於子類必須繼承父類,小編我的認爲,是由於按照面向對象的五大基本原則所說的中的依賴倒置原則:抽象不依賴於具體,具體依賴於抽象。既然要實現多態,那麼一定有一個做爲"抽象"類來定義「行爲」,以及若干個做爲"具體"類來呈現不一樣的行爲形式或形態。htm
因此咱們給出的一個具體類——白切雞類:對象
class BaiqieChicken extends Chicken{ }
但僅是定義一個白切雞類是不夠的,由於在此咱們只能作到複用父類的屬性和行爲,而沒有呈現出行爲上的不一樣的形式或形態。blog
重寫,簡單地理解就是從新定義的父類方法,使得父類和子類對同一行爲的表現形式各不相同。咱們用白切雞類來舉個栗子。繼承
class BaiqieChicken extends Chicken{ public void live(){ System.out.println("這是一隻會被作成白切雞的雞"); } }
這樣就實現了重寫,雞類跟白切雞類在live()方法中定義的行爲不一樣,雞類是一隻命運有着無限可能的雞,而白切雞類的命運就是作成一隻白切雞。
可是爲何還要有「父類引用指向子類對象」這個條件呢?
其實這個條件是面向對象的五大基本原則裏面的里氏替換原則,簡單說就是父類能夠引用子類,但不能反過來。
當一隻雞被選擇作白切雞的時候,它的命運就不是它能掌控的。
Chicken c = new BaiqieChicken(); c.live();
運行結果:
這是一隻會被作成白切雞的雞
爲何要有這個原則?由於父類對於子類來講,是屬於「抽象」的層面,子類是「具體」的層面。「抽象」能夠提供接口給「具體」實現,可是「具體」憑什麼來引用「抽象」呢?並且「子類引用指向父類對象」是不符合「依賴倒置原則」的。
當一隻白切雞想回頭從新選擇本身的命運,抱歉,它已經在鍋裏,逃不出去了。
BaiqieChicken bc = new Chicken(); //這句是運行不了的 bc.live();
多態的實現途徑有三種:重寫、重載、接口實現,雖然它們的實現方式不同,可是核心都是:同一行爲的不一樣表現形式。
重寫,指的是子類對父類方法的從新定義,可是子類方法的參數列表和返回值類型,必須與父類方法一致!因此能夠簡單的理解,重寫就是子類對父類方法的核心進行從新定義。
舉個栗子:
class Chicken{ public void live(String lastword){ System.out.println(lastword); } } class BaiqieChicken extends Chicken{ public void live(String lastword){ System.out.println("這隻白切雞說:"); System.out.println(lastword); } }
這裏白切雞類重寫了雞類的live()方法,爲何說是重寫呢?由於白切雞類中live()方法的參數列表和返回值與父類同樣,但方法體不同了。
重載,指的是在一個類中有若干個方法名相同,但參數列表不一樣的狀況,返回值能夠相同也能夠不一樣的方法定義場景。也能夠簡單理解成,同一行爲(方法)的不一樣表現形式。
舉個栗子:
class BaiqieChicken extends Chicken{ public void live(){ System.out.println("這是一隻會被作成白切雞的雞"); } public void live(String lastword){ System.out.println("這隻白切雞說:"); System.out.println(lastword); } }
這裏的白切雞類中的兩個live()方法,一個無參一個有參,它們對於白切雞類的live()方法的描述各不相同,但它們的方法名都是live。通俗講,它們對於白切雞雞生的表現形式不一樣。
接口,是一種沒法被實例化,但能夠被實現的抽象類型,是抽象方法的集合,多用做定義方法集合,而方法的具體實現則交給繼承接口的具體類來定義。因此,接口定義方法,方法的實如今繼承接口的具體類中定義,也是對同一行爲的不一樣表現形式。
interface Chicken{ public void live(); } class BaiqieChicken implements Chicken{ public void live(){ System.out.println("這是一隻會被作成白切雞的雞"); } } class ShousiChicken implements Chicken{ public void live(){ System.out.println("這是一隻會被作成手撕雞的雞"); } }
從上面咱們能夠看到,對於雞接口中的live()方法,白切雞類和手撕雞類都有本身對這個方法的獨特的定義。
前文咱們知道,java文件在通過javac編譯後,生成class文件以後在JVM中再進行編譯後生成對應平臺的機器碼。而JVM的編譯過程當中體現多態的過程,在於選擇出正確的方法執行,這一過程稱爲方法調用。
方法調用的惟一任務是肯定被調用方法的版本,暫時還不涉及方法內部的具體運行過程。(注:方法調用不等於方法執行)
在介紹多態的重載和重寫在JVM的實現以前,咱們先簡單瞭解JVM提供的5條方法調用字節碼指令:
invokestatic:調用靜態方法。
invokespecial:調用實例構造器<init>方法、私有方法和父類方法。
invokevirtual:調用全部的虛方法(這裏的虛方法泛指除了invokestatic、invokespecial指令調用的方法,以及final方法)。
invokeinterface:調用接口方法,會在運行時再肯定一個實現此接口的對象。
invokedynamic:先在運行時動態解析出調用點限定符所應用的方法(說人話就是用於動態指定運行的方法)。
而方法調用過程當中,在編譯期就能肯定下來調用方法版本的靜態方法、實例構造器<init>方法、私有方法、父類方法和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); } }
想一想以上代碼的運行結果是什麼?3,2,1,運行結果以下:
hello, guy! hello, guy!
爲何會出現這樣的結果?讓咱們來看這行代碼:
Human man = new Man();
根據里氏替換原則,子類必須可以替換其基類,也就是說子類相對於父類是「具體類」,而父類是處於「奠基」子類的基本功能的地位。
因此,咱們把上面代碼中的「Human」稱爲變量man的靜態類型(Static Type),然後面的"Man"稱爲變量的實際類型(Actual Type),兩者的區別在於,靜態類型是在編譯期可知的;而實際類型的結果在運行期才能肯定,編譯期在編譯程序時並不知道一個對象的實際類型是什麼。
在瞭解了這兩個概念以後,咱們來看看字節碼文件是怎麼說的:
javac -verbose StaticDispatch.class
咱們看到,圖中的黃色框的invokespecial指令以及<init>標籤,咱們能夠知道這三個是指令是在調用實例構造器<init>方法。同理,下面兩個紅色框的invokevirtual指令告訴咱們,這裏是採用分派調用的調用虛方法,並且入參都是「Human」。
由於在分派調用的時候,使用哪一個重載版本徹底取決於傳入參數的數量和數據類型。並且,虛擬機(準確說是編譯期)在重載時是經過參數的靜態類型而不是實際類型做爲判斷依據,而且靜態類型是編譯期可知的。
因此,在編譯階段,Javac編譯期就會根據參數的靜態類型決定使用哪一個重載版本。重載是靜態分派的經典應用。
咱們仍是用上面的例子:
public class StaticDispatch { 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 say hello woman say hello
相信你看到這裏也會會心一笑,這一看就很明顯嘛,重寫是按照實際類型來選擇方法調用的版本嘛。先別急,咱們來看看它的字節碼:
嘶...這好像跟靜態分派的字節碼同樣啊,可是從運行結果看,這兩句指令最終執行的目方法並不相同啊,那緣由就得從invokevirtual指令的多態查找過程開始找起。
咱們來看看invokevirtual指令的運行時解析過程的步驟:
- 找到操做數棧頂的第一個元素所指向的對象的實際類型,記做C。
- 若是在在類型C中找到與常量中的描述符和簡單名稱都相符的方法,則進行訪問權限校驗,若是經過則返回這個方法的直接引用,查找過程結束;若是不經過,則返回java.lang.IllegalAccessError異常。
- 不然,按照繼承關係從下往上依次對C的各個父類進行第2步的搜索和驗證過程。
- 若是始終沒有找到合適的方法,則拋出java.lang.AbstractMethodError異常。
咱們能夠看到,因爲invokevirtual指令在執行的第一步就是在運行期肯定接收者的實際類型,因此字節碼中會出現invokevirtual指令把常量池中的類方法符號引用解析到了不一樣的直接引用上,這個就是Java重寫的本質。
總結一下,重載的本質是在編譯期就會根據參數的靜態類型來決定重載方法的版本,而重寫的本質在運行期肯定接收者的實際類型。
堅持寫技術文章的確是一件不容易的事情。如今技術更新愈來愈快,但依然想把基礎再打牢一點。
若是本文對你理解多態有幫助,請給一個贊吧,這會是我最大的動力~
參考資料:
《深刻理解Java虛擬機》