類載入的七個階段

大將黃猿發表於2020-11-18

一個類的生命週期

類生命週期的7個階段

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止。他的整個生命週期包括七個階段:載入,驗證,準備,解析,初始化,使用,解除安裝7個階段。其中驗證,準備,解析3個部分統稱為連線*(Linking)

 

階段順序

載入,驗證,準備,初始化,解除安裝這五個階段的順序是確定的,但是對於”解析”階段卻不一定。它在某些情況下可以再初始化之後再開始,這樣做是為了支援java的執行時繫結特性(也稱為動態繫結或晚期繫結)

 

載入

什麼時候需要開始類第一個階段”載入”呢?虛擬機器規範沒有強制束縛。這點交給虛擬機器的具體實現來自由把控。

“載入loading”階段是整個類載入的第一個階段。

 

載入階段虛擬機器需要完成以下三件事

  1. 通過一個類的許可權定名來獲取定義此類的二進位制位元組流(將整個class檔案解析成二進位制流),此步驟由類載入器完成。這一動作是放在Java虛擬機器外部去實現的,以便讓應用程式自己決定如何獲取所需的類。
  2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料區。(將位元組流的資料存入執行時資料區)
  3. 在記憶體中生成一個代表這個類的java.lang.Class物件。作為方法區這個類的各種資料的訪問入口。(方法區內自己生成一個物件,在多執行緒中常用的類物件就是它)

 

注意:比如”通過一個類的全限定名來獲取定義此類的二進位制位元組流”沒有指定一定得從某個class檔案中獲取。所以我們可以從zip壓縮包,從網路中獲取,執行時計算生成,資料庫中讀取忙活著從加密檔案中讀取等等。

我們也可以通過JHSDB看到,JVM啟動後,相關的類已經載入進入了方法區,成為了方法區的執行時結構。(注意!第一步已經把class檔案的資料載入進記憶體了。所以說相關的類已經進入了方法區,成為了方法區的執行時結構了)。

驗證

是連線階段的第一步,這個階段的目的是為了確保Class檔案的位元組流中,包含的資訊符合虛擬機器的要求。並且不會危害虛擬機器的自身安全。但從整體上看,驗證階段大致會完成4個階段的檢驗動作:檔案格式驗證,後設資料驗證,位元組碼驗證,符號引用驗證。

 

檔案格式驗證(非重點)

第一階段要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理,這一階段可能包括下面這些驗證點:

  1. 是否以魔數cafebaby開頭。
  2. 主,次版本號是否在當前java虛擬機器接受範圍內。
  3. 常量池的常量中是否有不被支援的常量資料(檢查常量tag標誌)。
  4. 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
  5. Utf8 info型的常量中是否有不符合UTF-8編碼的資料。
  6. Class檔案中各部分及檔案本身是否有被刪除的或附加的資訊。

......以上只是一小部分,沒必要深入研究。

 

總結:這個階段的驗證是基於二進位制位元組流進行的。只有通過了這個階段的驗證之後,這段位元組流才被允許進入Java虛擬機器記憶體的方法區中進行儲存,所以後面三個驗證階段全部是基於方法區的儲存結構(記憶體)上進行的,不會再直接讀取,操作位元組流了。

 

後設資料驗證(非重點)

我們直接用編譯器直接編譯出來的.class檔案一般沒這些問題。但是.class檔案的來源很雜。因此再此處再次判斷。

後設資料:描述類與類之間關係的資料。

第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java語言規範》

  1. 這個類是否有父類(除了Java,lang.Object之外,所有的類都應當有父類)。
  2. 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
  3. 類中的欄位,方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等。)
  4. .....

後設資料驗證是第二階段,主要對類的後設資料資訊進行語義驗證。保證不存在《Java語言規範》定義相悖的後設資料資訊。

位元組碼驗證

位元組碼驗證第三階段是整個驗證過程中最複雜的一個階段。主要目的是通過資料流分析和控制流分析。確定程式語義是合法的,符合邏輯的。在第二階段對後設資料資訊中的資料型別校驗完畢之後,這階段就要對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證校驗類的方法在執行時不會做出危害虛擬機器安全的行為。例如:

  1. 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作。例如不會出現類似於”在運算元棧放置了一個int型別的資料,使用時卻按long型別載入入區域性變數表中”這樣的情況。
  2. 保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
  3. 保證方法體中的型別轉換總是有效的。例如把一個子類的物件賦值給父類資料型別,這是安全的。但是把父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係,完全不相干的一個資料型別,則是危險和不合法的。
  4. .......

如果一個方法體中的位元組碼沒通過位元組碼驗證,那肯定是有問題的。

符號引用驗證(非重點)

最後一個階段的校驗行為發生在虛擬機器將符號引用轉換為直接引用的過程。這個轉化動作將在連續的三個階段------解析階段中發生,符號引用驗證可以看做是對類自身以外(常量池中的各個符號引用)的各類資訊進行匹配性校驗。通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類,方法,欄位等資源。本階段需要校驗的內容如下:

  1. 符號引用中通過字串描述的全限定名是否能找到對應的類。
  2. 在指定類中是否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位。
  3. 符號引用中類,欄位,方法的可訪問性。
  4. 是否可被當前類訪問。
  5. .....

符號引用驗證的主要目的是確保解析行為能夠正常執行,如果無法通過符號引用驗證,將會丟擲異常。

驗證總結:驗證階段對於虛擬機器的載入機制來說,是非常重要,且不是必須要執行的階段。因為驗證階段只有通過或者不通過的差別,只要通過了驗證,其後就對程式執行沒有任何影響了。如果程式執行的全部程式碼(包括自己編寫的,第三方包中的,從外部載入的,動態生成的等所有程式碼)都被反覆編譯與驗證過了,在生產環境的實施階段就可以考慮用-Xverify:none引數來關閉大部分驗證措施,以縮短虛擬機器類載入的時間。

 

準備(給靜態變數賦初值)

準備這個階段是正式為類中定義的變數(被static修飾的變數)分配記憶體並設定類變數初始值的階段。這些變數所使用的記憶體都將在方法區中進行分配。(為什麼靜態方法可以使用該類的class物件。因為class物件在類載入時出現。而static則是在準備階段出現)。

這個階段容易產生混淆的概念:

  1. 首先這時候進行記憶體分配的僅包括類變數(被static修飾的變數),而不包括例項變數(成員變數)。例項變數將會在物件例項化時隨著物件一起分配在java堆中。
  2. 其次,這裡所說的初始值”通常情況”下是資料型別的零值。假設一個類變數定義為。

public static int value = 123;

那變數value在準備階段後的初始值為0而不是123。因此此時還沒有開始執行任何Java方法,而把value賦值給123是後續的初始化環節。

 

解析

解析階段是JVM將常量池內的符號引用替換為直接引用的過程。

符號引用是一種定義,可以是任何字面上的含義。而直接引用就是直接指向目標的指標,相對偏移量。

直接引用的物件都存在於記憶體中。你可以把通訊錄裡女友手機號碼類比成符號引用,把面對面和你吃飯的女朋友類比為直接引用。By:享學king老師的例子。

我就直白一點:直接引用的作用是在我們執行時資料區內部,幫助呼叫者找到資料的實際記憶體地址。符號引用就是我們執行時資料區在類載入階段,還未對類進行佈局時,我們通過符號引用訪問class檔案中資料的實際記憶體地址,載入進執行時資料區進行佈局。佈局完後就擁有了所謂的直接引用。可以相對概念來思考。在執行時資料區與class檔案之間,符號引用就像直接引用一樣。

 

解析大概可以分為:(不重要)

  1. 類或介面的解析
  2. 欄位解析
  3. 類方法解析
  4. 介面方法解析

我們經常遇到的異常就與這個階段有關。

java.lang.NoSuchFieldError 根據繼承關係從下往上,找不到相關欄位時的報錯。(欄位解析異常)

java.lang.IllegalAccessError 欄位或者方法,訪問許可權不具備時的錯誤。(類或介面的解析異常)

java.lang.NoSuchMethodError 找不到相關方法時的錯誤。(類方法解析、介面方法解析時發生的異常)

初始化(給靜態變數賦程式碼裡的值)

作用

當一個Java類第一次被真正使用到的時候,JVM會進行該類的初始化操作。初始化過程的主要操作是執行靜態程式碼塊和初始化靜態域(假如一個類有一個變數public static int a = 10。在準備階段會賦值為0。而到了初始化階段會賦值為10)。在一個類被初始化之前,它的直接父類也需要被初始化。但是,一個介面的初始化,不會引起其父介面的初始化。在初始化的時候,會按照原始碼中從上到下的順序依次執行靜態程式碼塊和初始化靜態域。

我們講到了這裡,先提一下什麼時候需要進行類載入,什麼時候需要進行連線,什麼時候初始化呢?

答案是大部分都是直接去觸發類載入的。要觸發就直接觸發類的初始化了。而初始化之前的步驟順序都是確認的,因此類載入與連線也跟著執行了。當然還有部分特殊的操作只會觸發類載入不會觸發類初始化,後面會舉例羅列。

初始化條件

初始化主要對一個class中的static{}語句進行操作(對應的位元組碼就是client方法)。

Static{}語句對於類或者介面而言都不是必須的。如果一個類中,沒有靜態語句塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<client>()方法。

初始化階段,虛擬機器規範則是嚴格規定了有且只有6中情況必須立即對類進行初始化(載入,驗證,準備再次之前就必須開始,解析不一定)。

  1. 遇到new(例項化),getstatic(獲取靜態變數值),putstatic(存放靜態變數值)或invokestatic(靜態方法呼叫)這四條位元組碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化,生成這4條指令最常見的Java場景是。
  1. 使用new關鍵字例項化物件時。
  2. 讀取或設定一個類的靜態欄位(被final修飾,已在編譯期把結果放入常量池的靜態欄位除外)的時候。
  3. 呼叫一個類的靜態方法時。
  1. 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
  2. 當初始化一個類的時候,如果發現其父類還沒有進行初始化,需要先觸發其父類的初始化。
  3. 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包括main()方法的那個類),虛擬機器會先初始化這個主類。
  4. 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後解析結果REF_getStatic,REF_putStatic,REF_invokeStatic的方法控制程式碼,並且這個方法控制程式碼所對應的類沒有進行過初始化。則需要先觸發其初始化。
  5. 當一個介面定義了JDK1.8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果這個類發生了初始化,那該介面要在其之前被初始化。

猛地一看,好傢伙,那也別一條條記了,只要程式碼跟類沾點邊兒就直接初始化唄?別慌。

我們來看一下什麼時候不會觸發初始化。

雖然確實存在反例,但也不能完全不能歸類記憶。以上6種我們統稱為主動引用。除此之外的所有引用型別都不會觸發初始化。稱為被動引用。具體見以下案例:

 

 

案例

首先先定義一個父類

 

再定義一個子類

 

此時,在呼叫方法中列舉5個例子,檢視類的載入以及初始化情況。

 

案例1(M1方法)

 

 

由結果可知:如果子類引用父類中的靜態欄位,會使父類進行初始化,而不會觸發子類的初始化(但是子類會被載入)。

 

此時父類的class檔案遇到了getstatic位元組碼指令,而子類沒有。

 

通過給VM新增-XX:+TraceClassLoading,得知子類雖然沒有被初始化,但是已經被載入。

總結:子類繼承父類但沒有重寫父類的靜態欄位。當呼叫子類.欄位時只會觸發父類初始化。子類僅載入。

案例2(M2方法)

 

使用陣列的方式

 

並沒有與類初始化相關的位元組碼指令。因此不會初始化。

 

通過給VM新增-XX:+TraceClassLoading,得知使用陣列的父類雖然沒有被初始化,但是已經被載入。

這只是分配了一堆這個資料型別的空間。並沒有操作。可以從這個角度去理解。如果需要給這個陣列賦值,需要先遍歷這個陣列然後依次對每個下標進行賦值。

案例3(M3方法)

 

列印一個String常量

 

沒有初始化位元組碼指令,因此不進行初始化。

 

通過給VM新增-XX:+TraceClassLoading,得知父類也沒有被載入。

什麼原因呢?

 

這裡說的就是String常量池的變數

 

    可以發現在編譯Test的時候,就已經把SuperClazz的常量載入到了Test的常量池中。此外可以測試int型別的資料。但是據觀察沒有在常量池中找到123字面量,但是也不會進行類載入。(所以String常量池真實一個特殊的存在

案例4

 

 

 

執行發現,觸發了初始化,所以必然已經類載入了。

原因:再次證明了String常量池的特殊性。

 

執行緒安全

其實初始化的時候就是對靜態程式碼塊進行賦值static{}。那麼如果多執行緒去同時初始化一個類,此時虛擬機器會保證一個類的<clinit>()方法在多執行緒環境下會被正確的加鎖,同步。所以,如果一個類的<clinit>()(也就是static{})方法中有耗時很長的操作,就有可能造成堵塞。同時也可以利用這點將一些操作放在這裡以達到執行緒同步的效果。

擴充套件:在單例模式懶漢式進階版——延遲初始化佔位類模式正是用了這個思想。類載入絕對是執行緒安全的。

 

相關文章