前言 在上一篇文章中,我們知道了JVM的記憶體劃分,其中在說到方法區的時候說到方法區中存放的信息包括[已被JVM載入的類信息,常量,靜態變數,即時編譯的代碼等],整個方法區其實就和類載入有關. 類載入過程 類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入、驗證、準備、解析、初 ...
前言
在上一篇文章中,我們知道了JVM的記憶體劃分,其中在說到方法區的時候說到方法區中存放的信息包括[已被JVM載入的類信息,常量,靜態變數,即時編譯的代碼等],整個方法區其實就和類載入有關.
類載入過程
類從被載入到虛擬機記憶體中開始,到卸載出記憶體為止,它的整個生命周期包括:載入、驗證、準備、解析、初始化、使用和卸載七個階段.它們開始的順序如下圖所示:
而類載入是指的前五個階段,在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它既可能在初始化前開始(靜態分派),也可能初始化階段之後開始(動態分派)。另外註意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中調用或激活另一個階段。
靜態分派和動態分派
我們都知道,JAVA語言三大特性是封裝、繼承和多態,其中多態的底層實現原理就是靜態分派和動態分派機制.那麼什麼是靜態分派和動態分派呢?
靜態分派
所謂靜態分派,指的是方法在程式真正執行前就有一個可確定的調用版本,並且這個方法的調用版本在運行期是不可改變的.換句話說,調用目標在編譯器進行編譯時就必須確定下來,這類方法的調用稱為解析.符合靜態分派的方法有靜態方法、私有方法、實例構造器和父類方法四類,它們在類載入時就會把符號引用解析為該方法的直接引用,對應的過程發生在類解析階段.典型的應用是對應於多態中的方法重載:
class Human{
}
class Man extends Human{
}
class Woman extends Human{
}
public class StaticPai{
public void say(Human hum){
System.out.println("I am human");
}
public void say(Man hum){
System.out.println("I am man");
}
public void say(Woman hum){
System.out.println("I am woman");
}
public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
StaticPai sp = new StaticPai();
sp.say(man);
sp.say(woman);
}
}
很明顯,答案會是:
I am human
I am human
以上結果應該不難分析,首先Human man = new Man()中,我們把Human稱為靜態類型,Man稱為實際類型,由於編譯器在方法重載時是通過參數的靜態類型而不是實際類型作為判定依據的,因此執行結果如上,這就是靜態分派最典型的應用.
動態分派
所謂動態分派,是指程式在運行期根據實際類型確定方法執行版本的過程,該過程發生在類初始化階段,典型的應用是對應於多態中的方法重寫:
class Eat{
}
class Drink{
}
class Father{
public void doSomething(Eat arg){
System.out.println("爸爸在吃飯");
}
public void doSomething(Drink arg){
System.out.println("爸爸在喝水");
}
}
class Child extends Father{
public void doSomething(Eat arg){
System.out.println("兒子在吃飯");
}
public void doSomething(Drink arg){
System.out.println("兒子在喝水");
}
}
public class SingleDoublePai{
public static void main(String[] args){
Father father = new Father();
Father child = new Child();
father.doSomething(new Eat());
child.doSomething(new Drink());
}
}
運行結果會是:
爸爸在吃飯
兒子在喝水
至於虛擬機是如何實現動態分派的,從位元組碼指令來說是invokevirtual指令,具體的實現就不再贅述了.講完了靜態分派和動態分派,讓我們再來詳細描述一下類載入過程中每個階段所做的工作.
載入
載入是類載入過程的第一個階段,在載入階段,虛擬機需要完成以下三件事情:
1、通過一個類的全限定名來獲取其定義的二進位位元組流(class文件本質上是一組二進位位元組流).
2、將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構.
3、在Java堆中生成一個代表這個類的Class對象作為對方法區中這些數據的訪問入口.
說到載入就不得不提類載入器,在java語言中的類載入器有以下幾種:
當一個類載入器收到了類載入的請求後,它首先不會自己去嘗試載入這個類,而是把請求委托給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜索範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類,我們將這種工作流程稱之為類載入器的雙親委派模型.
雙親委派模型的好處是類隨著它的類載入器一起具備了一種帶有優先順序的層次關係,這對於保證Java程式的穩定運作很重要.我們都說java語言是面向對象的語言,一切皆對象,所有類的父類都是Object類,這是為什麼呢?就是因為Object類是頂層啟動類載入器載入的(jre\lib下的rt.jar),所有子類在嘗試載入Obejct父類的時候都會委托給啟動類載入器,保證了所有類的父類都是同一個Object對象!
另外載入階段也是開發人員可控性最強的階段,我們可以自定義載入器載入類,也可以使用JDK提供的載入器來動態載入代碼,比較典型的是JDBC創建資料庫連接的時候,都要調用Class.forName()方法來載入驅動類,另外我們也可以在代碼中調用this.getClass().getClassLoader()方法來載入類,需要註意的是這樣獲取的類載入器是應用程式類載入器.
驗證
驗證的目的是為了確保Class文件中的位元組流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全.不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、位元組碼驗證和符號引用驗證.
準備
準備階段是正式為類變數分配記憶體並設置類變數初始值的階段,這些記憶體都將在方法區中分配.
對於該階段有以下幾點需要註意:
1、這時候進行記憶體分配的僅包括類變數(static),而不包括實例變數,實例變數會在對象實例化時隨著對象一塊分配在Java堆中.
2、這裡所設置的初始值通常情況下是數據類型預設的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予的值.
下表列出了Java中所有基本數據類型以及reference類型的預設零值:
另外需要註意的是,對於常量,在該階段會直接賦值為代碼中設定的值.
解析
解析階段是虛擬機將常量池中的符號引用轉化為直接引用的過程.所謂符號引用,可以大致歸結為以下三種:
類和介面的全限定名(即帶有包名的Class名,如:org.lxh.test.TestClass)
欄位的名稱和描述符(private、static等描述符)
方法的名稱和描述符(private、static等描述符)
而所謂直接引用,簡單理解就是直接指向引用對象,我們知道,主流有兩種訪問對象地址的方式,直接指針和句柄,因此這裡的直接引用既可以是直接指針,也可以是句柄.
初始化
初始化是類載入過程的最後一步,到了此階段,才真正開始執行類中定義的Java程式代碼.在準備階段,類變數已經被賦過一次各數據類型的預設零值,而在初始化階段,則是根據程式員通過程式指定的值去初始化類變數和其他資源,或者可以從另一個角度來表達:初始化階段是執行類構造器clinit()方法的過程.這裡簡單說明下clinit()方法的執行規則:
1.clinit()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問.
2.clinit()方法與實例構造器init()方法(類的構造函數)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的clinit()方法執行之前,父類的clinit()方法已經執行完畢.因此,在虛擬機中第一個被執行的clinit()方法的類肯定是java.lang.Object。
3.clinit()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成clinit()方法。
4.介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會生成clinit()方法.但是介面與類不同的是:執行介面的clinit()方法不需要先執行父介面的clinit()方法,只有當父介面中定義的變數被使用時,父介面才會被初始化.另外,介面的實現類在初始化時也一樣不會執行介面的clinit()方法.
5.虛擬機會保證一個類的clinit()方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的clinit()方法,其他線程都需要阻塞等待,直到活動線程執行clinit()方法完畢.如果在一個類的clinit()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隱蔽的.
其中對我們最有幫助的也是需要我們記憶的是第一點,明白了這一點,就能清晰的知道變數的載入順序,再結合類初始化的規則,就能清晰的明白一個類的載入過程,那麼java類什麼時候需要初始化?
Java類什麼時候需要初始化
Java虛擬機規範中並沒有強制要求什麼情況下需要開始類載入過程.但是對於初始化階段,虛擬機規範則嚴格規定了有且僅有5種情況必須立即對類進行“初始化”(而載入,驗證,準備自然需要在此之前開始):
1.遇到new,getstatic,putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化.生成這四條指令單最常見的Java代碼場景是:使用new關鍵字實例化對象的時候,讀取或設置一個類的靜態欄位(被final修飾,已在編譯期把結果放入常量池的靜態欄位除外)的時候,以及調用一個類的靜態方法的時候.
2.使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化.
3.當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化.
4.當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類.
5.當使用JDK1.7的動態語言支持時,如果一個Java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic,REF_outStatic,REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化.
那麼讓我們來看一個實際的例子來理解類載入機制:
class Father{
public static int a = 10;
static {
a= 20;
System.out.println("觸發了father的載入");
}
}
class Children extends Father{
public static int b = a;
static {
System.out.println("觸發了children的載入");
}
}
public class TestClassLoad {
public static void main(String[] args) {
System.out.println(Children.b);
}
}
讓我們來分析一下這段代碼的載入過程:
1.首先,根據 java類什麼時候需要初始化中的規則第4條(當虛擬機啟動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類),虛擬機會先初始化TestClassLoad類.
2.main方法中列印Children.b的值,根據 java類什麼時候需要初始化中的規則第1條(這裡是遇到getstatic位元組碼時),要觸發Children類的初始化.
3.再根據java類什麼時候需要初始化中的規則第3條(當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化),會先觸發Father類的初始化.
4.觸發Father類的初始化,根據類載入過程的準備階段,首先會為靜態變數a分配記憶體並且設置預設值,從上文我們知道預設值為0,再執行到初始化階段,根據clinit()方法的執行規則1,根據代碼順序會先將a設置為10,再執行static靜態代碼塊中的語句,將a賦值為20,並且列印日誌:觸發father的載入.
5.繼續執行chidren的載入,此時按照我上述的分析過程,最後得到的結果是會將b的值賦值為20,因此最終的執行結果應該是:
觸發了father的載入
觸發了children的載入
20
讓我們執行來驗證一下:
結果和我們分析的結果一樣,說明我們的分析過程是完全正確的.假如我們在main方法中列印Children.a的值會如何?再假如我們把Father類中的靜態變數和static代碼塊的順序交換一下結果又如何呢?感興趣的童鞋可以自己分析下.
總結
閱讀完本篇文章後,我想童鞋們應該明白了以下幾點:
1.類載入過程.
2.多態的底層實現:靜態分派和動態分派.
3.java是如何保證所有類的父類都是同一個Object類的(雙親委派模型).
4.java類什麼時候需要初始化
其實,童鞋們回憶一下,是否曾經出去找工作筆試的時候,經常會遇到各種父類子類的static賦值,然後問我們最後的執行結果是什麼的啊?如果我們沒有弄明白類載入機制,就算答對了也是蒙對的,在看完了這篇文章弄明白了類載入機制之後,童鞋們是否有一種拍大腿叫絕的感覺:啊,我終於弄明白類載入機制了,以後筆試再遇到類似的題,媽媽再也不怕我蒙錯了!因為經過我們的分析,完全能得出準確的正確答案!
本文我們深入瞭解了類載入機制,下一篇文章,讓我們來學習GC,敬請期待!
如果覺得博主寫的不錯,歡迎關註博主微信公眾號,博主會不定期分享技術乾貨!
本文由博客一文多發平臺 OpenWrite 發佈!