三-類的載入過程詳解

蓋大大發表於2020-11-30

3.1 概述

  • Java中資料型別分為基本資料型別和引用資料型別。基本資料型別由虛擬機器預先定義,引用資料型別則需要進行類的載入
  • 從class檔案到載入到記憶體中的類,到類解除安裝出記憶體為止,整個生命週期
    生命週期
    從程式中類的使用過程看:

概述

大廠面試題:
描述JVM載入Class檔案的原理機制
java類載入過程
類載入的時機

3.2 過程一:Loading(載入)階段

3.2.1 載入完成的操作

  • 所謂載入,就是講Java類的位元組碼檔案載入到機器記憶體中,並在記憶體中構建出Java類的原型–類别範本物件。所謂類别範本物件,就是Java類在JVM記憶體中的一個快照,JVM講位元組碼檔案中解析出的常量池、類欄位、類方法等資訊儲存到類别範本中,這樣JVM在執行期便能通過類别範本而獲取Java類中的任何資訊,能對Java類的成員變數進行遍歷,也能進行Java方法的呼叫
  • 反射的機制即基於這一基礎。如果JVM沒有講Java類的宣告資訊儲存起來,則JVM在執行期也無法反射
  • -XX:+TraceClassLoading可以追蹤類的載入資訊並列印出來

載入完成的操作

載入階段,簡言之,就是查詢並載入類的二進位制資料,生成Class的例項

在載入類時,Java虛擬機器完成以下事情:

  1. 通過類的全名,獲取類的二進位制資料流
  2. 解析類的二進位制資料流為方法區的資料結構(Java類模型)
  3. 建立java.lang.Class類的例項,表示該型別。作為方法區這個類的各個資料的訪問入口

3.2.2 二進位制流的獲取方式

對於類的二進位制資料流,虛擬機器可以通過多種途徑產生或獲得

  • 虛擬機器可能通過檔案系統讀入一個class字尾的檔案(最常見)‘
  • 讀入jar、zip等歸檔資料包,提取類檔案
  • 事先存放在資料庫中的類的二進位制資料
  • 通過類似於HTTP之類的協議通過網路進行載入
  • 在執行生成一段class的二進位制資訊等

在獲取類的二進位制資訊後,Java虛擬機器就會處理這些資料,並最終轉為一個java.lang.Class的例項

如果輸入不是ClassFile結構,會丟擲ClassFormatError

3.2.3 類模型與Class例項的位置

類模型的位置

載入的類在JVM中建立相應的類結構,類結構會儲存在方法區(JDK8前:永久代,JDK8後:元空間)

Class例項的位置

類講.class檔案載入到元空間後,會在堆中建立一個java.lang.Class物件,用來封裝類位於方法區內的資料結構,該Class物件是在載入類的過程中建立的,每個類都對應有一個Class型別的物件
外部圖示
外部可以通過訪問代表Order類的Class物件來獲取Order的類資料結果

Class類的構造器是私有的,只有JVM能夠建立
java.lang.Class例項時訪問型別後設資料的介面,也是實現反射的關鍵資料、入口。通過Class類提供的結構,可以獲得目標類所關聯的.class檔案中具體的資料結構:方法、欄位等資訊。

3.2.4 陣列類的載入

  • 建立資料類的情況有些特殊,因為陣列類本身並不是由類載入器負責建立,而是由JVM在執行時根據需要而直接建立的,但陣列的元素型別依然依靠類載入器去建立。建立資料類(A)的過程:
  • 如果陣列的元素型別是引用型別,那麼就遵循定義的載入過程遞迴載入和建立陣列A的元素型別;
  • JVM使用指定的元素型別和陣列維度來建立新的陣列類

如果陣列的元素型別是引用型別,陣列類的可訪問性就由元素型別的可訪問性決定。否則陣列類的可訪問性被預設定義為public

3.3 過程二:Linking(連結)階段

3.3.1 連結階段之驗證(Verification)

  • 當類載入到系統後,就開始連結操作,驗證是連結的第一步,目的是保證載入的位元組碼是合法、合理並符合規範的

驗證
1.格式驗證:

  • 是否以魔數OxCAFEBABE開頭,主版本和副版本號是否在當前Java虛擬機器的支援範圍內,資料中每一項是否都擁有正確的長度等
  • 格式驗證會和載入階段一起執行。驗證通過後,類載入器才會成功將類的二進位制資料資訊載入到方法區中
  • 格式驗證之外的驗證操作都會在方法區中進行。連結階段的驗證雖然拖慢了載入速度,但避免了在位元組碼執行時還需檢查的時間

2.語義檢查:

  • 但凡在語義上不符合規範的,虛擬機器都不會予以驗證通過,比如:
  • 是否所有的類都有父類
  • 是否一些被定義為final的方法或類被重寫或繼承了
  • 非抽象類是否實現了所有抽象方法或者介面方法
  • 是否存在不相容的方法(abstract下的方法,就不能是final的了)

3.位元組碼驗證:

  • 最複雜的一個過程,他試圖通過對位元組碼流的分析,判斷位元組碼是否可以被正確地執行,比如:
  • 在位元組碼的執行過程中,是否會跳轉到一條不存在的指令
  • 函式的呼叫是否傳遞了正確型別的引數
  • 變數的賦值是不是給了正確的資料型別等
  • 棧對映幀(StackMapTable)就是在這個階段,用於檢測在特定的位元組碼處,其區域性變數表和運算元棧是否有正確的資料型別

前面3次檢查中,已排除了檔案格式錯誤、語義錯誤、位元組碼不正確性,但依然不能確保類是沒有問題的

4.符號引用驗證:

  • Class檔案在其常量池會通過字串記錄自己將要使用的其他類或方法。因此,在驗證階段,虛擬機器就會檢查這些類或方法確實是存在的,並且當前類有權訪問這些資料。如果一個需要使用類無法在系統中找到,則會拋NoClassDefFoundError,如果一個方法無法被找到,則拋NoSuchMethodError
  • 此階段在解析環節才會執行

3.3.2 連結階段之準備(Preparation)

  • 簡言之,為類的靜態變數分配記憶體,並將其初始化為預設值
  • 當一個類驗證通過時,虛擬機器就會進入準備階段。在這個階段,虛擬機器就會為這個類分配響應的記憶體空間,並設定預設初始值
型別預設初始值
byte(byte)0
short(short)0
int0
long0L
float0.0f
double0.0
char\u0000
boolean0
referencenull
/**
* 基本資料型別,非final修飾的變數,在準備環節進行預設初始化賦值
*               final修飾後,在準備環節直接進行顯示賦值
* 如果使用字面量的方式,定義一個字串的常量,也是在解析環節直接進行顯示賦值
*/
public class LinkingTest {
    private static long id;
    private static final int num = 1;

    public static final String constStr = "CONST";
    public static final String constStr1 = new String("CONST");
}

3.3.3 連結階段之解析(Resolution)

  • 將類、介面、欄位和方法的符號引用轉為直接引用
  • 符號引用就是一些字面量的引用,和虛擬機器的內部資料結構和記憶體佈局無關。
  • 舉例:輸出操作System.out.println()對應的位元組碼

解析

  • 通過解析操作,符號引用就可以轉變為目標方法在類中方法表中的位置,從而使得方法被成功呼叫

3.4 過程三:Initialization(初始化)階段

  • 簡言之,為類的靜態變數賦予正確的初始值
  • 是類的最後一個階段,如果前面的步驟都沒有問題,那麼表示類可以順利裝載到系統中。此時,類才會開始執行Java程式碼

最重要的工作是執行類的初始化方法:<clinit.>()方法
1.此方法僅能由Java編譯器生成並由JVM呼叫,程式開發中無法自定義一個同名的方法,更無法直接從Java程式中呼叫該方法,雖然該方法也是由位元組碼指令組成
2.他是由類靜態成員的賦值語句以及static語句塊合併產生的
3.父類的<clinit.>總是在子類<clinit.>之前被呼叫

/**
 *
 * 哪些場景下,Java 編譯器就不會生成<clinit>()方法
 */
public class InitializationTest1 {
  //場景1:對應非靜態的欄位,不管是否進行了顯式賦值,都不會生成<clinit>()方法
  public int num = 1;
  //場景2:靜態的欄位,沒有顯式的賦值,不會生成<clinit>()方法
  public static int num1;
  //場景3:比如對於宣告為 static final 的基本資料型別的欄位,不管是否進行了顯式賦值,都不會生成<clinit>()方法
  public static final int num2 = 1;
}

3.4.1 static與final的搭配問題

/**
 *
 * 說明:使用 static + final 修飾的欄位的顯式賦值的操作,到底是在哪個階段進行的賦值?
 * 情況1:在連結階段的準備環節賦值
 * 情況2:在初始化階段<clinit>()中賦值
 *
 * 結論:
 * 在連結階段的準備環節賦值的情況:
 * 1. 對於基本資料型別的欄位來說,如果使用 static final 修飾,則顯式賦值(直接賦值常量,而非呼叫方法)通常是在連結階段的準備環節進行
 * 2. 對於 String 來說,如果使用字面量的方式賦值,使用 static final 修飾的話,則顯式賦值通常是在連結階段的準備環節進行
 *
 * 在初始化階段<clinit>()中賦值的情況
 * 排除上述的在準備環節賦值的情況之外的情況
 *
 * 最終結論:使用 static + final 修飾,且顯示賦值中不涉及到方法或構造器呼叫的基本資料型別或String型別的顯式賦值,是在連結階段的準備環節進行
 */
public class InitializationTest2 {
    public static int a = 1; //在初始化階段<clinit>()中賦值
    public static final int INT_CONSTANT = 10;  //在連結階段的準備環節賦值

    public static final Integer INTEGER_CONSTANT1 = Integer.valueOf(100);   //在初始化階段<clinit>()中賦值
    public static Integer INTEGER_CONSTANT2 = Integer.valueOf(1000); //在初始化階段<clinit>()中賦值

    public static final String s0 = "helloworld0"; //在連結階段的準備環節賦值
    public static final String s1 = new String("helloworld1"); //在初始化階段<clinit>()中賦值

}

3.4.2 <clinit.>()的執行緒安全性

  • 對於<clinit.>()方法的呼叫,也就是類的初始化,虛擬機器會在內部確保其多執行緒環境中的安全性
  • 在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類地<clinit.>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行完方法
  • 如果在一個類的<clinit.>()方法中有耗時很長的操作,就可能造成多個執行緒阻塞,引發死鎖。並且這種死鎖很難發現,因為看起來他們沒有可用的鎖資訊

3.4.3 類的初始化情況:主動使用vs被動使用

主動使用

Class只有在必須要首次使用的時候才會被裝載,Java虛擬機器不會無條件地裝載Class型別。一個類或介面在初始使用前,必須要進行初始化,主動使用只有幾種情況:

  1. 當建立一個類地例項時,比如使用new關鍵字,或者通過反射、克隆、反序列化
  2. 當呼叫類地靜態方法時,即當使用了位元組碼invokestatic指令
  3. 當使用類、介面的靜態欄位(final修飾符特殊考慮),比如getstatic或putstatic指令
  4. 當使用java.lang.reflect包中地方法反射類地方法時,比如Class.forName(" xxx.xxx.xxx")
  5. 當初始化子類時,如果發現其父類還沒有進行過初始化,則需要先觸發父類地初始化,不適用於介面。
  6. 如果一個介面定義了default方法,那麼直接實現或間接實現該介面的類的初始化,該介面要在其之前被初始化
  7. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類
  8. 當初此呼叫MethodHandle例項時,初始化該MethodHandle指向的方法所在的類

被動使用

除了以上的情況屬於主動使用,其他的情況都屬於被動使用。被動使用不會引起類的初始化

  1. 當訪問一個靜態欄位時,只有真正宣告這個欄位的類才會被初始化。當通過子類引用父類的靜態變數,不會導致子類初始化
  2. 通過陣列定義類引用,不會觸發此類的初始化
  3. 引用常量不會觸發此類或介面的初始化。因為常量在連結階段就已經被顯示賦值了
  4. 呼叫ClassLoader類的loadClass()方法載入一個類

3.5 過程四:類的Using(使用)

  • 任何一個型別在使用之前必須經過完整的載入、連結和初始化3個類載入步驟
  • 開發人員可以在程式中訪問和呼叫它的靜態類成員資訊(比如靜態欄位、靜態方法),或使用new為其建立物件例項

3.6 過程五:類的Unloading(解除安裝)

類、類的載入器、類的例項之間的引用關係

  • 在類載入器的內部實現中,用一個Java集合來存放所載入類的引用。另一方面,一個Class物件總是會引用它的類載入器,呼叫Class物件的getClassLoader()方法,就能獲得它地類載入器。由此可見,代表某個類地Class例項與其類地載入器之間為雙向關聯關係
  • 一個類的例項總是引用代表這個類的Class物件,在Object類種定義了getClass()方法,這個方法返回代表物件所屬類的Class物件的引用,此外,所有Java類都有一個靜態屬性class,它引用這個類的Class物件

類的生命週期

當Sample類被載入、連結和初始化後,它的生命週期就開始了,當代表Sample類的Class物件不再被引用,即不可觸及時,Class物件就會結束生命週期,Sample類在方法區內的資料也會被解除安裝,從而結束Sample類的生命週期

類的解除安裝
類的解除安裝

  1. 啟動類載入器載入的型別在整個執行期間是不可能被解除安裝的
  2. 被系統類載入器和擴充套件類載入器載入的型別在執行期間不太可能被解除安裝,系統類載入器例項或者擴充套件類的例項基本上在整個執行期間總能直接或間接地訪問到
  3. 被開發中自定義的類載入器例項載入的型別只有在很簡單的上下文環境中才能被解除安裝

綜上三點,一個已經載入地型別被解除安裝地機率很小被解除安裝的時間是不確定的

方法區的垃圾回收

方法區的垃圾收集主要回收兩部分內容:常量池廢棄的常量和不再使用的型別

HotSpot虛擬機器對常量池的回收策略時很明確的,只要常量池中的常量沒有被任何地方引用,就可以被回收

判定一個型別是否屬於“不再被使用的類”需要同時滿足下面三個條件:

  • 該類所有的例項都已經被回收。也就是Java堆中不存在該類及其派生子類的例項
  • 載入該類的類載入器已經被回收,這個條件除非時經過精心設計的可替換的類載入器的場景,如OSGi、JSP的重載入等,否則通常很難達成的
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法

相關文章