C++是默認具備拷貝語義的,對於沒有拷貝運算符和拷貝構造函數的類,能夠直接進行二進制拷貝,可是Java並不天生支持深拷貝,它的拷貝只是拷貝在堆上的地址,不一樣的變量引用的是堆上的同一個對象,那最初的對象是怎麼被構建出來的呢?html
關於對象的建立過程通常是從new指令(我說的是JVM的層面)開始的(具體請看圖1),JVM首先對符號引用進行解析,若是找不到對應的符號引用,那麼這個類尚未被加載,所以JVM便會進行類加載過程(具體加載過程可參見個人另外一篇博文)。符號引用解析完畢以後,JVM會爲對象在堆中分配內存,HotSpot虛擬機實現的JAVA對象包括三個部分:對象頭、實例字段和對齊填充字段(對齊不必定),其中要注意的是,實例字段包括自身定義的和從父類繼承下來的(即便父類的實例字段被子類覆蓋或者被private修飾,都照樣爲其分配內存)。相信不少人在剛接觸面向對象語言時,總把繼承當作簡單的「複製」,這實際上是徹底錯誤的。JAVA中的繼承僅僅是類之間的一種邏輯關係(具體如何保存記錄這種邏輯關係,則設計到Class文件格式的知識),惟有建立對象時的實例字段,能夠簡單的當作「複製」。java
爲對象分配完堆內存以後,JVM會將該內存(除了對象頭區域)進行零值初始化,這也就解釋了爲何JAVA的屬性字段無需顯示初始化就能夠被使用,而方法的局部變量卻必需要顯示初始化後才能夠訪問。最後,JVM會調用對象的構造函數,固然,調用順序會一直上溯到Object類。c++
初始化的順序是父類的實例變量構造、初始化->父類構造函數->子類的實例變量構造、初始化->子類的構造函數。對於靜態變量、靜態初始化塊、變量、初始化塊、構造器,它們的初始化順序依次是(靜態變量、靜態初始化塊)>(變量、初始化塊)>構造器。編程
JVM在爲一個對象分配完內存以後,會給每個實例變量賦予默認值,這個時候實例變量被第一次賦值,這個賦值過程是沒有辦法避免的。若是咱們在實例變量初始化器中對某個實例x變量作了初始化操做,那麼這個時候,這個實例變量就被第二次賦值了。 若是咱們在實例初始化器中,又對變量x作了初始化操做,那麼這個時候,這個實例變量就被第三次賦值了。若是咱們在類的構造函數中,也對變量x作了初始化操做,那麼這個時候,變量x就被第四次賦值。也就是說,一個實例變量,在Java的對象初始化過程當中,最多能夠被初始化4次。 segmentfault
下面仍是舉一個例子吧數組
class Parent { /* 靜態變量 */ public static String p_StaticField = "父類--靜態變量"; /* 變量 */ public String p_Field = "父類--變量"; protected int i = 9; protected int j = 0; /* 靜態初始化塊 */ static { System.out.println( p_StaticField ); System.out.println( "父類--靜態初始化塊" ); } /* 初始化塊 */ { System.out.println( p_Field ); System.out.println( "父類--初始化塊" ); } /* 構造器 */ public Parent() { System.out.println( "父類--構造器" ); System.out.println( "i=" + i + ", j=" + j ); j = 20; } } public class SubClass extends Parent { /* 靜態變量 */ public static String s_StaticField = "子類--靜態變量"; /* 變量 */ public String s_Field = "子類--變量"; /* 靜態初始化塊 */ static { System.out.println( s_StaticField ); System.out.println( "子類--靜態初始化塊" ); } /* 初始化塊 */ { System.out.println( s_Field ); System.out.println( "子類--初始化塊" ); } /* 構造器 */ public SubClass() { System.out.println( "子類--構造器" ); System.out.println( "i=" + i + ",j=" + j ); } /* 程序入口 */ public static void main( String[] args ) { System.out.println( "子類main方法" ); new SubClass(); } }
上面的初始化結果是:編程語言
父類--靜態變量ide
父類--靜態初始化塊函數
子類--靜態變量佈局
子類--靜態初始化塊
子類main方法
父類--變量
父類--初始化塊
父類--構造器
i=9, j=0
子類--變量
子類--初始化塊
子類--構造器
i=9,j=20
子類的靜態變量和靜態初始化塊的初始化是在父類的變量、初始化塊和構造器初始化以前就完成了。靜態變量、靜態初始化塊,變量、初始化塊初始化了順序取決於它們在類中出現的前後順序。
分析:
訪問SubClass.main(),(這是一個static方法),因而裝載器就會爲你尋找已經編譯的SubClass類的代碼(也就是SubClass.class文件)。在裝載的過程當中,裝載器注意到它有一個基類(也就是extends所要表示的意思),因而它再裝載基類。無論你創不建立基類對象,這個過程總會發生。若是基類還有基類,那麼第二個基類也會被裝載,依此類推。
執行根基類的static初始化,而後是下一個派生類的static初始化,依此類推。這個順序很是重要,由於派生類的「static初始化」有可能要依賴基類成員的正確初始化。
當全部必要的類都已經裝載結束,開始執行main()方法體,並用new SubClass()建立對象。
類SubClass存在父類,則調用父類的構造函數,你可使用super來指定調用哪一個構造函數。基類的構造過程以及構造順序,同派生類的相同。首先基類中各個變量按照字面順序進行初始化,而後執行基類的構造函數的其他部分。
對子類成員數據按照它們聲明的順序初始化,執行子類構造函數的其他部分。
靜態變量初始化器和靜態初始化器基本同實例變量初始化器和實例初始化器相同,也有相同的限制(按照編碼順序被執行,不能引用後定義和初始化的類變量)。靜態變量初始化器和靜態初始化器中的代碼會被編譯器放到一個名爲static的方法中(static是Java語言的關鍵字,所以不能被用做方法名,可是JVM卻沒有這個限制),在類被第一次使用時,這個static方法就會被執行。
接下來咱們再問一個問題,Java是怎麼經過引用找到對象的呢?
至此,一個對象就被建立完畢,此時,通常會有一個引用指向這個對象。在JAVA中,存在兩種數據類型,一種就是諸如int、double等基本類型,另外一種就是引用類型,好比類、接口、內部類、枚舉類、數組類型的引用等。引用的實現方式通常有兩種,具體請看圖3。此處說一句題外話,常常用人拿C++中的引用和JAVA的引用做對比,其實他們兩個只是「名稱」同樣,本質並沒什麼關係,C++中的引用只是給現存變量起了一個別名(引用變量只是一個符號引用而已,編譯器並不會給引用分配新的內存),而JAVA中的引用變量倒是真真正正的變量,具備本身的內存空間,只是不一樣的引用變量能夠「指向」同一個對象而已。所以,若是要拿C++和JAVA引用對象的方式相對比,C++中的指針倒和JAVA中的引用一模一樣,畢竟,JAVA中的引用其實就是對指針的封裝。
關於對象引用更深層次的問題,咱們將在JVM篇章中詳細解釋。
這一部分的內容至關寬泛,詳細的能夠查閱下面的參考文章,我在這裏主要強調幾個問題:
內部類的訪問權限(它對外部類的訪問權限和外部對它的訪問權限)
成員內部類爲何不能有靜態變量和靜態函數(final修飾的除外)
內部類和靜態內部類(嵌套內部類)的區別
局部內部類使用的形參爲何必須是final的
匿名內部類沒法具備構造函數,怎麼作初始化操做
內部類的繼承問題(因爲它必須和外部類實例相關聯)
在這裏只回答一下最後一個問題,因爲成員內部類的實現實際上是其構造函數的參數添加了外部類實體,因此內部類的實例化必須有外部類,但就類定義來講,內部類的定義只和外部類定義有關,代碼以下
public class Out { private static int a; private int b; public class Inner { public void print() { System.out.println(a); System.out.println(b); } } } // 內部類實例化 Out out = new Out(); Out.Inner inner = out.new Inner(); public class InheritInner extends Out.Inner { InheritInner(Out out){ out.super(); } }
最後關於內部類的實現原理,請閱讀參考文章中的《內部類的簡單實現原理》,這很是重要
Java的多態主要有如下幾種形式:
繼承
覆蓋
接口
多態是面向對象編程語言的重要特性,它容許基類的指針或引用指向派生類的對象,而在具體訪問時實現方法的動態綁定。Java 對於方法調用動態綁定的實現主要依賴於方法表,但經過類引用調用(invokevitual)和接口引用調用(invokeinterface)的實現則有所不一樣。
類引用調用的大體過程爲:Java編譯器將Java源代碼編譯成class文件,在編譯過程當中,會根據靜態類型將調用的符號引用寫到class文件中。在執行時,JVM根據class文件找到調用方法的符號引用,而後在靜態類型的方法表中找到偏移量,而後根據this指針肯定對象的實際類型,使用實際類型的方法表,偏移量跟靜態類型中方法表的偏移量同樣,若是在實際類型的方法表中找到該方法,則直接調用,不然,認爲沒有重寫父類該方法。按照繼承關係從下往上搜索。
方法表是實現動態調用的核心。方法表存放在方法區中的類型信息中。爲了優化對象調用方法的速度,方法區的類型信息會增長一個指針,該指針指向一個記錄該類方法的方法表,方法表中的每個項都是對應方法的指針。這些方法中包括從父類繼承的全部方法以及自身重寫(override)的方法。
Java 的方法調用有兩類:
動態方法調用:動態方法調用須要有方法調用所做用的對象,是動態綁定的。
靜態方法調用:靜態方法調用是指對於類的靜態方法的調用方式,是靜態綁定的;
類調用 (invokestatic) 是在編譯時就已經肯定好具體調用方法的狀況。
實例調用 (invokevirtual)則是在調用的時候才肯定具體的調用方法,這就是動態綁定,也是多態要解決的核心問題。
JVM 的方法調用指令有四個,分別是 invokestatic,invokespecial,invokesvirtual 和 invokeinterface。前兩個是靜態綁定,後兩個是動態綁定的。
class Person { public String toString(){ return "I'm a person."; } public void eat(){} public void speak(){} } class Boy extends Person{ public String toString(){ return "I'm a boy"; } public void speak(){} public void fight(){} } class Girl extends Person{ public String toString(){ return "I'm a girl"; } public void speak(){} public void sing(){} }
若是子類改寫了父類的方法,那麼子類和父類的那些同名的方法共享一個方法表項。所以,方法表的偏移量老是固定的。全部繼承父類的子類的方法表中,其父類所定義的方法的偏移量也老是一個定值。Person 或 Object中的任意一個方法,在它們的方法表和其子類 Girl 和 Boy 的方法表中的位置 (index) 是同樣的。這樣 JVM 在調用實例方法其實只須要指定調用方法表中的第幾個方法便可。
在常量池(這裏有個錯誤,上圖爲ClassReference常量池而非Party的常量池)中找到方法調用的符號引用 。
查看Person的方法表,獲得speak方法在該方法表的偏移量(假設爲15),這樣就獲得該方法的直接引用。
根據this指針獲得具體的對象(即 girl 所指向的位於堆中的對象)。
根據對象獲得該對象對應的方法表,根據偏移量15查看有無重寫(override)該方法,若是重寫,則能夠直接調用(Girl的方法表的speak項指向自身的方法而非父類);若是沒有重寫,則須要拿到按照繼承關係從下往上的基類(這裏是Person類)的方法表,一樣按照這個偏移量15查看有無該方法。
由於 Java 類是能夠同時實現多個接口的,而當用接口引用調用某個方法的時候,狀況就有所不一樣了。
Java 容許一個類實現多個接口,從某種意義上來講至關於多繼承,這樣一樣的方法在基類和派生類的方法表的位置就可能不同了。
interface IDance{ void dance(); } class Person { public String toString(){ return "I'm a person."; } public void eat(){} public void speak(){} } class Dancer extends Person implements IDance { public String toString(){ return "I'm a dancer."; } public void dance(){} } class Snake implements IDance{ public String toString(){ return "A snake."; } public void dance(){ //snake dance } }
咱們先來看一個示例
public class Test { public static class A { public void print() { System.out.println("A"); } public void invoke() { print(); sprint(); } static void sprint() { System.out.println("sA"); } } public static class B extends A { @Override public void print() { System.out.println("B"); } static void sprint() { System.out.println("sB"); } } public static void main(String[] args){ A a = new B(); a.invoke(); // B SA } }
因爲靜態方法是靜態調用的,在編譯期就決定了跳轉的符號,因此進入父類的invoke方法調用的sprint在編譯期便是A的sprint,A的sprint符號和B的sprint在class中並不相同,這個符號在編譯期已經肯定了。
可是當在invoke中調用print,Java是經過傳進來的this去找他的類型信息,再從類別信息裏去找方法表,因此依然調用的是子類方法表中的print。
咱們再看一個例子。
public class Test { public static class A { public int a = 3; public void print() { System.out.println(a); } } public static class B extends A { public int a = 4; } public static void main(String[] args){ B b = new B(); b.print(); // 3 } }
多態只適用於父子類一樣簽名的方法,而屬性是不參與多態的。在print裏的符號a在編譯期就肯定是A的a了。一樣的還有private的方法,私有方法不參與繼承, 也不會出如今方法表中,由於私有方法是由invokespecial指令調用的。
成員變量的訪問只根據靜態類型進行選擇,不參與多態
私有方法不會發生多態選擇,只根據靜態類型進選擇。
上面已經說明了類方法調用的問題,子類繼承父類在方法調用時依然是根據對象頭找型別信息,而後去本身的類信息裏找到方法區調用方法指針,和C++經過在對象中增長虛函數表指針不同,Java須要經過本身的運行時型別信息找到本身的方法表,並且這張方法表不只包含覆蓋的方法也包含不覆蓋的,不像C++,不一樣的虛函數表包含不一樣的方法。好比A->B->C,那麼A對象部分包含的虛函數表只有A聲明的虛方法,假設B新聲明瞭虛方法X,在C類的B類部分的末尾的虛函數表指針指向的才包含X,可是A類部分的指向的虛函數表則不會包含X。Java其實是先在編譯時期就得知方法的偏移,在調用的時候直接找到真正型別的方法表對應偏移的方法,若是一個父類引用調用了一個父類沒有的方法,在編譯期就會報錯。
和C++不一樣,C++的內存佈局是很是緊湊的,這也是爲了支持它自然的拷貝語義,c++父類對象的內存空間是直接被包含在子類對象的連續內存空間中的,其屬性的偏移都取決於聲明順序和對齊。而Java雖然父類的實例變量依然是和子類的放在同一個連續的內存空間,但並不是是經過簡單的偏移來取成員的。不過在Java對象的內存佈局中,依然是先安置父類的再安置子類的,因此講sizeof(Parent)大小的內容轉型成爲父類指針,就能夠實現super了。具體是在字節碼中子類會有個u2類型的父類索引,屬於CONSTANT_Class_info類型,經過CONSTANT_Class_info的描述能夠找到CONSTANT_Utf8_info,而後能夠找到指定的父類。
重載:方法名相同,但參數不一樣的多個同名函數
參數不一樣的意思是參數類型、參數個數、參數順序至少有一個不一樣
返回值和異常以及訪問修飾符,不能做爲重載的條件(由於對於匿名調用,會出現歧義,eg:void a ()和int a() ,若是調用a(),出現歧義)
main方法也是能夠被重載的
覆蓋:子類重寫父類的方法,要求方法名和參數類型徹底同樣(參數不能是子類),返回值和異常比父類小或者相同(即爲父類的子類),訪問修飾符比父類大或者相同
子類實例方法不能覆蓋父類的靜態方法;子類的靜態方法也不能覆蓋父類的實例方法(編譯時報錯),總結爲方法不能交叉覆蓋
隱藏:父類和子類擁有相同名字的屬性或者方法時,父類的同名的屬性或者方法形式上不見了,實際是仍是存在的。
當發生隱藏的時候,聲明類型是什麼類,就調用對應類的屬性或者方法,而不會發生動態綁定
方法隱藏只有一種形式,就是父類和子類存在相同的靜態方法
屬性只能被隱藏,不能被覆蓋
子類實例變量/靜態變量能夠隱藏父類的實例/靜態變量,總結爲變量能夠交叉隱藏
隱藏和覆蓋的區別:
被隱藏的屬性,在子類被強制轉換成父類後,訪問的是父類中的屬性
被覆蓋的方法,在子類被強制轉換成父類後,調用的仍是子類自身的方法
由於覆蓋是動態綁定,是受RTTI(run time type identification,運行時類型檢查)約束的,隱藏不受RTTI約束,總結爲RTTI只針對覆蓋,不針對隱藏
Java中存在兩種類型,原始類型和對象(引用)類型。原始類型,即數據類型,內存佈局符合其類型規範,並沒有其餘負載。而對象類型,則因爲自定義類型、垃圾回收,對象鎖等各類語義與JVM性能緣由,須要使用額外空間。
Java對象的內存佈局:對象頭(Header),實例數據(Instance Data),對齊填充(Padding)。
詳細的內容能夠查閱參考文章
這裏咱們主要講講在繼承和組合兩種情形下會對內存佈局形成什麼變化。
類屬性按照以下優先級進行排列:長整型和雙精度類型;整型和浮點型;字符和短整型;字節類型和布爾類型,最後是引用類型。這些屬性都按照各自的單位對齊。
不一樣類繼承關係中的成員不能混合排列。首先按照規則2處理父類中的成員,接着纔是子類的成員
當父類中最後一個成員和子類第一個成員的間隔若是不夠4個字節的話,就必須擴展到4個字節的基本單位。
若是子類第一個成員是一個雙精度或者長整型,而且父類並無用完8個字節,JVM會破壞規則1,按照整形(int),短整型(short),字節型(byte),引用類型(reference)的順序,向未填滿的空間填充。
數組有一個額外的頭部成員,用來存放「長度」變量。數組元素以及數組自己,跟其餘常規對象一樣,都須要遵照8個字節的邊界規則。
下面給一個例子
public class Test { public static class A { public A() { System.out.println(this.hashCode()); } } public static class B extends A { public B(){ System.out.println(this.hashCode()); System.out.println(super.equals(this)); } } public static void main(String[] args){ B b = new B(); } } /* * 輸出以下: * 1627674070 * 1627674070 * true */