從JVM看類的加載過程與對象實例化過程

一. 類的加載過程

1. 類的加載過程大致是個什麼過程?

我們編寫產生.java文件,這些.java文件經過Java編譯器編譯成拓展名爲.class的文件,.class文件中保存着Java代碼經轉換後的虛擬機指令,我們需要將類的.class文件通過類加載器加載成爲二進制流進入內存,即JVM運行時數據區中的方法區中成爲一種數據結構。然後,在運行時數據區中(沒有明確規定是在堆中)生成一個java.lang.Class對象,這個對象用來封裝加載到方法區的類的數據結構,便於向Java開發者提供用來訪問方法區中類的數據結構的接口。

再來先放一張刻在Java程序員DNA裏的圖便於直觀的討論
在這裏插入圖片描述

2. 類的具體加載過程

2.1 加載(Loading)

  • 通過一個類的全限定名來獲取定義此類的二進制字節流(並沒有指明要從一個Class文件中獲取,可以從其他渠道,譬如:網絡、動態生成、數據庫等)
  • 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構
  • 在內存中(對於HotSpot虛擬就而言就是方法區)生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口

2.2 驗證(Verification)

驗證是連接階段的第一步,這一過程的目的是爲了確保.Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。 驗證階段大致會完成4個階段的檢驗動作:

  • 文件格式驗證:驗證字節流是否符合.Class文件格式的規範(例如,是否以魔術0xCAFEBABE開頭(cafe babe咖啡寶貝,就問你夠不夠騷氣)、主次版本號是否在當前虛擬機的處理範圍之內、常量池中的常量是否有不被支持的類型)
  • 元數據驗證:對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範的要求(例如:這個類是否有父類,除了java.lang.Object之外)
  • 字節碼驗證:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的
  • 符號引用驗證:確保解析動作能正確執行

一個正兒八經的.class文件字節碼格式要求如下,以下數據項的數量和順序都是嚴格限制死的
u2,u4,u8分別對應2字節,4字節,8字節無符號數
_info結尾表明該數據是表形式

類型 名稱 數量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interface_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods method_count
u2 attributes_count 1
attribute_info attributes attributes_count

2.3 準備(Preparation)

準備階段是正式爲類變量(static 成員變量)分配內存並設置類變量初始值(零值)的階段,是連接階段的第二步,這些變量所使用的內存都將在方法區中進行分配。這時候進行內存分配的僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在堆中。其次,這裏所說的初始值“通常情況”下是數據類型的零值。
假設一個類變量的定義爲: public static int value = 123;
那麼,變量value在準備階段過後的值爲0而不是123
因爲這時候尚未開始執行任何Java方法,而把value賦值爲123的 public static指令是在程序編譯後,存放於類構造器<clinit>()方法之中的,所以把value賦值爲123的動作將在初始化階段纔會執行。

注意:如果是static final類型的常量屬性,它會被直接賦予所給定的初始值
假設一個常量定義爲:public static final int constValue = 123;
那麼,變量constValue準備階段後的值就爲123

原因是在加載生成.class字節碼文件時,在attributes屬性表中有一個ConstantValue屬性類型,如果類變量是static final修飾的常量,就會生成一個ConstantValue屬性來進行初始化。

2.4 解析(Resolution)

解析階段連接的最後一部,也是虛擬機將常量池內的符號引用替換爲直接引用的過程。解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符7類符號引用進行。

2.5 初始化(Initialization)

類初始化階段是類加載過程的最後一步。在前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼(字節碼)。
在準備階段,變量已經賦過一次系統要求的初始值(零值);而在初始化階段,則根據開發者通過程序制定的主觀計劃去初始化類變量和其他資源,或者更直接地說:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊static{}中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變量。
(PS:類構造器使用<clinit>()進行初始化,而實例構造器使用的是<init>()方法

注意:如果該類的直接父類還沒有被初始化,那麼先初始化其直接父類,也就是先執行父類的<clinit>方法
在這裏插入圖片描述

3.類的加載器

在虛擬機提供了三種類加載器:

  • 啓動(Bootstrap)類加載器
  • 擴展(Extension)類加載器
  • 系統(System)類加載器(也稱應用類加載器)

3.1 啓動類加載器(Bootstrap)

當我們每天打開自己的電腦時,第一個運行的就是我們的引導程序,即Bootstrap,所以看這加載器的名字就知道,是一個非常底層的類加載器,它主要加載的是JVM自身需要的類,這個類是由C++實現的,是虛擬機自身的一部分。

它負責將 <JAVA_HOME>/lib路徑下的核心類庫或-Xbootclasspath參數指定的路徑下的jar包加載到內存中,注意必由於虛擬機是按照文件名識別加載jar包的,如rt.jar,如果文件名不被虛擬機識別,即使把jar包丟到lib目錄下也是沒有作用的,出於安全考慮,Bootstrap啓動類加載器只加載包名爲java、javax、sun等開頭的類。

啓動類加載器是無法被Java程序直接引用的。

3.2 擴展類加載器(Extension)

該加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載 JDK\jre\lib\ext目錄中,或者由 java.ext.dirs系統變量指定的路徑中的所有類庫(如javax.開頭的類),開發者可以直接使用擴展類加載器。

3.3 系統類加載器(System/Application)

該類加載器由 sun.misc.Launcher$AppClassLoader來實現,它負責加載系統類路徑java -classpath-D java.class.path指定路徑下的類庫,也就是我們經常用到的classpath路徑,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

注意:
應用程序都是由這三種類加載器互相配合進行加載的,如果有必要,我們還可以加入自定義的類加載器。
Java虛擬機對class文件採用的是按需加載的方式,也就是說當需要使用該類時纔會將它的class文件加載到內存生成class對象,而且加載某個類的class文件時,Java虛擬機採用的是雙親委派模式即把請求交由父類處理,它一種任務委派模式,下面我們進一步瞭解它。

在這裏插入圖片描述

3.4 雙親委派模型

雙親委派模型的工作流程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委託給父加載器去完成,依次向上,因此,所有的類加載請求最終都應該被傳遞到頂層的啓動類加載器中,只有當父加載器在它的搜索範圍中沒有找到所需的類時,即無法完成該加載,子加載器纔會嘗試自己去加載該類。

注意不同層的類加載器不是繼承關係,而是通過組合實現類加載器調用上一級加載功能。

雙親委派機制:

1、當 AppClassLoader加載一個class時,它首先不會自己去嘗試加載這個類,而是把類加載請求委派給父類加載器ExtClassLoader去完成。

2、當 ExtClassLoader加載一個class時,它首先也不會自己去嘗試加載這個類,而是把類加載請求委派給BootStrapClassLoader去完成。

3、如果 BootStrapClassLoader加載失敗(例如在 $JAVA_HOME/jre/lib裏未查找到該class),會使用 ExtClassLoader來嘗試加載;

4、若ExtClassLoader也加載失敗,則會使用 AppClassLoader來加載,如果 AppClassLoader也加載失敗,則會報出異常 ClassNotFoundException

實現代碼:

protected sychronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
	//檢查請求的類是否已經被加載
	Class c = findLoadedClass(name);
	if(c == null){
		try{
			if(parent != null){
				c = parent.loadClass(name, false);
			}
			else{
				c = findBootstrapClassOrNull(name);
			}
		}
		catch(ClassNotFoundException e){
			//父類無法完成加載
		}
		if(c == null){
			//父類無法加載,調用本身的findClass方法來進行類加載
			c = findClass(name);
		}
	}
	if(resolve){
		resolveClass(c);
	}
	return c;
}

二. 創建對象的過程

當一個對象被創建時,虛擬機就會爲其在中分配內存來存放對象自己的實例變量及其從父類繼承過來的實例變量(即使這些從超類繼承過來的實例變量有可能被隱藏但也會被分配空間)。在爲這些實例變量分配內存的同時,這些實例變量也會被賦予默認值(零值)。在內存分配完成之後,Java虛擬機就會開始對新創建的對象按照程序猿的意志進行初始化。在Java對象初始化過程中,主要涉及三種執行對象初始化的結構,分別是 實例變量初始化、實例代碼塊初始化 以及 構造函數初始化。(從父類到子類)
一個對象創建過程的僞代碼,instance = new Singleton();

memory = allocate(); //1.分配內存空間
ctorInstance(memory); //2.初始化對象
instance = memory; //3.設置instance指向剛分配的內存地址

由於JVM的重排序,又可能步驟2和步驟3的過程互換。

  • Java要求在實例化類之前,必須先實例化其超類,以保證所創建實例的完整性。事實上,這一點是在構造函數中保證的:Java強制要求Object對象(Object是Java的頂層對象,沒有超類)之外的所有對象構造函數的第一條語句必須是超類構造函數的調用語句或者是類中定義的其他的構造函數,如果我們既沒有調用其他的構造函數,也沒有顯式調用超類的構造函數,那麼編譯器會爲我們自動生成一個對超類構造函數的調用

  • 我們在定義(聲明)實例變量的同時,還可以直接對實例變量進行賦值或者使用普通代碼塊對其進行賦值。如果我們以這兩種方式爲實例變量進行初始化,那麼它們將在構造函數執行之前完成這些初始化操作。
    實際上,如果我們對實例變量直接賦值或者使用實例代碼塊賦值,那麼編譯器會將其中的代碼放到類的構造函數中去,並且這些代碼會被放在對超類構造函數的調用語句之後,構造函數本身的代碼之前。所以從最終的初始化順序來看,這些初始化操作在構造器之前完成。

  • 類構造器<clinit>()與實例構造器<init>()不同,它不需要程序員進行顯式調用,虛擬機會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。由於父類的構造器<clinit>()先執行,也就意味着父類中定義的靜態代碼塊/靜態變量的初始化要優先於子類的靜態代碼塊/靜態變量的初始化執行。
    特別地,類構造器<clinit>()對於類或者接口來說並不是必需的,如果一個類中沒有靜態代碼塊,也沒有對類變量的賦值操作,那麼編譯器可以不爲這個類生產類構造器<clinit>()。此外,在同一個類加載器下,一個類只會被初始化一次,但是一個類可以任意地實例化對象。也就是說,在一個類的生命週期中,類構造器<clinit>()最多會被虛擬機調用一次,而實例構造器<init>()則會被虛擬機調用多次,只要程序員還在創建對象。

  • 一個實例變量在對象初始化的過程中會被賦值幾次?
    我們知道,JVM在爲一個對象在堆中分配完內存之後,會給每一個實例變量賦予默認零值,這個時候實例變量被第一次賦值,這個賦值過程是沒有辦法避免的。如果我們在聲明實例變量x的同時對其進行了賦值操作,那麼這個時候,這個實例變量就被第二次賦值了。如果我們在實例代碼塊中,又對變量x做了初始化操作,那麼這個時候,這個實例變量就被第三次賦值了。如果我們在構造函數中,也對變量x做了初始化操作,那麼這個時候,變量x就被第四次賦值。也就是說,在Java的對象初始化過程中,一個實例變量最多可以被初始化4次。當然了,一般不會這樣做,除非特殊情況。

總的來說就是:父類的類構造器<clinit>() ->子類的類構造器<clinit>() -> 父類的成員變量和實例代碼塊 -> 父類的構造函數 -> 子類的成員變量和實例代碼塊 -> 子類的構造函數
在這裏插入圖片描述

有關Java靜態域、塊非靜態域、塊構造函數的初始化順序?
對於靜態變量、靜態初始化塊、變量、初始化塊、構造器,它們的初始化順序以此是(靜態變量、靜態初始化塊)>(實例變量、初始化塊)> 構造器。靜態代碼塊是在類加載時自動執行的,非靜態代碼塊是在創建對象時自動執行的代碼,不創建對象不執行該類的非靜態代碼塊。

靜態代碼塊 與 靜態方法?
一般情況下,如果有些代碼必須在項目啓動的時候就執行的時候,需要使用靜態代碼塊,這種代碼是主動執行的;需要在項目啓動的時候就初始化,在不創建對象的情況下,其他程序來調用的時候,需要使用靜態方法,這種代碼是被動執行的。
兩者的區別就是:靜態代碼塊是自動執行的;靜態方法是被調用的時候才執行的。
作用:靜態代碼塊可用來初始化一些項目最常用的變量或對象;靜態方法可用作不創建對象也可能需要執行的代碼;

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章