詳解Java類載入過程

語言: CN / TW / HK

類從被載入到虛擬機器開始,到卸載出記憶體,整個生命週期分為七個階段,分別是載入、驗證、準備、解析、初始化、使用和解除安裝。其中驗證、準備和解析這三個階段統稱為連線。整個過程如下圖所示:

載入、驗證、準備、初始化和解除安裝這五個階段順序是確定的,類的載入過程這些階段必須按這個順序開始(注意這裡強調的開始的順序,進行和完成可能是交叉混合著的)。由於 Java 支援動態繫結,在動態繫結時解析階段會在初始化之後執行。

類載入時機

上面講到類的分為七個階段,那麼什麼情況下會開始類的載入呢?

思考這個問題我們可以從兩個維度出發,一個是 JVM 規範維度,一個是從虛擬機器執行的維度;

JVM 規範維度

JVM 規範沒有強制約束類的載入時機,但 Java 虛擬機器嚴格規定了有且只有5種情況必須立即對類進行”初始化”,執行初始化自然必須先執行前面的步驟。

  • 遇到 new、getstatic、putstatic、或 invokestatic 這4條位元組碼指令時,如果類沒有初始化,則需要先觸發其初始化。其對應的場景分別為: 使用 new 關鍵字初始化例項物件 的時候、 讀取或設定一個類的靜態欄位 (被 final 修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候和 呼叫一個類的靜態方法 的時候;
  • 使用 java.lang.reflect 包的方法對類進行 反射呼叫 的時候,如果類沒有進行初始化,則需要先觸發其初始化;
  • 當初始化一個類的時候,如果發現其 父類沒有初始化 ,則需要先觸發其父類的初始化;
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含 main() 方法那個類),虛擬機器會先 初始化這個主類
  • 當使用 JDK1.7 開始的 動態語言支援 時,如果 java.lang.invoke.MethodHandle 例項最後的解析結果為 REF_getStatic、 REF_putStatic、 REF_invokeStatic 的方法控制代碼 ,並且這個方法控制代碼所對應的類沒有進行過初始化,則需要先觸發其初始化。

虛擬機器執行維度

從虛擬機器執行的維度來說,有兩種時機會觸發類載入:

  • 預載入
  • 執行時載入

預載入

虛擬機器啟動時載入,載入的是 JAVA_HOME/lib/ 下的 rt.jar 下的 .class 檔案,這個jar包裡面的內容是程式執行時非常常 常用到的,像 java.lang.*java.util. java.io. 等等,因此隨著虛擬機器一起載入。

要證明這一點很簡單,寫一個空的 main 函式,設定虛擬機器引數為 -XX:+TraceClassLoading 來獲取類載入資訊,執行一下:

[Opened /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Object from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.io.Serializable from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Comparable from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from /Library/Java/JavaVirtualMachines/jdk1.8.0_231.jdk/Contents/Home/jre/lib/rt.jar]
...

執行時載入

虛擬機器在用到一個.class檔案的時候,會先去記憶體中檢視一下這個.class檔案有沒有被載入,如果沒有就會按照類的全限定名來載入這個類。

詳解類載入過程

載入(重要)

載入階段主要做了三件事:

  • 獲取 .class 檔案的二進位制流;
  • 將類資訊、靜態變數、位元組碼、常量這些 .class 檔案中的內容放入方法區中;
  • 在記憶體中生成一個代表這個.class檔案的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。一般這個Class是在堆裡的,不過HotSpot虛擬機器比較特殊,這個Class物件是放在方法區中的。

虛擬機器規範對這三點的要求並不具體,因此虛擬機器實現與具體應用的靈活度都是相當大的。

這種靈活度對於開發者來說主要體現在第一步,由於虛擬機器規範並沒有規定二進位制位元組流的來源,開發者可以從以下幾個渠道獲取:

  • 從zip包中獲取,這就是以後jar、ear、war格式的基礎
  • 從網路中獲取,典型應用就是Applet
  • 執行時計算生成,典型應用就是動態代理技術
  • 由其他檔案生成,典型應用就是JSP,即由JSP生成對應的.class檔案
  • 從資料庫中讀取,這種場景比較少見

連結(理解)

連結分為三個步驟:驗證、準備和解析

驗證

驗證階段主要是為了確保 .class 檔案的位元組流中包含的資訊符合當前虛擬機器要求,並且不會危害虛擬機器自身的安全。

正如前面所說,二進位制位元組流可能有很多種來源,虛擬機器如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有害的位元組 流而導致系統崩潰,所以 驗證是虛擬機器對自身保護的一項重要工作

驗證階段大致會完成以下四個階段的檢驗動作:

  • 檔案格式驗證
  • 元資料驗證
  • 位元組碼驗證
  • 符號引用驗證

驗證階段與載入階段是交叉進行的,載入階段還沒有結束驗證階段就已經開始了。

這個階段也是最耗費時間的,如果我們所執行的全部程式碼(包括自己編寫的及第三方依賴包中的程式碼)都已經被反覆使用和驗證過,那麼可以考慮使用 -Xverify none 引數來關閉大部分類驗證措施,以縮短虛擬機器類載入時間。

準備

準備階段是正式為 類變數 分配記憶體並設定其 初始值 的階段,這些變數所使用的記憶體都將在方法區中分配 。

這裡的 類變數 是指不被 final 修飾的 static 變數,這裡設定的 初始值 指的是賦零值。

各個資料型別對應的零值如下:

資料型別 零值
int 0
long 0L
short (short)0
chart ‘\u0000’
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

這裡需要注意一下,類變數由於在這個階段會有一個初始值,所有程式碼裡可以不指定初始值直接使用,但其他變數不行,使用前必須有初值,否則會編譯出錯。

解析

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

這裡就需要了解符號引用和直接引用的概念:

符號引用

符號引用是 以一組符號來描述所引用的目標 ,符號可以是任何形式的字面量,只要使用的時候可以無歧義地定位到目標即可。

下面以簡單的程式碼來理解符號引用:

package com.zephyr.demo;

public class SymbolClass {

    public static String serial;
    private int count;

    public static void calculate() {
    }

    public int getCount() {
        return count;
    }
}

使用 javap -verbose SymbolClass 反編譯一下這個類,我們主要看看常量池部分:

Constant pool:
   #1 = Methodref          #4.#25         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#26         // com/zephyr/demo/SymbolClass.count:I
   #3 = Class              #27            // com/zephyr/demo/SymbolClass
   #4 = Class              #28            // java/lang/Object
   #5 = Utf8               serial
   #6 = Utf8               Ljava/lang/String;
   #7 = Utf8               count
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               Lcom/zephyr/demo/SymbolClass;
  #16 = Utf8               calculate
  #17 = Utf8               getCount
  #18 = Utf8               ()I
  #19 = Utf8               main
  #20 = Utf8               ([Ljava/lang/String;)V
  #21 = Utf8               args
  #22 = Utf8               [Ljava/lang/String;
  #23 = Utf8               SourceFile
  #24 = Utf8               SymbolClass.java
  #25 = NameAndType        #9:#10         // "<init>":()V
  #26 = NameAndType        #7:#8          // count:I
  #27 = Utf8               com/zephyr/demo/SymbolClass
  #28 = Utf8               java/lang/Object

上面帶 Utf8 的那一行就是符號引用,每行最前面的就是符號,後面就是引用的值。對於變數來說都會有兩行成對出現,比如#7 是 count,#8就是 count 的型別 Integer(常量池裡簡寫為 I )。方法如果有返回值,方法和返回值也會成對出現,比如 #17 和 #18,分別代表的方法和返回值型別。

簡單理解符號引用就是對於類、變數、方法的描述。 並且 符號引用和虛擬機器的記憶體佈局是沒有關係的,引用的目標未必已經載入到記憶體中了。

直接引用

直接引用可以是直接指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。

直接引用是和虛擬機器實現的記憶體佈局相關的。如果有了直接引用, 那引用的目標必定已經存在在記憶體中了。

解析過程

Java 本身是一個靜態語言,但後面又加入了動態載入特性,因此我們理解解析階段需要從這兩方面來考慮。

如果不涉及動態載入,那麼一個符號的解析結果是可以快取的,這樣可以避免多次解析同一個符號,因為第一次解析成功後面多次解析也必然成功,第一次解析異常後面重新解析也會是同樣的結果。

如果使用了動態載入,前面使用動態載入解析過的符號後面重新解析結果可能會不同。使用動態載入時解析過程發生在在程式執行到這條指令的時候,這就是為什麼前面講的動態載入時解析會在初始化後執行。

整個解析階段主要做了下面幾個工作:

  • 類或介面的解析
  • 類方法解析
  • 介面方法解析
  • 欄位解析

初始化(重要)

初始化是整個類載入過程的最後一個階段。整個類載入的五個階段只有載入和初始化是開發者可以參與的,因此初始化階段也是需要重點關注的階段。

初始化階段簡單來說就是執行類的構造器方法( <clinit>() ),要注意的是這裡的構造器方法 <clinit>() 並不是開發者寫的,而是 編譯器自動生成的

程式碼順序的影響

編譯器編譯的時候會按程式碼順序進行收集,宣告在靜態程式碼塊(static {} 塊)之後的靜態變數,只能在靜態程式碼塊裡賦值,不能訪問。

public class TestClinit {
   static {
		i = 0; // 可以給變數賦值
		System.out.print(i); // 在這裡訪問編譯器會提示“非法向前引用” 
   }
	static int i = 1; 
}

父類與子類 <clinit>() 方法執行順序

Java 虛擬機器會保證父類的 <clinit>() 方法執行完成後才執行子類的 <clinit>() 方法,這也就意味著 父類的靜態程式碼塊一定會先於子類執行 的。

這裡需要注意的是,如果父類的靜態程式碼塊有耗時操作,子類可能會被阻塞遲遲載入不了。

編譯器生成 <clinit>() 方法的條件

編譯器生成 <clinit>() 方法的前提是有變數賦值操作或者有靜態程式碼塊需要執行。介面雖然沒有靜態程式碼塊但是有變數賦值操作,所以介面會生成 <clinit>() 方法。

注意:介面或者介面的實現類在執行 <clinit>() 方法之前不一定會去執行父類的 <clinit>() 方法,僅當父類或者被實現的介面變數被使用才會呼叫這個方法。