是時候瞭解一波虛擬機器的類載入機制

coderbruis發表於2018-12-02

程式語言發展的大步發展——程式碼編譯的結果,從本地機器碼變為位元組碼

從Java類到JVM執行Class檔案

Java類會被編譯為Class檔案,這裡,編譯的過程先不去具體瞭解,Class檔案中儲存的各種資訊,包括魔數、Class檔案的版本、常量池、訪問標誌、欄位表集合等等重要資訊,都需要被載入到JVM中之後才能執行和使用。

虛擬機器會將Class檔案中的描述類的資料載入到記憶體中,然後對資料進行校驗、轉化解析和初始化,最終得到虛擬機器能夠直接使用的Java型別。以上粗略的概括了一下虛擬機器的類載入機制。

Java執行期間類載入的特性

由於Java語言規定,型別的載入、連線和初始化過程都是在程式初始化期間完成的,這種策略會讓Java在類載入時稍微增加了一些效能開銷,但是,這樣卻利於Java在應用程式方面提供了高度的靈活性。另一方面,Java裡天生可以動態擴充套件的語言特性就是基於執行期間動態載入和動態連線的這個特點實現的。例如最基礎的Applet、JSP和OSGi。

類載入的時機

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體位置,它的整個生命週期包括:

  • 載入
  • 驗證
  • 準備
  • 解析
  • 初始化
  • 使用
  • 解除安裝

其中,驗證、準備和解析三部分部分是連線過程。

載入階段

那虛擬機器什麼時候開始類載入的第一個階段:載入?

答:Java虛擬機器規範中並沒有強制約束什麼時候進行載入,這點可以交由虛擬機器的具體實現來把握。這是因為虛擬機器設計團隊在載入階段搭建了一個相當開放的平臺,就是因為這樣一個相當開放的平臺,虛擬機器才不知道類載入的第一階段:載入 什麼時候發生,以及載入了什麼、如何載入。許多舉足輕重的Java技術就是建立在這一基礎之上的。 這一開放的平臺,衍生出了以下這些重要技術:

  • 從ZIP包中讀取,這技術就是JAR、EAR、WAR格式的基礎
  • 從網路中獲取,典型應用就是Applet
  • 執行時計算生成,這種場景使用最多的就是動態代理技術,在java.lang.reflect.Proxy中,就定義了許多代理工具類
  • 由其他檔案生成,典型的是JSP應用,JSP會被編譯為Class檔案

雖然不知道類載入的第一階段:載入 什麼時候開始,但是下面還是詳細瞭解一下第一階段的具體內容。在載入階段,虛擬機器會完成以下3件事情:

  1. 通過一個類的全限定類名來獲取定義此類的二進位制位元組流,也就是Class檔案,這一過程是通過類載入器來完成的。
  2. 將這個位元組流檔案所代表的的靜態儲存結構轉化為方法區的執行時資料結構。
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件,這個物件沒有明確實在堆記憶體中的,對於HotSpot而言,這個物件是放在方法區中的,作為方法區這個類的各種資料的訪問入口。

對於類的載入,有兩種型別:

  1. 非陣列類的載入 對於非陣列類的載入,準確來講就是在載入階段中獲取二進位制流的動作,這個動作是最易控制的,這是因為在載入階段既可以使用系統提供的引導載入器來完成載入工作,也可以實現自定義的載入器來完成,自定義的載入器只需要重寫一個類載入器的loadClass()方法。

  2. 陣列類的載入 陣列類的載入是不好控制的,因為陣列類本身不是通過類載入器來建立的,它是由Java虛擬機器本身直接建立的。具體的內容,這裡就不詳解了。

下面,有幾點需要特別注意的地方:

  1. 載入階段完成之後,虛擬機器外部的二進位制流會被JVM讀取,並且按照JVM所需要的格式在方法區中儲存,然後轉化為方法區的執行時資料結構,方法區中的儲存格式,是由虛擬機器自定義實現的。
  2. 二進位制流在方法區中儲存為執行時資料結構以後,需要有一個外部介面方便程式訪問,這時記憶體中會例項化一個java.lang.Class類的物件,特別要注意的是,這個類的物件,並沒有明確的規定是儲存再堆中的,而對於HotSpot而言,這個物件是儲存再方法區裡面的,這時為了方便程式訪問方法區中的型別資料。作用就是作為這些型別資料的外部介面
  3. 注意載入階段和連線階段,是交叉進行的,而不是依次進行的。
  4. 除了載入階段使用者可以通過自定義類載入器來參與類載入過程,其他的類載入節點都是有虛擬機器主導和控制的,這點需要注意。

連線階段:驗證

驗證是連線階段的第一步,目的是確保Class位元組流檔案中的資料內容符合虛擬機器的規範,不會危害到虛擬機器自身的安全。如果虛擬機器不對輸入的位元組流Class檔案作檢查,對其完全信任的話,虛擬機器很可能會因為載入了有害的位元組流檔案二導致系統崩潰。所以,驗證對於虛擬機器來說是一項很重要的工作。

從整體來看,驗證階段分為4個階段的驗證工作:

  1. 檔案格式驗證
  2. 後設資料驗證
  3. 位元組碼驗證
  4. 符號引用驗證

有效的檔案格式是啥?

對於JVM來說,任何一個擁有唯一一個類或介面的定義資訊的Class檔案,就是有效的檔案格式,這種有效的檔案格式又稱作為"Class檔案格式"。Class類檔案格式包括魔數、Class檔案的版本號、常量池、訪問標誌、欄位表集合等等。也就是說,這一階段可能包含下面這些驗證點:

  • 是否以魔數開頭
  • 主、次版本號是否在虛擬機器版本範圍之內
  • 常量池中的常量是否都合法
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8編碼的資料 ....

所以驗證階段的第一階段,就是要驗證輸入的位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。對檔案格式驗證並且驗證成功後,位元組流檔案就會進入記憶體的方法區中進行儲存。 總結一下,這一驗證階段幾個重要的點:

  1. 該驗證階段是基於二進位制位元組流進行的。
  2. 通過該驗證階段的二進位制位元組流檔案,會進入記憶體的方法區中進行儲存。
  3. 除此階段之外的其他三個階段,都是基於方法區的資料進行的,不會再直接操作位元組流。

後設資料驗證

這一階段就是對位元組碼描述的資訊進行語義分析,確保這些描述資訊符合Java的語言規範。驗證點可能包括如下:

  • 這個類是否有父介面(除了java.lang.Object類之外,所有的類都有父類)
  • 這個父類是否繼承了不可繼承的類(被final修飾的類) ...

位元組碼驗證

這一階段是最複雜的,是因為這一階段主要驗證程式語義是否合法的、符合邏輯的。這一階段是基於"後設資料驗證"的,待後設資料都合法之後,位元組碼驗證就對方法體內的程式語義進行驗證,保證方法體內的程式不會危害到虛擬機器的自身安全。常見的驗證點可能包括如下:

  • 在運算元棧中放置了一個int型的資料,但是使用時卻把一個long型的資料載入至本地變數表中
  • 保證跳轉指令不跳轉到方法體外的位元組碼指令上

符號引用驗證

什麼是符號引用? 符號引用是一一組符號來描述需要引用的目標,雖然不知道引用目標的地址,但是可以使用任何形式的字面量來代表該目標。比如全限定類名java.lang.Object就代表的是這個物件的符號引用。這一驗證階段是發生在在虛擬機器將符號引用轉化為直接引用的時候,而這個轉化的過程又是發生在連線的第三個階段——解析階段中發生。這一階段可能包含以下的驗證點:

  • 符號引用中代表的全限定類名是否能找到對應的類
  • 在指定類中是否存在符合方法的欄位描述符以及描述方法名稱和欄位的字面量
  • 符號引用中的類、方法、欄位的訪問性(private、protected、public、default)是否被當前類訪問。

驗證階段總結:這部分雖然對於虛擬機器載入非常關鍵,但卻並不是必須的,因為對於已被反覆使用以及驗證過的類,可以不進行驗證就可安全的使用。可以使用-Xverify:none引數來關閉大部分的驗證操作。

連線階段:準備

準備階段是正式為類變數分配記憶體空間並設定類變數初始值,這些變數都是在方法區中進行記憶體分配的。就在這記憶體分配的操作裡面,有很多小細節需要注意:

  1. 這裡的操作的是類變數(被static修飾的變數),而不是例項變數,例項變數會在物件被例項化的時候被一起分配到Java堆中。
  2. 這裡的類變數初始值是通常情況下的零值,而不是程式碼上所附的值。如:public static int value = 123,在準備階段,value的類初始值為0,而不是123。只有當程式編譯、初始化類過後,存放在()方法中的putstatic指令才會執行將123賦值給value的動作,賦值完過後,value的值才為123。這裡提一個小問題,對於類的成員變數,區域性變數,賦值動作都是什麼時候發生的呢?
  3. 對於類欄位屬性表中存在的ConstantValue屬性,在準備階段類變數會被初始化為ConstantValue屬性所指的值。那麼ConstantValue屬性是什麼呢?ConstantValue。這裡簡單介紹下時如何給ConstantValue屬性指定值——使用 static final。這是因為static final修飾的欄位在javac編譯時,會生成ConstantValue屬性,在類載入的準備階段直接把ConstantValue的值賦給該欄位。所以:public static final int value = 123,在準備階段value的值就是123。

連線階段:解析

對於解析階段,有兩個非常重要的概念:符號引用直接引用

  • 符號引用

    符號引用是一組用來描述所引用物件的符號,這符號的字面量形式都已經明確的定義在了Java虛擬機器規範的Class檔案格式中,不符合虛擬機器規範的字面量是無法作為字面量的。在Class檔案格式中,它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodef_info等形式出現。另外,符號引用的實現與記憶體佈局無關,引用的目標也不一定已經載入到了記憶體中。

  • 直接引用

    直接引用有三種型別,分別為"直接指向目標的指標"、"相對偏移量"、"目標的控制程式碼(引用)"。直接引用於記憶體中的佈局有關,如果存在目標的直接引用,那麼引用的目標就已經存在記憶體中了。

介紹完符號引用和直接引用後,就可以解釋一下解析階段的作用了。 解析階段就是虛擬機器將常量池中的符號引用轉化為直接引用的過程。 重要的事情說三遍,常量池的符號引用!常量池的符號引用!常量池的符號引用!

初始化

類初始化階段是類載入過程的最後一步。到了這一階段,虛擬機器才真正開始執行類中定義的Java程式程式碼(位元組碼)。這裡提一個問題,什麼時候執行類的初始化呢?

對於類初始化階段,虛擬機器規範則嚴格規定了有且只有5種情況必須立即執行對類進行"初始化"。

  1. 遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令的時候,如果沒有進行類的初始化,則需要先觸發其初始化。這4條位元組碼指令分別對應著Java程式碼的例項化物件的關鍵字——new,讀取靜態變數,設定靜態變數(被final修飾、已在編譯期把結果放在常量池的靜態欄位除外,也就是static final觸發的ConstantValue)以及呼叫靜態方法。
  2. 使用了java.lang.reflect包的方法對類進行了反射呼叫,如果該類沒有進行過初始化,則會觸發其初始化。
  3. 當初始化一個類的時候,如果發現其父類還沒有過初始化,則先觸發其父類的初始化。
  4. 當虛擬機器啟動的時候,如果使用者指定了一個包含main()方法的主類,虛擬機器會先觸發這個主類。
  5. 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼鎖對應的類沒有進行過初始化,則需要先觸發其初始化。

這裡,需要先引入一個概念,就是類構造器< clinit >()方法。該方法實際上是由編譯期自動收集類中的所有類變數的賦值動作和靜態語句塊(static{})中的語句合併產生的,也就是說< clinit >()方法裡就包含兩部分內容:賦值動作、static程式碼塊邏輯。

下面總結一下()方法的特點:

  • < clinit >()方法與類的建構函式(或者說例項構造器< init >()方法)不同,因為()方法不需要顯示的呼叫父類構造器,虛擬機器會保證在子類的< clinit >()方法執行之前,父類的< clinit >()方法已經執行完畢。因此在虛擬機器中第一個被執行的< clinit >()方法一定是java.lang.Object的。
  • 父類和子類中類變數賦值和靜態程式碼塊執行順序:父類類變數賦值 -> 父類靜態程式碼塊 -> 子類類變數賦值 -> 子類靜態程式碼塊。
  • < clinit >()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯期就可以不為這個類生成< clinit >()方法。
  • 在一個類的生命週期中,類構造器< clinit >()最多會被虛擬機器呼叫一次,而例項構造器< init >()則會被虛擬機器呼叫多次,只要程式設計師還在建立物件。
  • 虛擬機器會保證一個類的< clinit >()方法在多執行緒環境中被正確的加鎖、同步。如果一個執行緒在呼叫< clinit >()方法時,其他執行緒會被阻塞。所以說,如果< clinit >()方法中有很耗時的操作,那麼會造成多個執行緒阻塞,雖然實際上這種阻塞往往很隱蔽。需要值得注意的是,如果一個執行緒已經執行完< clinit >()了,那麼其他執行緒被喚醒之後就不會執行()方法了,因為< clinit >()方法只會執行一次。
  • 類初始化(< clinit >()方法)和例項初始化(< init >()方法)之間並沒有嚴格的先後順序,沒有規定必須要執行完類初始化後才能執行例項初始化。例項初始化可以在類初始化完成之前完成。空口無憑

另外,順便總結一下< clinit >()方法和< init >()方法的執行順序。

類例項化的一般過程是:父類的類構造器< clinit >() -> 子類的類構造器< clinit >() -> 父類的成員變數和例項程式碼塊 -> 父類的建構函式< init >() -> 子類的成員變數和例項程式碼塊 -> 子類的建構函式< init >()

非一般情況下例項化的過程:類的成員變數和例項程式碼塊 -> 類的建構函式< init >() -> 類的類構造器< clinit >()

相關文章