1. 程式人生 > >深入理解JAVA虛擬機器6:類載入機制

深入理解JAVA虛擬機器6:類載入機制

類載入機制

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的 Java 型別,這就是虛擬機器類載入機制。(類是在執行期間動態載入的)

懶載入:要用的時候再去載入。舉個栗子,我們的電腦上有很多軟體,比如qq,idea,網易雲音樂等等,如果我們在電腦開機的時候就全部自動開啟,那我們的電腦肯定會卡爆的,因為我們不是所有應用都要使用到..所以我們不採取,而是我們要用哪個軟體就點開哪個,這樣子。

類的生命週期

包括以下 7 個階段:

  • 載入(Loading)
  • 驗證(Verification)
  • 準備(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 解除安裝(Unloading)

其中解析過程在某些情況下可以在初始化階段之後再開始,這是為了支援 Java 的動態繫結。

這7個階段中的:載入、驗證、準備、初始化、解除安裝的順序是固定的。但它們並不一定是嚴格同步序列執行,它們之間可能會有交叉,但總是以 “開始” 的順序總是按部就班的。至於解析則有可能在初始化之後才開始,這是為了支援 Java 語言的執行時繫結(也稱為動態繫結或晚期繫結)。

類初始化時機

1. 主動引用

虛擬機器規範中並沒有強制約束何時進行載入,但是規範嚴格規定了有且只有下列五種情況必須對類進行初始化(載入、驗證、準備都會隨之發生):

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

2. 被動引用

以上 5 種場景中的行為稱為對一個類進行主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。被動引用的常見例子包括:

  • 通過子類引用父類的靜態欄位,不會導致子類初始化。
  • 通過陣列定義來引用類,不會觸發此類的初始化。該過程會對陣列類進行初始化,陣列類是一個由虛擬機器自動生成的、直接繼承自 Object 的子類,其中包含了陣列的屬性和方法。
  • 常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化。

類載入過程

包含了載入、驗證、準備、解析和初始化這 5 個階段。

1. 載入

載入是類載入的一個階段,注意不要混淆。

載入過程完成以下三件事:

  • 通過一個類的全限定名來獲取定義此類的二進位制位元組流。
  • 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時儲存結構。
  • 在記憶體中生成一個代表這個類的 Class 物件,作為方法區這個類的各種資料的訪問入口。

載入源(其中二進位制位元組流可以從以下方式中獲取):

  • 檔案:從 ZIP 包讀取,這很常見,最終成為日後 JAR、EAR、WAR 格式的基礎。
  • 網路:從網路中獲取,這種場景最典型的應用是 Applet。
  • 計算生成一個二進位制流:執行時計算生成,這種場景使用得最多得就是動態代理技術,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理類的二進位制位元組流。
  • 由其他檔案生成:由其他檔案生成,典型場景是 JSP 應用,即由 JSP 檔案生成對應的 Class 類。
  • 資料庫:從資料庫讀取,這種場景相對少見,例如有些中介軟體伺服器(如 SAP Netweaver)可以選擇把程式安裝到資料庫中來完成程式程式碼在叢集間的分發。 ...

2. 驗證

目的:確保 Class 檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。

  • 檔案格式驗證:驗證位元組流是否符合 Class 檔案格式的規範,並且能被當前版本的虛擬機器處理。
    • 是否以0xCAFEBABE開頭
    • 版本號是否合理
  • 元資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合 Java 語言規範的要求。
    • 是否有父類-繼
    • 承了final類?
    • 非抽象類實現了所有的抽象方法
  • 位元組碼驗證(很複雜):通過資料流和控制流分析,確保程式語義是合法、符合邏輯的。
    • 執行檢查
    • 棧資料型別和操作碼資料引數吻合
    • 跳轉指令指定到合理的位置
  • 符號引用驗證:發生在虛擬機器將符號引用轉換為直接引用的時候,對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。
    • 常量池中描述類是否存在
    • 訪問的方法或欄位是否存在且有足夠的許可權

3. 準備

類變數是被 static 修飾的變數,準備階段為類變數分配記憶體並設定初始值,使用的是方法區的記憶體。

例項變數不會在這階段分配記憶體,它將會在物件例項化時隨著物件一起分配在堆中。

注意,例項化不是類載入的一個過程,類載入發生在所有例項化操作之前,並且類載入只進行一次,例項化可以進行多次。

初始值一般為 0 值,例如下面的類變數 value 被初始化為 0 而不是 123,在初始化的中才會被設定為1。

  • 預設值:int 0, boolean false, float 0.0, char '0', 抽象資料型別 null
public static int value = 123;

對於static final型別,在準備階段就會被賦上正確的值

public static final int value = 123;

4. 解析

什麼是符號引用和直接引用?

  • 符號引用:符號引用是一組符號來描述所引用的目標物件,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標物件並不一定已經載入到記憶體中。
  • 直接引用:直接引用可以是直接指向目標物件的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是與虛擬機器記憶體佈局實現相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在記憶體中存在。

將常量池的符號引用替換為直接引用的過程

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

5. 初始化

初始化階段才真正開始執行類中定義的 Java 程式程式碼。初始化階段即虛擬機器執行類構造器 () 方法的過程。

在準備階段,類變數已經賦過一次系統要求的初始值,而在初始化階段,根據程式設計師通過程式制定的主觀計劃去初始化類變數和其它資源。

() 方法具有以下特點:

  • 是由編譯器自動收集類中所有類變數的賦值動作和靜態語句塊中的語句合併產生的,編譯器收集的順序由語句在原始檔中出現的順序決定。特別注意的是,靜態語句塊只能訪問到定義在它之前的類變數,定義在它之後的類變數只能賦值,不能訪問。例如以下程式碼:
public class Test {
    static {
        i = 0;                // 給變數賦值可以正常編譯通過
        System.out.print(i);  // 這句編譯器會提示“非法向前引用”
    }
    static int i = 1;
}
  • 與類的建構函式(或者說例項構造器 ())不同,不需要顯式的呼叫父類的構造器。虛擬機器會自動保證在子類的 () 方法執行之前,父類的 () 方法已經執行結束。因此虛擬機器中第一個執行 () 方法的類肯定為 java.lang.Object。
  • 由於父類的 () 方法先執行,也就意味著父類中定義的靜態語句塊要優先於子類的變數賦值操作。例如以下程式碼:
static class Parent {
    public static int A = 1;
    static {
        A = 2;
    }
}

static class Sub extends Parent {
    public static int B = A;
}

public static void main(String[] args) {
     System.out.println(Sub.B);  // 2
}
  • () 方法對於類或介面不是必須的,如果一個類中不包含靜態語句塊,也沒有對類變數的賦值操作,編譯器可以不為該類生成 () 方法。
  • 介面中不可以使用靜態語句塊,但仍然有類變數初始化的賦值操作,因此介面與類一樣都會生成 () 方法。但介面與類不同的是,執行介面的 () 方法不需要先執行父介面的 () 方法。只有當父介面中定義的變數使用時,父接口才會初始化。另外,介面的實現類在初始化時也一樣不會執行介面的 () 方法。
  • 虛擬機器會保證一個類的 () 方法在多執行緒環境下被正確的加鎖和同步,如果多個執行緒同時初始化一個類,只會有一個執行緒執行這個類的 () 方法,其它執行緒都會阻塞等待,直到活動執行緒執行 () 方法完畢。如果在一個類的 () 方法中有耗時的操作,就可能造成多個執行緒阻塞,在實際過程中此種阻塞很隱蔽。

類載入器

虛擬機器設計團隊把類載入階段中的 “通過一個類的全限定名來獲取描述此類的二進位制位元組流(即位元組碼)” 這個動作放到 Java 虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類(通過一個類的全限之名獲取描述此類的二進位制位元組流)。實現這個動作的程式碼模組稱為 “類載入器”

1. 類與類載入器

兩個類相等:只有被同一個類載入器載入的類才可能會相等。相同的位元組碼被不同的類載入器載入的類不相等。

這裡的相等,包括類的 Class 物件的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果為 true,也包括使用 instanceof 關鍵字做物件所屬關係判定結果為 true。

2. 類載入器分類

從 Java 虛擬機器的角度來講,只存在以下兩種不同的類載入器:

  • 啟動類載入器(Bootstrap ClassLoader),這個類載入器用 C++ 實現,是虛擬機器自身的一部分;
  • 所有其他類的載入器,這些類由 Java 實現,獨立於虛擬機器外部,並且全都繼承自抽象類 java.lang.ClassLoader。

從 Java 開發人員的角度看,類載入器可以劃分得更細緻一些:

  • 啟動類載入器(Bootstrap ClassLoader)此類載入器負責將存放在 <JAVA_HOME>\lib 目錄中的,或者被 -Xbootclasspath 引數所指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如 rt.jar,名字不符合的類庫即使放在 lib 目錄中也不會被載入)類庫載入到虛擬機器記憶體中。啟動類載入器無法被 Java 程式直接引用,使用者在編寫自定義類載入器時,如果需要把載入請求委派給啟動類載入器,直接使用 null 代替即可。
  • 擴充套件類載入器(Extension ClassLoader)這個類載入器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系統變數所指定路徑中的所有類庫載入到記憶體中,開發者可以直接使用擴充套件類載入器。
  • 應用程式類載入器(Application ClassLoader)這個類載入器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。由於這個類載入器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般稱為系統類載入器。它負責載入使用者類路徑(ClassPath)上所指定的類庫,開發者可以直接使用這個類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。
  • 自定義類載入器
    • 載器步驟:
      • 定義一個類,繼承ClassLoader
      • 重寫 loadClass 方法
      • 例項化 Class 物件
    • 自定義類載入器的優勢
      • 類載入器是java語言的一項創新,也是java語言流行的重要原因之一,它最初的設計是為了滿足java applet 的需求而開發出來的
      • 高度的靈活性
      • 通過自定義類載入器可以實現熱部署
      • 程式碼加密

3. 雙親委派模型

JVM 如何載入一個類的過程,雙親委派模型中有哪些方法有沒有可能父類載入器和子類載入器,載入同一個類?如果載入同一個類,該使用哪一個類?

  • 雙親委派機制圖

  • 雙親委派概念

    • 如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的載入器都是如此,因此所有的類載入請求都會傳給頂層的啟動類載入器,只有當父載入器反饋自己無法完成該載入請求(該載入器的搜尋範圍中沒有找到對應的類)時,子載入器才會嘗試自己去載入。
  • 載入器

    • 啟動(Bootstrap)類載入器:是用原生代碼實現的類裝入器,它負責將 <Java_Runtime_Home>/lib下面的類庫載入到記憶體中(比如rt.jar)。由於引導類載入器涉及到虛擬機器本地實現細節,開發者無法直接獲取到啟動類載入器的引用,所以不允許直接通過引用進行操作。
    • 標準擴充套件(Extension)類載入器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)實現的。它負責將< Java_Runtime_Home >/lib/ext或者由系統變數 java.ext.dir指定位置中的類庫載入到記憶體中。開發者可以直接使用標準擴充套件類載入器。
    • 系統(System)類載入器:由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)實現的。它負責將系統類路徑(CLASSPATH)中指定的類庫載入到記憶體中。開發者可以直接使用系統類載入器。除了以上列舉的三種類載入器,還有一種比較特殊的型別 — 執行緒上下文類載入器。
  • 如果載入同一個類,該使用哪一個類?

    • 父類的

破壞雙親委派模型

雙親委派模型很好的解決了各個類載入器載入基礎類的統一性問題。即越基礎的類由越上層的載入器進行載入。  若載入的基礎類中需要回呼叫戶程式碼,而這時頂層的類載入器無法識別這些使用者程式碼,怎麼辦呢?這時就需要破壞雙親委派模型了。  下面介紹兩個例子來講解破壞雙親委派模型的過程。

  1. JNDI破壞雙親委派模型  JNDI是Java標準服務,它的程式碼由啟動類載入器去載入。但是JNDI需要回調獨立廠商實現的程式碼,而類載入器無法識別這些回撥程式碼(SPI)。  為了解決這個問題,引入了一個執行緒上下文類載入器。 可通過Thread.setContextClassLoader()設定。  利用執行緒上下文類載入器去載入所需要的SPI程式碼,即父類載入器請求子類載入器去完成類載入的過程,而破壞了雙親委派模型。

  2. Spring破壞雙親委派模型  Spring要對使用者程式進行組織和管理,而使用者程式一般放在WEB-INF目錄下,由WebAppClassLoader類載入器載入,而Spring由Common類載入器或Shared類載入器載入。  那麼Spring是如何訪問WEB-INF下的使用者程式呢?  使用執行緒上下文類載入器。 Spring載入類所用的classLoader都是通過Thread.currentThread().getContextClassLoader()獲取的。當執行緒建立時會預設建立一個AppClassLoader類載入器(對應Tomcat中的WebAppclassLoader類載入器): setContextClassLoader(AppClassLoader)。  利用這個來載入使用者程式。即任何一個執行緒都可通過getContextClassLoader()獲取到WebAppclassLoader。