學習導圖
一.為什麼要學習類加載機制?
今天想跟大家嘮嗑嘮嗑 Java 的類加載機制,這是 Java 的一個很重要的創新點,曾經也是 Java 流行的重要原因之一。
Oracle 當初引入這個機制是為了滿足 Java Applet 開發的需求, JVM 咬咬牙引入了 Java 類加載機制,後來的基於 Jvm 的動態部署,插件化開發包括大家熱議的熱修復,總之很多後來的技術都源於在 JVM 中引入了類加載器。
如今,類加載機制也在各個領域大放異彩,在面試中,由類加載機制所衍生出來各類面試題也層出不窮。
所以,我們要了解下類加載機制,為工作中或者是面試中實際的需要打好良好的基礎。
二.核心知識點歸納
2.1 概述
Q1:JVM類加載機制定義:
虛擬機把描述類的數據從 Class 文件 加載 到內存,並對數據進行 校驗 、 轉換解析 和 初始化 ,最終形成可被虛擬機直接使用的 Java 類型的過程
Q2:特性
運行期類加載。即在 Java 語言裡面,類型的加載、連接和初始化過程都是在程序 運行期 完成的,從而通過犧牲一些性能開銷來換取 Java 程序的高度靈活性
什麼是運行期,什麼是編譯期?
- 編譯期 是指編譯器將 源代碼翻譯 為 機器能識別的代碼 , Java 被編譯為 Jvm 認識的 字節碼文件
- 運行期 則是指 Java 代碼的 運行 過程
JVM 運行期動態加載+動態連接-> Java 的動態擴展特性
2.2 類加載的過程
類從被加載到虛擬機內存中開始、到卸載出內存為止,整個生命週期包括七個階段:
- 加載
- 驗證
- 準備
- 解析
- 初始化
- 使用
- 卸載
其中,驗證、準備、解析這3個部分統稱為 連接 ,流程如下圖:
注意:
<code>『加載』->『驗證』->『準備』->『初始化』->『卸載』這五個階段的順序是確定的,而『解析』可能為了支持Java的動態綁定會在『初始化』後才開始
上述階段通常都是互相交叉地混合式進行的,比如會在一個階段執行的過程中調用、激活另外一個階段/<code>
2.2.1 加載
Q1:任務
- 通過類的全限定名來獲取定義此類的 二進制字節流 。如從 ZIP 包讀取、從網絡中獲取、通過運行時計算生成、由其他文件生成、從數據庫中讀取等等途徑......
- 將該二進制字節流所代表的 靜態存儲結構 轉化為 方法區 的 運行時數據結構 ,該數據存儲數據結構由虛擬機實現自行定義
- 在內存中生成一個代表這個類的 java.lang.Class 對象,它將作為程序訪問方法區中的這些類型數據的外部接口
2.2.2 驗證
- 是 連接 階段的 第一步 ,且工作量在 JVM 類加載子系統中佔了相當大的一部分
- 目的:為了 確保 Class 文件的字節流中包含的信息 符合 當前 虛擬機的要求 ,並且 不會危害虛擬機自身的安全
由此可見,它能直接決定 JVM 能否承受惡意代碼的攻擊,因此驗證階段 很重要 ,但由於它對程序運行期沒有影響,並 不一定必要 ,可以考慮使用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
- 檢驗過程包括下面四個階段:A.文件格式驗證:內容:驗證 字節流是否符合 Class 文件格式的規範 、以及是否能被 當前版本的虛擬機處理目的:保證輸入的 字節流 能正確地解析並存儲於 方法區 之內,且格式上符合描述一個 Java 類型信息的要求。只有保證二進制字節流通過了該驗證後,它才會進入內存的方法區中進行存儲,所以 後續3個驗證階段全部是基於方法區 而不是字節流了例子:是否以魔數 0xCAFEBABE 開頭主次版本號是否在 JVM 接受範圍內索引值是否有指向不存在/不符合類型的常量......B.元數據驗證:內容:對字節碼描述的信息進行 語義 分析,以保證其描述的信息符合 Java 語言規範的要求目的:對類的 元數據信息 進行語義校驗,保證不存在不符合 Java 語言規範的元數據信息例子:類是否有父類(除了 java.lang.Object 之外,所有類都應有父類)父類是否繼承了不允許被繼承的類( final 修飾的類)如果該類不是抽象類,是否實現了其父類或接口中要求實現的所有方法...... C.字節碼驗證:是驗證過程中 最複雜 的一個階段內容:對類的 方法體 進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的事件目的:通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的例子:保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現“在操作數棧的數據類型中放置了 int 類型的數據,使用時卻按 long 類型來載入本地變量表中”保證任何跳轉指令都不會跳轉到方法體外的字節碼指令上...... D.符號引用驗證:內容:對 類自身以外(如常量池中的各種符號引用)的信息 進行匹配性校驗目的:確保解析 動作能正常執行 ,如果無法通過符號引用驗證,那麼將會拋出一個 java.lang.IncompatibleClassChangeError 異常的子類注意:該驗證發生在虛擬機將 符號引用 轉化為 直接引用 的時候,即『 解析 』階段
2.2.3 準備
Q1:任務
- 為類變量(靜態變量) 分配內存 : 因為這裡的變量是由方法區分配內存 的,所以 僅包括類變量 而不包括實例變量,後者將會在對象實例化時隨著對象一起分配在 Java 堆中
- 設置類變量 初始值 :通常情況下零值
2.2.4 解析
之前提過,解析階段就是虛擬機將 常量池 內的 符號引用替換為直接引用 的過程
- 符號引用:以一組符號來描述所引用的目標
- 可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可
- 與虛擬機實現的內存佈局無關,因為符號引用的字面量形式明確定義在 Java 虛擬機規範的 Class 文件格式中,所以即使各種虛擬機實現的內存佈局不同,但是能接受符號引用都是一致的
- 直接引用:
- 可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄
- 與虛擬機實現的內存佈局相關,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不同
- 發生時間: JVM 會根據需要來判斷,是在類被加載器 加載時 就對常量池中的符號引用進行解析,還是等到一個符號引用將要被 使用前 才去解析
- 解析動作:有七類符號及其對應在常量池的七種常量類型
- 類或接口 ( CONSTANT_Class_info )
- 字段 ( CONSTANT_Fieldref_info )
- 類方法 ( CONSTANT_Methodref_info )
- 接口方法 ( CONSTANT_InterfaceMethodref_info )
- 方法類型 ( CONSTANT_MethodType_info )
- 方法句柄 ( CONSTANT_MethodHandle_info )
- 調用點限定符 ( CONSTANT_InvokeDynamic_info )
舉個例子,設當前代碼所處的為類 D ,把一個從未解析過的 符號引用 N 解析為一個 類或接口 C 的直接引用 ,解析過程分三步:
- 若 C 不是數組類型: JVM 將會把代表 N 的全限定名傳遞給 D 類加載器去加載這個類 C 。在加載過程中,由於 元數據驗證 、 字節碼驗證 的需要,又可能觸發其他相關類的加載動作。一旦這個加載過程出現了任何異常,解析過程就宣告失敗。
- 若 C 是數組類型且數組元素類型為對象: JVM 也會按照上述規則加載數組元素類型
- 若上述步驟無任何異常:此時 C 在 JVM 中已成為一個有效的類或接口,但在解析完成前還需進行 符號引用驗證 ,來確認 D 是否具備對 C 的訪問權限。如果發現不具備訪問權限,將拋出 java.lang.IllegalAccessError 異常
Q1:字段(成員變量/域)和屬性有什麼區別?
- 屬性,是指對象的屬性,對於 JavaBean 來說,是 getXXX 方法定義的
- 字段,是成員變量
<code>class Person{
private String mingzi; //mingzi是字段,一般來說字段和屬性是相同的,但是這個例子是特例
public String getName(){ //name是屬性
return mingzi:
}
public void setName(){
mingzi= "張三";
}
}/<code>
2.2.5 初始化
- 是類加載過程的最後一步,會開始真正執行類中定義的 Java 代碼。而之前的類加載過程中,除了在『 加載 』階段用戶應用程序可通過 自定義類加載器 參與之外, 其餘階段均由虛擬機主導和控制
- 與『準備』階段的 區分 :
<code>準備階段:變量賦初始零值
初始化階段:根據Java程序的設定去初始化類變量和其他資源,或者說是執行類構造器clinit的過程/<code>
clinit :由編譯器自動收集類中的所有 類變量(靜態變量)的賦值動作 和靜態語句塊 static{} 中的語句合併產生
- 是 線程安全 的,在多線程環境中被正確地加鎖、同步
- 對於類或接口來說是 非 必需的,如果一個類中 沒有靜態語句塊 ,也沒有對變量的賦值操作,那麼編譯器可以不為這個類生成 clinit
- 接口與類不同的是 ,執行接口的 clinit 不需要先執行父接口 的 clinit ,只有當父接口中定義的變量使用時,父接口才會初始化。另外, 接口的實現類在初始化時 也一樣不會執行接口的 clinit
- 在虛擬機規範中,規定了有且只有五種情況 必須立即 對類進行『 初始化 』:
- 遇到 new 、 getstatic 、 putstatic 或 invokestatic 這4條字節碼指令時
- 使用 java.lang.reflect 包的方法對類進行反射調用的時候
- 當初始化一個類的時候,若發現其父類還未進行初始化,需先觸發其父類的初始化
- 在虛擬機啟動時,需指定一個要執行的 主類 ,虛擬機會先初始化它
- 當使用 JDK1.7 的動態語言支持時,若一個 java.lang.invoke.MethodHandle 實例最後的解析結果為 REF_getStatic 、 REF_putStatic 、 REF_invokeStatic 的方法句柄,且這個方法句柄所對應的類未進行初始化,需先觸發其初始化。
2.3 類加載器&雙親委派模型
每個類加載器,都擁有一個獨立的命名空間,它不僅用於加載類,還和這個類本身一起作為在 JVM 中的唯一標識。所以比較兩個類是否相等,只要看它們是否由同一個 類加載器 加載,即使它們來源於同一個 Class 文件且被同一個 JVM 加載,只要加載它們的 類加載器不同,這兩個類就必定不相等
2.3.1 類加載器
從 JVM 的角度,可將類加載器分為兩種:
- 啟動類加載器
- 由 C++ 語言實現,是虛擬機自身的一部分
- 負責加載存放在 <JAVA_HOME>\\lib 目錄中、或被 -Xbootclasspath 參數所指定路徑中的、且可被虛擬機識別的類庫
- 無法被 Java 程序直接引用,如果自定義類加載器想要把加載請求委派給引導類加載器的話,可直接用 null 代替
- 其他類加載器:由 Java 語言實現,獨立於虛擬機外部,並且全都繼承自抽象類 java.lang.ClassLoader ,可被 Java 程序直接引用。常見幾種:
- 擴展類加載器A.由 sun.misc.Launcher$ExtClassLoader 實現B.負責加載 <JAVA_HOME>\\lib\\ext 目錄中的、或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫
- 應用程序類加載器A.是 默認 的類加載器,是 ClassLoader#getSystemClassLoader() 的返回值,故又稱為 系統類加載器B.由 sun.misc.Launcher$App-ClassLoader 實現C.負責加載用戶類路徑上所指定的類庫
- 自定義類加載器:如果以上類加載起不能滿足需求,可自定義
需要注意的是:雖然 數組類 不通過類加載器創建而是由 JVM 直接創建的,但仍與類加載器有密切關係,因為 數組類的元素類型最終還要靠類加載器去創建
2.3.2 雙親委派模型
- 定義:表示類加載器之間的層次關係
- 前提 :除了頂層啟動類加載器外, 其餘類加載器都應當有自己的父類加載器 ,且它們之間關係一般不會以 繼承 關係來實現,而是通過 組合 關係來複用父加載器的代碼
- 工作過程 :若一個類加載器收到了類加載的請求,它先會把這個請求 委派 給父類加載器,並向上傳遞,最終請求都傳送到頂層的啟動類加載器中。只有當父加載器反饋自己無法完成這個加載請求時,子加載器才會嘗試自己去加載
- 注意 :不是一個強制性的約束模型,而是 Java 設計者推薦給開發者的一種類加載器實現方式
- 優點 :類會隨著它的類加載器一起具備帶有 優先級 的層次關係,可保證 Java 程序的穩定運作;實現簡單,所有實現代碼都集中在 java.lang.ClassLoader的loadClass() 中
比如,某些類加載器要加載 java.lang.Object 類,最終都會委派給最頂端的啟動類加載器去加載,這樣 Object 類在程序的各種類加載器環境中都是同一個類。
相反,系統中將會出現多個不同的 Object 類, Java 類型體系中最基礎的行為也就無法保證,應用程序也將會變得一片混亂
閱讀更多 sandag 的文章