JVM原理及調優(4)——類載入機制

白水不開發表於2016-11-21

系列文章規劃:

  1. JVM原理及調優(1)——記憶體模型
  2. JVM原理及調優(2)——記憶體管理
  3. JVM原理及調優(3)——編譯機制
  4. JVM原理及調優(4)——類載入機制
  5. JVM原理及調優(5)——垃圾回收和調優
  6. JVM原理及調優(6)——G1收集器及G1日誌分析
  7. JVM原理及調優(7)——JDK常用內建工具

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝七個階段。

類載入的過程包括了載入、驗證、準備、解析、初始化五個階段。在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始,這是為了支援 Java 語言的執行時繫結(也成為動態繫結或晚期繫結)。另外注意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中呼叫或啟用另一個階段。

Java 中的繫結指的是把一個方法的呼叫與方法所在的類(方法主體)關聯起來,對 Java 來說,繫結分為靜態繫結和動態繫結:
* 靜態繫結:即前期繫結。在程式執行前方法已經被繫結,此時由編譯器或其它連線程式實現。針對 Java,簡單的可以理解為程式編譯期的繫結。Java 當中的方法只有 final,static,private 和構造方法是前期繫結的。
* 動態繫結:即晚期繫結,也叫執行時繫結。在執行時根據具體物件的型別進行繫結。在 Java 中,幾乎所有的方法都是後期繫結的。

1. 載入

載入是使用類載入器將JVM外部的二進位制位元組碼讀取到JVM內部,並按照JVM所需的格式儲存到方法區中,同時在堆中建立一個java.lang.Class物件,以便可以通過該物件訪問方法區中的這些資料。

從JVM角度看,只存在兩種類載入器:

  • 啟動類載入器:它使用 C++ 實現(這裡僅限於 Hotspot,也就是 JDK1.5 之後預設的虛擬機器,有很多其他的虛擬機器是用 Java 語言實現的),是虛擬機器自身的一部分。
  • 所有其他的類載入器:這些類載入器都由 Java 語言實現,獨立於虛擬機器之外,並且全部繼承自抽象類 java.lang.ClassLoader,這些類載入器需要由啟動類載入器載入到記憶體中之後才能去載入其他的類。

但從Java開發人員看,類載入器可大致分為3類:

  • Bootstrap ClassLoader(啟動類載入器)。跟上面相同,負責載入存放在$JDK_HOME\jre\lib下,或被-Xbootclasspath引數指定的路徑中的,並且能被虛擬機器識別的類庫(如 rt.jar,所有的java.*開頭的類均被 Bootstrap ClassLoader 載入)。啟動類載入器是無法被 Java 程式直接引用的。
  • Extension ClassLoader(擴充套件類載入器)。該載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入$JDK_HOME\jre\lib\ext目錄中,或者由 java.ext.dirs 系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴充套件類載入器。
  • Application ClassLoader(應用程式類載入器)。該類載入器由 sun.misc.Launcher$AppClassLoader 來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。

應用程式都是由這三種類載入器互相配合進行載入的,如果有必要,我們還可以加入自定義的類載入器。因為 JVM 自帶的 ClassLoader 只是懂得從本地檔案系統載入標準的 java class 檔案,因此如果編寫了自己的 ClassLoader,便可以做到如下幾點:

  • 在執行非置信程式碼之前,自動驗證數字簽名。
  • 動態地建立符合使用者特定需要的定製化構建類。
  • 從特定的場所取得 java class,例如資料庫中和網路中。

對於任意一個類,都需要由它的類載入器和這個類本身一同確定其在就 Java 虛擬機器中的唯一性,也就是說,即使兩個類來源於同一個 Class 檔案,只要載入它們的類載入器不同,那這兩個類就必定不相等。這裡的“相等”包括了代表類的 Class 物件的 equals()、isAssignableFrom()、isInstance()等方法的返回結果,也包括了使用 instanceof 關鍵字對物件所屬關係的判定結果。

類的層次關係和載入順序可以由下圖來描述:

這裡寫圖片描述

這種層次關係稱為類載入器的雙親委派模型。我們把每一層上面的類載入器叫做當前層類載入器的父載入器,當然,它們之間的父子關係並不是通過繼承關係來實現的,而是使用組合關係來複用父載入器中的程式碼。

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

2. 驗證

驗證的目的是為了確保 Class 檔案中的位元組流包含的資訊符合當前虛擬機器的要求,而且不會危害虛擬機器自身的安全。不同的虛擬機器對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:

  • 檔案格式的驗證:驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理,該驗證的主要目的是保證輸入的位元組流能正確地解析並儲存於方法區之內。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行儲存,後面的三個驗證都是基於方法區的儲存結構進行的。
  • 後設資料驗證:對類的後設資料資訊進行語義校驗(其實就是對類中的各資料型別進行語法校驗),保證不存在不符合 Java 語法規範的後設資料資訊。
  • 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在執行時不會做出危害虛擬機器安全的行為。
  • 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符號引用)進行匹配性的校驗。

3. 準備

準備階段是正式為類變數(僅static型別)分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配。對於該階段有以下幾點需要注意:

  • 僅為static類變數分配記憶體,不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在堆中。
  • 通常情況下,設定的初始值是資料型別預設的零值(如 0、0L、null、false 等),而不是被在Java程式碼中被顯式地賦予的值。

這裡寫圖片描述

需要注意如下幾點:

  • 對基本資料型別來說,對於類變數(static)和全域性變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予預設的零值,而對於區域性變數來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
  • 對於同時被 static 和 final 修飾的常量,必須在宣告的時候就為其顯式地賦值,否則編譯時不通過;
  • 對於同時被 static 和 final 修飾的常量(ConstantValue 屬性),在準備階段變數 value 就會被初始化為 ConstValue 屬性所指定的值。
  • 只被 final 修飾的常量則既可以在宣告時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予預設零值。
  • 對於引用資料型別 reference 來說,如陣列引用、物件引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予預設的零值,即null。
  • 如果在陣列初始化時沒有對陣列中的各元素賦值,那麼其中的元素將根據對應的資料型別而被賦予預設的零值。
  • 如果類欄位的欄位屬性表中存在 ConstantValue 屬性,即同時被 final 和 static 修飾,那麼在準備階段變數 value 就會被初始化為 ConstValue 屬性所指定的值。

4. 解析

解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行,分別對應於常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四種常量型別。

  • 類或介面的解析:判斷所要轉化成的直接引用是對陣列型別,還是普通的物件型別的引用,從而進行不同的解析。
  • 欄位解析:對欄位進行解析時,會先在本類中查詢是否包含有簡單名稱和欄位描述符都與目標相匹配的欄位,如果有,則查詢結束;如果沒有,則會按照繼承關係從上往下遞迴搜尋該類所實現的各個介面和它們的父介面,還沒有,則按照繼承關係從上往下遞迴搜尋其父類,直至查詢結束。
  • 類方法解析:對類方法的解析與對欄位解析的搜尋步驟差不多,只是多了判斷該方法所處的是類還是介面的步驟,而且對類方法的匹配搜尋,是先搜尋父類,再搜尋介面。
  • 介面方法解析:與類方法解析步驟類似,只是介面不會有父類,因此,只遞迴向上搜尋父介面就行了。

5. 初始化

類初始化是類載入過程的最後一個階段,到初始化階段,才真正開始執行類中的 Java 程式程式碼。在準備階段,類變數已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程式設計師通過程式指定的主觀計劃去初始化類變數和其他資源,或者可以從另一個角度來表達:初始化階段是執行類構造器()方法的過程。

簡單說明下()方法的執行規則:

  1. ()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問。
  2. ()方法與例項構造器()方法(類的建構函式)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的()方法執行之前,父類的()方法已經執行完畢。因此,在虛擬機器中第一個被執行的()方法的類肯定是java.lang.Object。
  3. ()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成()方法。
  4. 介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會生成()方法。但是介面和類不同的是:執行介面的()方法不需要先執行父介面的()方法,只有當父介面中定義的變數被使用時,父介面才會被初始化。另外,介面的實現類在初始化時也一樣不會執行介面的()方法。
  5. 虛擬機器會保證一個類的()方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行()方法完畢。如果在一個類的()方法中有耗時很長的操作,那就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是很隱蔽的。

虛擬機器規範嚴格規定了有且只有四種情況必須立即對類進行初始化:

  • 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時,如果類還沒有進行過初始化,則需要先觸發其初始化。生成這四條指令最常見的 Java 程式碼場景是:使用 new 關鍵字例項化物件時、讀取或設定一個類的靜態欄位(static)時(被 static 修飾又被 final 修飾的,已在編譯期把結果放入常量池的靜態欄位除外)、以及呼叫一個類的靜態方法時。
  • 使用 Java.lang.refect 包的方法對類進行反射呼叫時,如果類還沒有進行過初始化,則需要先觸發其初始化。
  • 當初始化一個類的時候,如果發現其父類還沒有進行初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類,虛擬機器會先執行該主類。

虛擬機器規定只有這四種情況才會觸發類的初始化,稱為對一個類進行主動引用,除此之外所有引用類的方式都不會觸發其初始化,稱為被動引用。

對於靜態欄位(static),只有直接定義這個欄位的類才會被初始化,因此,通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。

常量(static final)在編譯階段會存入呼叫它的類的常量池中,本質上沒有直接引用到定義該常量的類,因此不會觸發定義常量的類的初始化。

參考文獻

  1. 類載入機制
  2. 類初始化

相關文章