JVM類載入與雙親委派機制被打破

等不到的口琴發表於2021-02-02

前言

前文已經講了虛擬機器將java檔案編譯成class檔案後的格式:JVM虛擬機器Class類檔案研究分析

java檔案經過編譯,形成class檔案,那麼虛擬機器如何將這些Class檔案讀取到記憶體中呢?

載入的時機

JVM 會在程式第一次主動引用類的時候載入該類,被動引用時並不會引發類載入的操作。也就是說,JVM 並不是在一開始就把一個程式就所有的類都載入到記憶體中,而是到不得不用的時候才把它載入進來,而且只載入一次。

一個類的生命週期如圖所示:

上圖中的載入、驗證、準備、初始化、解除安裝這幾個步驟是相對固定的,但是初始化這一步不一定,他在某些情況下可以是再初始化之後執行。

載入

載入是類載入的第一階段,虛擬機器此時主要做以下三件事情:

1.通過類的全限定名來獲取定義這個類的二進位制位元組流。

2.將位元組流的靜態儲存結構轉化為執行時的資料結構;

3.在記憶體中生成該類的 java.lang.Class 物件,作為方法區這個類各種資料訪問入口。

主動引用一定會載入,但是被動引用則不一定

主動引用

  1. 遇到 new、getstatic、putstatic、invokestatic 位元組碼指令,例如:

    使用 new 例項化物件;

    讀取或設定一個類的 static 欄位(被 final 修飾的除外);

    呼叫類的靜態方法。

  2. 對類進行反射呼叫;

  3. 初始化一個類時,其父類還沒初始化(需先初始化父類);

    這點類與介面具有不同的表現,介面初始化時,不要求其父介面完成初始化,只有真正使用父介面時才初始化,如引用父介面中定義的常量。

  4. 虛擬機器啟動,先初始化包含 main() 函式的主類;

  5. JDK 1.7 動態語言支援:一個 java.lang.invoke.MethodHandle 的解析結果為 REF_getStatic、REF_putStatic、REF_invokeStatic。

被動引用

  1. 通過子類引用父類靜態欄位,不會導致子類初始化;

  2. Array[] arr = new Array[10]; 不會觸發 Array 類初始化;

  3. static final VAR 在編譯階段會存入呼叫類的常量池,通過 ClassName.VAR 引用不會觸發 ClassName 初始化。

也就是說,只有發生主動引用所列出的 5 種情況,一個類才會被載入到記憶體中,也就是說類的載入是 lazy-load 的,不到必要時刻是不會提前載入的,畢竟如果將程式執行中永遠用不到的類載入進記憶體,會佔用方法區中的記憶體,浪費系統資源。

驗證

目的: 確保 .class 檔案中的位元組流資訊符合虛擬機器的要求。

4 個驗證過程:

檔案格式驗證:是否符合 Class 檔案格式規範,驗證檔案開頭 4 個位元組是不是 “魔數” 0xCAFEBABE

後設資料驗證:保證位元組碼描述資訊符號 Java 規範(語義分析)

位元組碼驗證:程式語義、邏輯是否正確(通過資料流、控制流分析)

符號引用驗證:對類自身以外的資訊(常量池中的符號引用)進行匹配性校驗

這個操作雖然重要,但不是必要的,可以通過 -Xverify:none 關掉。

準備

  • 描述: 為 static 變數(類變數,非例項變數)在方法區分配記憶體。

  • static 變數準備後的初始值:

    當static變數未被final修飾時:

    public static int value = 123;
    

    準備後為 0,value 的賦值指令 putstatic 會被放在 () 方法中,()方法會在初始化時執行,也就是說,value 變數只有在初始化後才等於 123。

    當static變數被final修飾時:

    public static final int value = 123;
    

    準備後為 123,因為被 static final 賦值之後 value 就不能再修改了,所以在這裡進行了賦值之後,之後不可能再出現賦值操作,所以可以直接在準備階段就把 value 的值初始化好。

解析

描述:將常量池中的 “符號引用” 替換為 “直接引用”,也就是說將引用指向記憶體。

符號引用,比如com.courage.People引用了com.courage.Man,這時候Man並不在記憶體中

但是直接飲用則是引用Man所在的記憶體地址。

在此之前,常量池中的引用是不一定存在的,解析過之後,可以保證常量池中的引用在記憶體中一定存在。

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

  • 符號引用:以一組符號描述所引用的物件(如物件的全類名),引用的目標不一定存在於記憶體中。
  • 直接引用:直接指向被引用目標在記憶體中的位置的指標等,也就是說,引用的目標一定存在於記憶體中。

初始化

描述: 執行類構造器<clinit>()方法的過程。

<clinit>()方法包含的內容:

所有 static 的賦值操作;

static 塊中的語句;

<clinit>()方法中的語句順序:

基本按照語句在原始檔中出現的順序排列;

靜態語句塊只能訪問定義在它前面的變數,定義在它後面的變數,可以賦值,但不能訪問。

與例項構造器<init>()不同的地方在於:

不需要顯示呼叫父類的<clinit>()方法;

虛擬機器保證在子類的<clinit>()方法執行前,父類的<clinit>()方法一定執行完畢。

也就是說,父類的 static 塊和 static 欄位的賦值操作是要先於子類的。

介面與類的不同

執行子介面的<clinit>()方法前不需要先執行父介面的<clinit>()方法(除非用到了父介面中定義的 public static final 變數);

執行過程中加鎖

同一時刻只能有一個執行緒在執行<clinit>()方法,因為虛擬機器要保證在同一個類載入器下,一個類只被載入一次。

非必要性:

一個類如果沒有任何 static 的內容就不需要執行 <clinit>()方法。

注:初始化時,才真正開始執行類中定義的 Java 程式碼。

虛擬機器規範中並沒有規定何時載入類,但是以下6種場景,場景必須初始化

類的顯式載入和隱式載入

顯示載入

  1. 呼叫 ClassLoader#loadClass(className)Class.forName(className)

  2. 兩種顯示載入 .class 檔案的區別:

    Class.forName(className) 載入 class 的同時會初始化靜態域,ClassLoader#loadClass(className) 不會初始化靜態域;

    Class.forName 藉助當前呼叫者的 class 的 ClassLoader 完成 class 的載入。

隱式載入

  1. new 類物件;

  2. 使用類的靜態域;

  3. 建立子類物件;

  4. 使用子類的靜態域;

  5. 其他的隱式載入,在 JVM 啟動時

    BootStrapLoader 會載入一些 JVM 自身執行所需的 Class;

    ExtClassLoader 會載入指定目錄下一些特殊的 Class;

    AppClassLoader 會載入 classpath 路徑下的 Class,以及 main 函式所在的類的 Class 檔案。

雙親委派機制

通過一個類的全限定名來獲取描述該類的二進位制位元組流這個動作在Java虛擬機器外部實現,這樣做的好處是應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼被稱為“類載入器”(Class Loader)。

在比較兩個類是不是同一個類,只有在同一個類載入器下比較才有意義,對於同一個類用不同的載入器載入記憶體,兩個類是不相等的。

站在Java虛擬機器的角度來看,只存在兩種不同的類載入器:一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器使用C++語言實現 ,是虛擬機器自身的一部分;另外一種就是其他所有的類載入器,這些類載入器都由Java語言實現,獨立存在於虛擬機器外部,並且全都繼承自抽象類java.lang.ClassLoader。

為了保證載入應該被載入的類,遵循雙親委派機制,目的是保證安全性,例如自己定義的String類不至於替換掉虛擬機器預設的String類,雙親委派機制如圖:

需要說明的是,此處的Cache以及倉庫是我為了後面說明方便而做的定義,每一個啟動器都有自己對應的Class檔案存放位置,將這個位置稱之為倉庫,已經載入進記憶體的Class存放在記憶體中,這塊記憶體稱之為Cache,對於我們自定義的String類,肯定是放在使用者空間的倉庫上,如果要載入這個類,會依次往上查詢,各級的記憶體,首先查詢使用者自定義的ClassLoader,如果已經載入過就直接返回,如果沒有載入過就往上一類載入器快取中查詢,如果直到Bootstrap都沒有找到的話就會開始查詢倉庫,查詢倉庫的順序與查詢快取相反,先查詢Bootstrap的倉庫,再查詢Extension,找到就載入然後返回Class,也就意味著,自定義的String根本沒法被查詢到,因為在Bootstrap倉庫中已經查詢到String並且載入返回了。

雙親委派模型要求除了頂層的啟動類載入器外,其餘的類載入器都應有自己的父類載入器。不過這裡類載入器之間的父子關係一般不是以繼承(Inheritance)的關係來實現的,而是通常使用組合(Composition)關係來複用父載入器的程式碼。

使用雙親委派模型來組織類載入器之間的關係,一個顯而易見的好處就是Java中的類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都能夠保證是同一個類。

雙親委派機制被破壞

在Java的世界中大部分的類載入器都遵循這個模型,但也有例外的情況,直到Java模組化出現為止,雙親委派模型主要出現過3次較大規模“被破壞”的情況。

第一次被破壞

雙親委派模型的第一次“被破壞”其實發生在雙親委派模型出現之前——即JDK 1.2面世以前的“遠古”時代。由於雙親委派模型在JDK 1.2之後才被引入,但是類載入器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經存在,面對已經存在的使用者自定義類載入器的程式碼,Java設計者們引入雙親委派模型時不得不做出一些妥協,為了相容這些已有程式碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之後的java.lang.ClassLoader中新增一個新的protected方法findClass(),並引導使用者編寫的類載入邏輯時儘可能去重寫這個方法,而不是在loadClass()中編寫程式碼。上節我們已經分析過loadClass()方法,雙親委派的具體邏輯就實現在這裡面,按照loadClass()方法的邏輯,如果父類載入失敗,會自動呼叫自己的findClass()方法來完成載入,這樣既不影響使用者按照自己的意願去載入類,又可以保證新寫出來的類載入器是符合雙親委派規則的。

第二次被破壞

雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷導致的,雙親委派很好地解決了各個類載入器協作時基礎型別的一致性問題(越基礎的類由越上層的載入器進行載入),基礎型別之所以被稱為“基礎”,是因為它們總是作為被使用者程式碼繼承、呼叫的API存在,但程式設計往往沒有絕對不變的完美規則,如果有基礎型別又要呼叫回使用者的程式碼,那該怎麼辦呢?

這並非是不可能出現的事情,一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,它的程式碼由啟動類載入器來完成載入(在JDK 1.3時加入到rt.jar的),肯定屬於Java中很基礎的型別了。但JNDI存在的目的就是對資源進行查詢和集中管理,它需要呼叫由其他廠商實現並部署在應用程式的ClassPath下的JNDI服務提供者介面(Service Provider Interface,SPI)的程式碼,現在問題來了,啟動類載入器是絕不可能認識、載入這些程式碼的,那該怎麼辦?

為了解決這個困境,Java的設計團隊只好引入了一個不太優雅的設計:執行緒上下文類載入器(Thread Context ClassLoader)。這個類載入器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設定,如果建立執行緒時還未設定,它將會從父執行緒中繼承一個,如果在應用程式的全域性範圍內都沒有設定過的話,那這個類載入器預設就是應用程式類載入器。

有了執行緒上下文類載入器,程式就可以做一些“舞弊”的事情了。JNDI服務使用這個執行緒上下文類載入器去載入所需的SPI服務程式碼,這是一種父類載入器去請求子類載入器完成類載入的行為,這種行為實際上是打通了雙親委派模型的層次結構來逆向使用類載入器,已經違背了雙親委派模型的一般性原則,但也是無可奈何的事情。Java中涉及SPI的載入基本上都採用這種方式來完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不過,當SPI的服務提供者多於一個的時候,程式碼就只能根據具體提供者的型別來硬編碼判斷,為了消除這種極不優雅的實現方式,在JDK 6時,JDK提供了java.util.ServiceLoader類,以META-INF/services中的配置資訊,輔以責任鏈模式,這才算是給SPI的載入提供了一種相對合理的解決方案。

第三次被破壞

雙親委派模型的第三次“被破壞”是由於使用者對程式動態性的追求而導致的,這裡所說的“動態性”指的是一些非常“熱”門的名詞:程式碼熱替換(Hot Swap)、模組熱部署(Hot Deployment)等。說白了就是希望Java應用程式能像我們的電腦外設那樣,接上滑鼠、U盤,不用重啟機器就能立即使用,滑鼠有問題或要升級就換個滑鼠,不用關機也不用重啟。對於個人電腦來說,重啟一次其實沒有什麼大不了的,但對於一些生產系統來說,關機重啟一次可能就要被列為生產事故,這種情況下熱部署就對軟體開發者,尤其是大型系統或企業級軟體開發者具有很大的吸引力。

相關文章