摘要:對應的代碼接下來的句是關鍵部分,兩句分分別把剛剛創建的兩個對象的引用壓到棧頂。所以雖然指令的調用是相同的,但行調用方法時,此時棧頂存放的對象引用是,行則是。這,就是語言中方法重寫的本質。
類初始化
在講類的初始化之前,我們先來大概了解一下類的聲明周期。如下圖
類的聲明周期可以分為7個階段,但今天我們只講初始化階段。我們我覺得出來使用和卸載階段外,初始化階段是最貼近我們平時學的,也是筆試做題過程中最容易遇到的,假如你想了解每一個階段的話,可以看看深入理解Java虛擬機這本書。
下面開始講解初始化過程。
注意:
這里需要指出的是,在執行類的初始化之前,其實在準備階段就已經為類變量分配過內存,并且也已經設置過類變量的初始值了。例如像整數的初始值是0,對象的初始值是null之類的。基本數據類型的初始值如下:
數據類型 | 初始值 | 數據類型 | 初始值 |
---|---|---|---|
int | 0 | boolean | false |
long | 0L | float | 0.0f |
short | (short)0 | double | 0.0d |
char | "u0000" | reference | null |
byte | (byte)0 |
大家先想一個問題,當我們在運行一個java程序時,每個類都會被初始化嗎?假如并非每個類都會執行初始化過程,那什么時候一個類會執行初始化過程呢?
答案是并非每個類都會執行初始化過程,你想啊,如果這個類根本就不用用到,那初始化它干嘛,占用空間。
至于何時執行初始化過程,虛擬機規范則是嚴格規定了有且只有 5中情況會馬上對類進行初始化。
當使用new這個關鍵字實例化對象、讀取或者設置一個類的靜態字段,以及調用一個類的靜態方法時會觸發類的初始化(注意,被final修飾的靜態字段除外)。
使用java.lang.reflect包的方法對類進行反射調用時,如果這個類還沒有進行過初始化,則會觸發該類的初始化。
當初始化一個類時,如果其父類還沒有進行過初始化,則會先觸發其父類。
當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
當使用JDK 1.7的動態語言支持時,如果一個.....(省略,說了也看不懂,哈哈)。
注意是有且只有。這5種行為我們稱為對一個類的主動引用。
初始化過程類的初始化過程都干了些什么呢?
在類的初始化過程中,說白了就是執行了一個類構造器
至于clinit()方法都包含了哪些內容?
實際上,clinit()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合并產生的,編譯器收集的順序則是由語句在源文件中出現的順序來決定的。并且靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但不能訪問。如下面的程序。
public class Test1 { static { t = 10;//編譯可以正常通過 System.out.println(t);//提示illegal forward reference錯誤 } static int t = 0; }
給大家拋個練習
public class Father { public static int t1 = 10; static { t1 = 20; } } class Son extends Father{ public static int t2 = t1; } //測試調用 class Test2{ public static void main(String[] args){ System.out.println(Son.t2); } }
輸出結果是什么呢?
答案是20。我相信大家都知道為啥。因為會先初始化父類啊。
不過這里需要注意的是,對于類來說,執行該類的clinit()方法時,會先執行父類的clinit()方法,但對于接口來說,執行接口的clinit()方法并不會執行父接口的clinit()方法。只有當用到父類接口中定義的變量時,才會執行父接口的clinit()方法。
被動引用上面說了類初始化的五種情況,我們稱之為稱之為主動引用。居然存在主動,也意味著存在所謂的被動引用。這里需要提出的是,被動引用并不會觸發類的初始化。下面,我們舉例幾個被動引用的例子:
1.通過子類引用父類的靜態字段,不會觸發子類的初始化
/** * 1.通過子類引用父類的靜態字段,不會觸發子類的初始化 */ public class FatherClass { //靜態塊 static { System.out.println("FatherClass init"); } public static int value = 10; } class SonClass extends FatherClass { static { System.out.println("SonClass init"); } } class Test3{ public static void main(String[] args){ System.out.println(SonClass.value); } }
輸出結果
FatherClass init
說明并沒有觸發子類的初始化
2.通過數組定義來引用類,不會觸發此類的初始化。
class Test3{ public static void main(String[] args){ SonClass[] sonClass = new SonClass[10];//引用上面的SonClass類。 } }
輸出結果是啥也沒輸出。
3.引用其他類的常量并不會觸發那個類的初始化
public class FatherClass { //靜態塊 static { System.out.println("FatherClass init"); } public static final String value = "hello";//常量 } class Test3{ public static void main(String[] args){ System.out.println(FatherClass.value); } }
輸出結果:hello
實際上,之所以沒有輸出"FatherClass init",是因為在編譯階段就已經對這個常量進行了一些優化處理,例如,由于Test3這個類用到了這個常量"hello",在編譯階段就已經將"hello"這個常量儲存到了Test3類的常量池中了,以后對FatherClass.value的引用實際上都被轉化為Test3類對自身常量池的引用了。也就是說,在編譯成class文件之后,兩個class已經沒啥毛關系了。
重載對于重載,我想學過java的都懂,但是今天我們中虛擬機的角度來看看重載是怎么回事。
首先我們先來看一段代碼:
//定義幾個類 public abstract class Animal { } class Dog extends Animal{ } class Lion extends Animal{ } class Test4{ public void run(Animal animal){ System.out.println("動物跑啊跑"); } public void run(Dog dog){ System.out.println("小狗跑啊跑"); } public void run(Lion lion){ System.out.println("獅子跑啊跑"); } //測試 public static void main(String[] args){ Animal dog = new Dog(); Animal lion = new Lion();; Test4 test4 = new Test4(); test4.run(dog); test4.run(lion); } }
運行結果:
動物跑啊跑
動物跑啊跑
相信大家學過重載的都能猜到是這個結果。但是,為什么會選擇這個方法進行重載呢?虛擬機是如何選擇的呢?
在此之前我們先來了解兩個概念。
先來看一行代碼:
Animal dog = new Dog();
對于這一行代碼,我們把Animal稱之為變量dog的靜態類型,而后面的Dog稱為變量dog的實際類型。
所謂靜態類型也就是說,在代碼的編譯期就可以判斷出來了,也就是說在編譯期就可以判斷dog的靜態類型是啥了。但在編譯期無法知道變量dog的實際類型是什么。
現在我們再來看看虛擬機是根據什么來重載選擇哪個方法的。
對于靜態類型相同,但實際類型不同的變量,虛擬機在重載的時候是根據參數的靜態類型而不是實際類型作為判斷選擇的。并且靜態類型在編譯器就是已知的了,這也代表在編譯階段,就已經決定好了選擇哪一個重載方法。
由于dog和lion的靜態類型都是Animal,所以選擇了run(Animal animal)這個方法。
不過需要注意的是,有時候是可以有多個重載版本的,也就是說,重載版本并非是唯一的。我們不妨來看下面的代碼。
public class Test { public static void sayHello(Object arg){ System.out.println("hello Object"); } public static void sayHello(int arg){ System.out.println("hello int"); } public static void sayHello(long arg){ System.out.println("hello long"); } public static void sayHello(Character arg){ System.out.println("hello Character"); } public static void sayHello(char arg){ System.out.println("hello char"); } public static void sayHello(char... arg){ System.out.println("hello char..."); } public static void sayHello(Serializable arg){ System.out.println("hello Serializable"); } //測試 public static void main(String[] args){ char a = "a"; sayHello("a"); } }
運行下代碼。
相信大家都知道輸出結果是
hello char
因為a的靜態類型是char,隨意會匹配到sayHello(char arg);
但是,如果我們把sayHello(char arg)這個方法注釋掉,再運行下。
結果輸出:
hello int
實際上這個時候由于方法中并沒有靜態類型為char的方法,它就會自動進行類型轉換?!產"除了可以是字符,還可以代表數字97。因此會選擇int類型的進行重載。
我們繼續注釋掉sayHello(int arg)這個方法。結果會輸出:
hello long。
這個時候"a"進行兩次類型轉換,即 "a" -> 97 -> 97L。所以匹配到了sayHell(long arg)方法。
實際上,"a"會按照char ->int -> long -> float ->double的順序來轉換。但并不會轉換成byte或者short,因為從char到byte或者short的轉換是不安全的。(為什么不安全?留給你思考下)
繼續注釋掉long類型的方法。輸出結果是:
hello Character
這時發生了一次自動裝箱,"a"被封裝為Character類型。
繼續注釋掉Character類型的方法。輸出
hello Serializable
為什么?
一個字符或者數字與序列化有什么關系?實際上,這是因為Serializable是Character類實現的一個接口,當自動裝箱之后發現找不到裝箱類,但是找到了裝箱類實現了的接口類型,所以在一次發生了自動轉型。
我們繼續注釋掉Serialiable,這個時候的輸出結果是:
hello Object
這時是"a"裝箱后轉型為父類了,如果有多個父類,那將從繼承關系中從下往上開始搜索,即越接近上層的優先級越低。
繼續注釋掉Object方法,這時候輸出:
hello char...
這個時候"a"被轉換為了一個數組元素。
從上面的例子中,我們可以看出,元素的靜態類型并非就是一定是固定的,它在編譯期根根據優先級原則來進行轉換。其實這也是java語言實現重載的本質
重寫我們先來看一段代碼
//定義幾個類 public abstract class Animal { public abstract void run(); } class Dog extends Animal{ @Override public void run() { System.out.println("小狗跑啊跑"); } } class Lion extends Animal{ @Override public void run() { System.out.println("獅子跑啊跑"); } } class Test4{ //測試 public static void main(String[] args){ Animal dog = new Dog(); Animal lion = new Lion();; dog.run(); lion.run(); } }
運行結果:
小狗跑啊跑
獅子跑啊跑
我相信大家對這個結果是毫無疑問的。他們的靜態類型是一樣的,虛擬機是怎么知道要執行哪個方法呢?
顯然,虛擬機是根據實際類型來執行方法的。我們來看看main()方法中的一部分字節碼
//聲明:我只是挑出了一部分關鍵的字節碼 public static void (java.lang.String[]); Code: Stack=2, Locals=3, Args_size=1;//可以不用管這個 //下面的是關鍵 0:new #16;//即new Dog 3: dup 4: invokespecial #18; //調用初始化方法 7: astore_1 8: new #19 ;即new Lion 11: dup 12: invokespecial #21;//調用初始化方法 15: astore_2 16: aload_1; 壓入棧頂 17: invokevirtual #22;//調用run()方法 20: aload_2 ;壓入棧頂 21: invokevirtual #22;//調用run()方法 24: return
解釋一下這段字節碼:
0-15行的作用是創建Dog和Lion對象的內存空間,調用Dog,Lion類型的實例構造器。對應的代碼:
Animal dog = new Dog();
Animal lion = new Lion();
接下來的16-21句是關鍵部分,16、20兩句分分別把剛剛創建的兩個對象的引用壓到棧頂。17和21是run()方法的調用指令。
從指令可以看出,這兩條方法的調用指令是完全一樣的??墒亲罱K執行的目標方法卻并不相同。這是為啥?
實際上:
invokevirtual方法調用指令在執行的時候是這樣的:
找到棧頂的第一個元素所指向的對象的實際類型,記作C.
如果類型C中找到run()這個方法,則進行訪問權限的檢驗,如果可以訪問,則方法這個方法的直接引用,查找結束;如果這個方法不可以訪問,則拋出java.lang.IllegalAccessEror異常。
如果在該對象中沒有找到run()方法,則按照繼承關系從下往上對C的各個父類進行第二步的搜索和檢驗。
如果都沒有找到,則拋出java.lang.AbstractMethodError異常。
所以雖然指令的調用是相同的,但17行調用run方法時,此時棧頂存放的對象引用是Dog,21行則是Lion。
這,就是java語言中方法重寫的本質。
本次的講解到此結束,希望對你有所幫助。
關注公我的眾號:苦逼的碼農,獲取更多原創文章,后臺回復禮包送你一份特別的資源大禮包。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/76835.html
摘要:重寫語言中的定義子類方法有一個方法與父類方法的名字相同且參數類型相同。父類方法的返回值可以替換掉子類方法的返回值。思維導圖參考文檔極客時間深入拆解虛擬機是如何執行方法調用的上廣告 原文 回顧Java語言中的重載與重寫,并且看看JVM是怎么處理它們的。 重載Overload 定義: 在同一個類中有多個方法,它們的名字相同,但是參數類型不同。 或者,父子類中,子類有一個方法與父類非私有方...
摘要:中,任何未處理的受檢查異常強制在子句中聲明。運行時多態是面向對象最精髓的東西,要實現運行時多態需要方法重寫子類繼承父類并重寫父類中已 1、簡述Java程序編譯和運行的過程:答:① Java編譯程序將Java源程序翻譯為JVM可執行代碼--字節碼,創建完源文件之后,程序會先被編譯成 .class 文件。② 在編譯好的java程序得到.class文件后,使用命令java 運行這個 .c...
摘要:中,任何未處理的受檢查異常強制在子句中聲明。運行時多態是面向對象最精髓的東西,要實現運行時多態需要方法重寫子類繼承父類并重寫父類中已 1、簡述Java程序編譯和運行的過程:答:① Java編譯程序將Java源程序翻譯為JVM可執行代碼--字節碼,創建完源文件之后,程序會先被編譯成 .class 文件。② 在編譯好的java程序得到.class文件后,使用命令java 運行這個 .c...
摘要:也就是說,一個實例變量,在的對象初始化過程中,最多可以被初始化次。當所有必要的類都已經裝載結束,開始執行方法體,并用創建對象。對子類成員數據按照它們聲明的順序初始化,執行子類構造函數的其余部分。 類的拷貝和構造 C++是默認具有拷貝語義的,對于沒有拷貝運算符和拷貝構造函數的類,可以直接進行二進制拷貝,但是Java并不天生支持深拷貝,它的拷貝只是拷貝在堆上的地址,不同的變量引用的是堆上的...
閱讀 1625·2021-11-02 14:42
閱讀 521·2021-10-18 13:24
閱讀 939·2021-10-12 10:12
閱讀 1817·2021-09-02 15:41
閱讀 3201·2019-08-30 15:56
閱讀 2874·2019-08-29 16:09
閱讀 2056·2019-08-29 11:13
閱讀 3617·2019-08-28 18:06