Java虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這個過程被稱作虛拟機的類加載機制。
1. 類加載的時機
一個類型從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期将會經曆加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)七個階段,其中驗證、準備、解析三個部分統稱為連接配接(Linking)。這七個階段的發生順序如下圖(類的生命周期)所示:

2. 類加載的過程
接下來我們會詳細了解Java虛拟機中類加載的全過程,即加載、驗證、準備、解析和初始化這五個階段所執行的具體動作。
2.1 加載
“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,在加載階段,Java虛拟機需要完成以下三件事情:
1)通過一個類的全限定名來擷取定義此類的二進制位元組流。
2)将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構。
3)在記憶體中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種資料的通路入口。
2.2 驗證
驗證是連接配接階段的第一步,這一階段的目的是確定Class檔案的位元組流中包含的資訊符合《Java虛拟機規範》的全部限制要求,保證這些資訊被當作代碼運作後不會危害虛拟機自身的安全。
驗證階段可以分為如下幾個步驟:
1.檔案格式驗證:
第一階段驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理。這一階段可能包括下面這些驗證點:
是否以魔數0xCAFEBABE開頭。
主、次版本号是否在目前Java虛拟機接受範圍之内。
常量池的常量中是否有不被支援的常量類型(檢查常量tag标志)。
指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
Class檔案中各個部分及檔案本身是否有被删除的或附加的其他資訊。
…
2.中繼資料驗證:
第二階段是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合《Java語言規範》的要求,這個階段可能包括的驗證點如下:
這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
如果這個類不是抽象類,是否實作了其父類或接口之中要求實作的所有方法。
類中的字段、方法是否與父類産生沖突(例如覆寫了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一緻,但傳回值類型卻不同等)。
3.位元組碼驗證:
第三階段是整個驗證過程中最複雜的一個階段,主要目的是通過資料流分析和控制流分析,确定程式語義是合法的、符合邏輯的。
在第二階段對中繼資料資訊中的資料類型校驗完畢以後,這階段就要對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法在運作時不會做出危害虛拟機安全的行為,例如:
保證任意時刻操作數棧的資料類型與指令代碼序列都能配合工作,例如不會出現類似于“在操作棧放置了一個int類型的資料,使用時卻按long類型來加載入本地變量表中”這樣的情況。
保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
保證方法體中的類型轉換總是有效的,例如可以把一個子類對象指派給父類資料類型,這是安全的,但是把父類對象指派給子類資料類型,甚至把對象指派給與它毫無繼承關系、完全不相幹的一個資料類型,則是危險和不合法的。
4.符号引用驗證:
最後一個階段的校驗行為發生在虛拟機将符号引用轉化為直接引用[3]的時候,這個轉化動作将在連接配接的第三階段——解析階段中發生。符号引用驗證可以看作是對類自身以外(常量池中的各種符号引用)的各類資訊進行比對性校驗,通俗來說就是,該類是否缺少或者被禁止通路它依賴的某些外部類、方法、字段等資源。本階段通常需要校驗下列内容:
符号引用中通過字元串描述的全限定名是否能找到對應的類。
在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
符号引用中的類、字段、方法的可通路性(private、protected、public、< package>)是否可被目前類通路。
2.3 準備
準備階段是正式為類中定義的變量(即靜态變量,被static修飾的變量)配置設定記憶體并設定類變量初始值的階段(是這裡所說的初始值“通常情況”下是資料類型的零值)。
假設一個類變量的定義為:
public static int value = 123;
那變量value在準備階段過後的初始值為0而不是123。
基本資料類型的零值表:
2.4 解析
解析階段是Java虛拟機将常量池内的符号引用替換為直接引用的過程。
符号引用(Symbolic References):符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可。
直接引用(Direct References):直接引用是可以直接指向目标的指針、相對偏移量或者是一個能間接定位到目标的句柄。
解析的具體流程分為如下幾個階段:
1.類或接口的解析
2.字段解析
3.方法解析
4.接口方法解析
2.5 初始化
進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程式代碼去初始化類變量和其他資源(例如,靜态變量指派動作和靜态語句塊(static{})中的語句)。
我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器< clinit>()方法的過程。
3. 類加載器
類加載器雖然隻用于實作類的加載動作,但它在Java程式中起到的作用卻遠超類加載階段。對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同确立其在Java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。
這句話可以表達得更通俗一些:比較兩個類是否“相等”,隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個Java虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等。
站在Java虛拟機的角度來看,隻存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用C++語言實作[1],是虛拟機自身的一部分;另外一種就是其他所有的類加載器,這些類加載器都由Java語言實作,獨立存在于虛拟機外部,并且全都繼承自抽象類 java.lang.ClassLoader。
類加載器分類,以 JDK 8 為例:
類加載器的優先級(由高到低):啟動類加載器 -> 擴充類加載器 -> 應用程式類加載器 -> 自定義類加載器
3.1 啟動類加載器
用 Bootstrap 類加載器加載類:
package cn.itcast.jvm.t3.load;
public class F {
static {
System.out.println("bootstrap F init");
}
}
執行:
package cn.itcast.jvm.t3.load;
public class Load5_1 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
// aClass.getClassLoader():獲得aClass對應的類加載器
System.out.println(aClass.getClassLoader());
}
}
輸出:
E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:. cn.itcast.jvm.t3.load.Load5
bootstrap F init
null
-Xbootclasspath 表示設定 bootclasspath
其中 /a:. 表示将目前目錄追加至 bootclasspath 之後
可以有以下幾個方式替換啟動類路徑下的核心類:
java -Xbootclasspath: < new bootclasspath>
前追加:java -Xbootclasspath/a:<追加路徑>
後追加:java -Xbootclasspath/p:<追加路徑>
3.2 擴充類加載器
package cn.itcast.jvm.t3.load;
public class G {
static {
System.out.println("classpath G init");
}
}
程式執行:
public class Load5_2 {
public static void main(String[] args) throws ClassNotFoundException {
Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.G");
System.out.println(aClass.getClassLoader());
}
}
輸出結果:
classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2 // 這個類是由應用程式加載器加載
寫一個同名的類:
package cn.itcast.jvm.t3.load;
public class G {
static {
System.out.println("ext G init");
}
}
打個 jar 包:
E:\git\jvm\out\production\jvm>jar -cvf my.jar cn/itcast/jvm/t3/load/G.class // 将G.class打jar包
已添加清單
正在添加: cn/itcast/jvm/t3/load/G.class(輸入 = 481) (輸出 = 322)(壓縮了 33%)
将 jar 包拷貝到
JAVA_HOME/jre/lib/ext
(擴充類加載器加載的類必須是以jar包方式存在),重新執行 Load5_2
ext G init
sun.misc.Launcher$ExtClassLoader@29453f44 // 這個類是由擴充類加載器加載
3.3 應用程式加載器
應用程式類加載器(Application Class Loader):這個類加載器由 sun.misc.Launcher$AppClassLoader 來實作。由于應用程式類加載器是ClassLoader類中的getSystem-ClassLoader() 方法的傳回值,是以有些場合中也稱它為“系統類加載器”。它負責加載使用者類路徑(ClassPath)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器。
3.4 自定義類加載器
什麼時候需要自定義類加載器:
1)想加載非 classpath 随意路徑中的類檔案
2)都是通過接口來使用實作,希望解耦時,常用在架構設計
3)這些類希望予以隔離,不同應用的同名類都可以加載,不沖突,常見于 tomcat 容器
步驟:
繼承 ClassLoader 父類。
要遵從雙親委派機制,重寫 findClass 方法 注意不是重寫 loadClass 方法,否則不會走雙親委派機制。
讀取類檔案的位元組碼。
調用父類的 defineClass 方法來加載類。
使用者調用該類加載器的 loadClass 方法。
4. 雙親委派模型
什麼是雙親委派模型?
如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求(它的搜尋範圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。
如下圖所示:
為什麼要使用雙親委派模型呢?(好處)
避免重複加載 + 避免核心類篡改:
采用雙親委派模式的是好處是Java類随着它的類加載器一起具備了一種帶有優先級的層次關系,通過這種層級關可以避免類的重複加載,當父加載器已經加載了該類時,就沒有必要子加載器再加載一次。
其次是考慮到安全因素,java 核心 api 中定義類型不會被随意替換,假設通過網絡傳遞一個名為 java.lang.Integer 的類,通過雙親委托模式傳遞到啟動類加載器,而啟動類加載器在核心Java API發現這個名字的類,發現該類已被加載,并不會重新加載網絡傳遞的過來的 java.lang.Integer,而直接傳回已加載過的 Integer.class,這樣便可以防止核心API庫被随意篡改。
結語:
非常建議學習Java的小夥伴,買一本周志明老師的《深入了解Java虛拟機(第3版)》去讀一讀,部落格和視訊教程,始終不如看書來得實在呀!