JVM筆記-類載入機制
- 2020 年 3 月 30 日
- 筆記
JVM 不和包括 Java 在內的任何語言綁定,它只與 "Class文件" 這種特定的二進位文件格式所關聯。而 Class 文件也並非只能通過 Java 源文件編譯生成,可以通過如下途徑而來:

JVM 把描述類的數據從 Class 文件載入到記憶體,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這個過程被稱為虛擬機的「類載入機制」。
即Class 文件中描述的關於類的資訊最終要載入到 JVM 中才能被運行和使用。
1. 類載入的時機
1.1 類的生命周期
一個類型(類或介面)從被載入到虛擬機記憶體開始,到卸載出記憶體為止,它的整個生命周期會經歷載入(Loading)、驗證(Verification)、準備(Prepare)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)七個階段,其中驗證、準備、解析統稱為連接(Linking)。如圖所示:

1.2 初始化時機
JVM 規範對於「載入」階段並未強制約束。但對於「初始化」階段,則規定有且僅有以下六種情況必須立即對其「初始化」:
- 遇到 new、getstatic、putstatic、invokestatic 這四條位元組碼指令時。場景如下:
- 使用 new 關鍵字實例化對象;
- 讀/寫靜態欄位(static 修飾,無 final);
- 調用靜態方法。
- 使用
java.lang.reflect的方法對類型進行反射調用時。 - 初始化類時,若父類尚未初始化,需要先初始化其父類。
- 虛擬機啟動時,需要先初始化用戶指定的主類(main 方法所在類)。
- 使用 JDK 7 新加入的動態語言支援時,若一個 java.lang.invoke.MethodHandle 實例最後解析結果為 REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial 四種類型的方法句柄,且該方法句柄對應的類未初始化,需要先初始化【平時似乎沒用到過,暫不深究,以後有機會再分析】。
- 介面中定義了 JDK 8 加入的默認方法(default 修飾)時,在該介面的實現類初始化之前,需要先初始化這個介面。
注意:當一個「類」在初始化時,要求其父類全都已經初始化;但是,一個「介面」在初始化時,並不要求父介面全都初始化,只有真正使用到父介面時才會初始化(比如引用介面定義的常量)。
1.3 主動引用&被動引用
上述六種情況的行為稱為對一個類型的「主動引用」,而除此之外的其他所有引用類型方式都不會觸發初始化,稱為「被動引用」。被動引用舉例如下:
- 示例程式碼
public class SuperClass { static { System.out.println("SuperClass init!"); } public static int value = 123; public static final String HELLO_WORLD = "hello, world"; } public class SubClass extends SuperClass { static { System.out.println("SubClass init!"); } }
PS: 為了跟蹤類載入資訊,可配置虛擬機參數
-XX:+TraceClassLoading。
- eg1
/** * 通過子類引用父類的靜態欄位,不會導致子類初始化 */ public class NotInitialization { public static void main(String[] args) { System.out.println(SubClass.value); } } /* 類載入情況:SubClass 和 SuperClass 均被載入 * * 輸出結果(父類初始化,子類未初始化): * SupClass init! * 123 */
- eg2
/** * 通過數組定義來引用類,不會觸發此類的初始化 */ public class NotInitialization { public static void main(String[] args) { SuperClass[] superClasses = new SuperClass[10]; } } /* 類載入情況:SuperClass 被載入 * 輸出結果為空,SuperClass 未初始化 */
- eg3
/** * 常量在【編譯階段】會存入調用類(NotInitialization)的常量池中, * 本質上並沒有直接引用到定義常量的類,因此不會觸發其初始化 */ public class NotInitialization { public static void main(String[] args) { System.out.println(SuperClass.HELLO_WORLD); } } /* 類載入情況:SubClass 和 SuperClass 均未被載入 * * 輸出結果: * hello, world */
編譯階段通過常量傳播優化,已將該常量的值("hello, world")直接存儲在 NotInitialization 類的常量池中,以後 NotInitialization 對常量 SuperClass.HELLO_WORLD 的引用實際都被轉化為對自身常量池的引用了。
PS: 其實 NotInitialization 類的 Class 文件中並不存在 SuperClass 類的符號引用入口,這兩個類在編譯成 Class 文件之後就沒聯繫了。
2. 類載入過程
2.1 載入
載入階段,JVM 主要做了三件事情:
- 通過一個類的全限定名來獲取定義此類的二進位位元組流;
- 將該位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
- 在(堆)記憶體中生成一個代表該類的
java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。
PS: 二進位位元組流的來源有很多,例如:從 ZIP 壓縮包讀取、從網路獲取、運行時計算生成(動態代理),從加密文件讀取等。
需要注意的是,數組類的載入情況有所不同:數組類本身不通過類載入器創建,而是由 JVM 直接在記憶體動態構造(newarray 指令)。它的創建過程遵循以下原則:
- 若數組的組件類型(數組去掉一個維度)為引用類型,則遞歸載入該組件類型;
- 若數組的組件類型不是引用類型(例如 int[] 組件類型為 int),JVM 會把數組標記為與引導類載入器關聯;
- 數組類的可訪問性與其組件類型的可訪問性一致(若組件類型不是引用類型,可訪問性默認為 public)。
2.2 驗證
主要目的:確保 Class 文件資訊符合 JVM 規範,防止惡意程式碼危害虛擬機自身安全。
有點類似我們平時開發介面時的參數校驗,不能因為入參問題把程式搞崩潰了。
該階段大致會完成下面四個階段的驗證:文件格式驗證、元數據驗證、位元組碼驗證和符號引用驗證。
2.2.1 文件格式驗證
驗證位元組流是否符合 Class 文件格式的規範,且能被當前虛擬機處理。主要驗證:
- 是否以魔數 0xCAFEBABY 開頭;
- 主次版本號是否在當前虛擬機處理範圍內;
- ……
PS: 該階段是基於二進位位元組流進行的,驗證通過之後才允許進入 JVM 的方法區。而後面的驗證都是基於方法區的存儲結構進行的,不再直接讀取位元組碼。
2.2.2 元數據驗證
對類的元數據資訊進行語義校驗,確保不違背 Java 語言規範。比如:
- 一個類是否有父類;
- 該父類是否繼承了 final 修飾的類;
- ……
2.2.3 位元組碼驗證
該階段最複雜,主要是數據流分析和控制流分析,確定語義合法、符合邏輯。驗證點如下:
- 操作數棧的數據類型與指令程式碼序列能配合工作;
- 跳轉指令不會跳到方法體以外的位元組碼指令上;
- 類型轉換有效;
- ……
2.2.4 符號引用驗證
發生在虛擬機將符號引用轉為直接引用時(即後面的解析階段),確保解析動作能正常執行。驗證點如下:
- 符號引用中通過字元串描述的全限定名是否能找到對應的類;
- 符號引用中的類、欄位、方法的可訪問性驗證;
- ……
驗證階段雖然很重要,卻並非必須執行。若程式程式碼已被反覆使用和驗證,可以考慮關閉大部分類驗證,以縮短類載入的時間。JVM 參數:
-Xverify:none
2.3 準備
主要目的:為類變數(即 static 修飾的靜態變數)分配記憶體並設置初始值。
初始值「通常」情況指的是零值,基本數據類型的零值如下:

// 經過「準備」階段後,該初始值為 0 // 而把 value 賦值為 123 是在後面的「初始化」階段 public static int value = 123;
注意,上面的「通常」不包含一種情況,即靜態變數被 final 修飾的時候,例如:
public static final int value = 123;
編譯階段會為 value 生成 ConstantValue 屬性,在準備階段虛擬機會根據 ConstantValue 將其設置為 123.
2.4 解析
主要動作:把常量池內的符號引用替換為直接引用。
該階段主要針對類或介面、欄位、類方法、介面方法、方法類型、方法句柄和調用點限定符這 7 類符號引用進行。
2.4.1 符號引用
符號引用(Symbolic References):以一組符號描述所引用的目標,可以是任何形式的字面量(比如全限定類名)。引用的目標不一定載入到 JVM 記憶體中。
- 程式碼示例
比如有兩個 java 文件,分別為 A.java 和 B.java,如下:
public class A { } public class B { private A a; }
其中 B 持有對 A 的引用,但此時兩個類並未載入到記憶體中,僅僅是一個標記而已。
2.4.2 直接引用
直接引用(Direct References)可以是:
- 直接指向目標的指針;
- 相對偏移量(例如實例變數、實例方法);
- 能間接定位到目標的句柄。
直接引用就是能夠直接在記憶體中找到相應對象(的記憶體地址)。若有直接引用,則目標必定已在虛擬機中。
2.5 初始化
初始化階段就是執行類構造器 <clinit>() 方法的過程,<clinit>() 方法有如下特點:
- 由編譯器根據源文件中的順序、自動收集類中的所有靜態變數的賦值動作和靜態程式碼塊合併產生。
- 此方法與類的構造方法(虛擬機視角中的實例構造器 <init>() 方法,也就是我們在程式碼中定義的構造器)不同,不需要顯式地調用父類構造器,JVM 會保證子類 <clinit>() 方法執行前,父類 <clinit>() 方法已執行完。
PS: 與類不同的是,介面的方法不需要先執行父介面的 <clinit>() 方法。 介面的實現類在初始化時也不會執行介面的 <clinit>() 方法。
- 該方法並不是必需的,若類中無靜態語句塊和對變數的賦值操作,編譯器可以不生成這個方法。
介面中雖然不能使用靜態程式碼塊,卻可以為變數始化賦值,因此也會生成 <clinit>() 方法。
- JVM 必須保證一個類的 <clinit>() 方法在多執行緒環境被正確地加鎖同步。如果多個執行緒同時去初始化一個類,只能有一個執行緒去執行 <clinit>() 方法,其他執行緒都要阻塞等待。
說到這裡,設計模式的「單例模式」就有一種寫法是利用該機制來保證執行緒安全性的,示例程式碼如下:
public class BeanFactory { private BeanFactory() { } public BeanFactory getBeanFactory() { return BeanFactoryHolder.beanFactory; } /** * 使用內部嵌套類實現單例,利用 JVM 的類載入機制可保證執行緒安全 */ private static class BeanFactoryHolder { private static BeanFactory beanFactory = new BeanFactory(); } }
3. 類載入器
所謂類載入器(Class Loader),其實就是一段程式碼。
這段程式碼的主要功能就是:通過一個類的全限定名來獲取描述類資訊的二進位位元組流。
3.1 類與類載入器
對於任意一個類,都必須由其「類載入器」和「該類本身」共同確定它在 JVM 中的唯一性。
即,若要比較兩個類是否相等,前提是這兩個類必須是由同一個類載入器載入(後面程式碼進行驗證)。
PS: 這裡的「相等」,包括 equals、isAssignableFrom、isInstance 等方法,還有 instanceof 關鍵字。
3.2 雙親委派模型
類載入器的分類及其主要特點如下:
- 啟動類載入器(Bootstrap Class Loader)
- 虛擬機的一部分(C++ 實現);
- 負責載入 JAVA_HOMElib 目錄,或者 -Xbootclasspath 參數指定路徑下,且被 JVM 識別的類庫。
- 擴展類載入器(Extension Class Loader)
- 由 sun.misc.Launcher$ExtClassLoader 類實現;
- 負責載入 JAVA_HOMElibext 目錄,或者 java.ext.dirs 系統變數指定的路徑中的類庫。
- 應用程式類載入器(Application Class Loader)
- 由 sun.misc.Launcher$AppClassLoader 類實現;
- 載入用戶類路徑(ClassPath)下所有的類庫;
- 默認的系統類載入器(若應用程式沒有自定義過類載入器,一般使用該類進行載入)。
若有必要,還可以加入自定義的類載入器進行擴展。
JDK 9 之前的 Java 應用都是由這三種類載入器互相配合完成載入的。它們之間的協作關係如圖所示:

這種層次關係被稱為類載入器的「雙親委派模型(Parents Delegation Model)」。
雙親委派模型的工作流程大致如下:
若一個類載入器收到了載入類的請求,它首先不會自己嘗試去載入這個類,而是將其委派給父類載入器,父載入器亦是如此,直至啟動類載入器;僅當父載入器無法載入該類的時候,子載入器才會嘗試自己進行載入。
注意:這裡的它們之間並非「繼承」關係,通常是採用「組合」的方式。
3.2.1 實現源碼
雙親委派模型的實現程式碼在 java.lang.ClassLoader 類的 loadClass 方法中,如下:
private final ClassLoader parent; protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 先檢查請求的類是否已載入過 Class<?> c = findLoadedClass(name); if (c == null) { try { // 若未載入過,調用父類載入器進行載入(父類載入器也會繼續該過程) if (parent != null) { c = parent.loadClass(name, false); } else { // 使用啟動類載入器載入 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父類載入器無法完成載入 } // 父類載入器未完成載入時,使用自身的 findClass 方法嘗試載入 if (c == null) { c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } } // JDK 1.2 提供的 protected Class<?> findClass(String name) throws ClassNotFoundException { throw new ClassNotFoundException(name); }
3.2.2 優點
為什麼要採用雙親委派模型?這樣做有什麼好處呢?
一個好處就是:Java 類隨著類載入器有了層級關係,把最基礎的類,例如 java.lang.Object,交給最頂端的類載入器載入,保證在各個載入器環境中都是同一個 Object 類。
說到這裡,有些面試題會問:如果自定義一個 java.lang.Object 類會怎樣?
- 自定義 java.lang.Object 類
這裡做下測試,自定義一個 java.lang.Object 類:
package java.lang; public class Object { public String toString() { return "hello"; } public static void main(String[] args) { System.out.println("hello"); } }
如果能正常載入,這裡會列印字元串 "hello",結果呢?會報錯:
Error: Main method not found in class java.lang.Object, please define the main method as: public static void main(String[] args) or a JavaFX application class must extend javafx.application.Application
錯誤原因是 main 方法未找到,就是我們自定義的方法未找到。
查看類載入資訊:
[Loaded java.lang.Object from /Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/jre/lib/rt.jar] [Loaded java.io.Serializable from /Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/jre/lib/rt.jar] [Loaded java.lang.Comparable from /Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/jre/lib/rt.jar] ...
可以發現,JVM 只載入了 rt.jar 中的 java.lang.Object ,並沒有載入我們定義的這個 Object 類,而 rt.jar 中的 Object 是沒有 main 方法的。
- 自定義 java.lang.HelloWorld 類
如果我們定義一個全類名為 java.lang.HelloWorld 的類呢?
package java.lang; public class HelloWorld { public static void main(String[] args) { System.out.println("hello"); } }
可以正常載入和運行嗎?並不會!
異常如下:
Error: A JNI error has occurred, please check your installation and try again Exception in thread "main" java.lang.SecurityException: Prohibited package name: java.lang at java.lang.ClassLoader.preDefineClass(ClassLoader.java:662) at java.lang.ClassLoader.defineClass(ClassLoader.java:761) at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142) at java.net.URLClassLoader.defineClass(URLClassLoader.java:468) at java.net.URLClassLoader.access$100(URLClassLoader.java:74) ...
可以看到,java.lang 這個包名是禁止使用的。
3.3 破壞雙親委派模型
PS: 「破壞雙親委派模型「這個概念剛開始聽起來可能有些費解,尤其是這個」破壞「,至少我是這樣。 其實呢,雙親委派模型可以理解為一個規範,然鵝,某些地方由於某些原因並未遵循這個規範。對於那些沒有遵循該規範的地方,就是破壞了雙親委派模型。
總的來說,破壞雙親委派模型的行為大致有三次:
- 第一次
由於「雙親委派模型」是 JDK 1.2 引入的,但類載入和 java.lang.ClassLoader 類在此之前就已經存在了,為了兼容已有程式碼,雙親委派模型做了妥協。
由於 ClassLoader 類的 loadClass 方法可以直接被子類重寫,這樣的類載入機制就不符合雙親委派模型了。
如何實現兼容呢?在 ClassLoader 類添加了 findClass 方法(程式碼見 3.2.1),並引導用戶重寫該方法,而非 loadClass 方法。
這就是第一次破壞雙親委派模型,其實就是兼容歷史遺留問題。
- 第二次
雙親委派模型的類載入都是自底向上的(越基礎的類由越上層的載入器來載入),但有些場景可能會出現基礎類型要反回來調用用戶程式碼,這個場景如何解決呢?
一個典型的例子就是 JNDI (啟動類載入器載入)服務,其目的是調用其它廠商實現並部署在應用程式 ClassPath 下的服務提供者介面(Service Provider Interface,SPI)。啟動類載入器是不認識這些 SPI 的,如何解決呢?
Java 團隊引入了一個執行緒上下文類載入器(Thread Context ClassLoader),可以設置類載入器,在啟動類載入器不認識的地方,調用其它類載入器去載入。這其實也打破了雙親委派模型。
比如 JDBC 的類載入機制,後文再詳細分析。
- 第三次
第三次破壞是對程式動態性的追求導致的,程式碼熱替換(Hot Swap)、模組熱部署(Hot Deployment)等。典型的如 IBM 的 OSGi 模組化熱部署。
4. 程式碼示例
4.1 自定義類載入器
public class MyClassLoader extends ClassLoader { // 重寫 findClass 方法 @Override protected Class<?> findClass(String name) throws ClassNotFoundException { byte[] classData = loadClassData(name); if (classData == null) { throw new ClassNotFoundException(); } return defineClass(name, classData, 0, classData.length); } // 讀取 class 文件 private byte[] loadClassData(String className) { String fileName = "~/Code/Java/test/target/classes" + File.separatorChar + className.replace('.', File.separatorChar) + ".class"; try { FileInputStream inputStream = new FileInputStream(fileName); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int length; while ((length = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, length); } return outputStream.toByteArray(); } catch (IOException e) { e.printStackTrace(); } return null; } }
4.2 雙親委派模型類載入
- 自定義一個 Person 類
package loader; public class Person { static { // 當 Person 類初始化時,會列印該程式碼 System.out.println("Person init!"); } private String name; public String getName() { return name; } public void setName(String name) { this.name = name; } }
使用上面自定義的類載入載入 Person 類:
private static void test1() throws Exception { // 創建類載入器實例 MyClassLoader myClassLoader1 = new MyClassLoader(); // 載入 Person 類(注意這裡是 loadClass 方法) Class<?> aClass1 = myClassLoader1.loadClass("loader.Person"); aClass1.newInstance(); // Person init! MyClassLoader myClassLoader2 = new MyClassLoader(); Class<?> aClass2 = myClassLoader2.loadClass("loader.Person"); aClass2.newInstance(); System.out.println("--->" + aClass1.getClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2 System.out.println("--->" + aClass2.getClassLoader()); // sun.misc.Launcher$AppClassLoader@18b4aac2 System.out.println("--->" + aClass1.equals(aClass2)); // true }
可以看到,這裡雖然使用了兩個類載入器實例載入 Person 類,但實際上 aClass1 和 aClass2 的類載入器並不是自定義的 MyClassLoader,而是 Launcher$AppClassLoader,即應用類載入器。為什麼會是這個結果呢?
其實這就是前面分析的雙親委派模型,示意圖如下:

大體流程分析:
- 使用 MyClassLoader 載入 Person 類時,它會先委託給 AppClassLoader;
- AppClassLoader 委託給 ExtClassLoader;
- ExtClassLoader 委託給啟動類載入器;
- 但是,啟動類載入器並不認識 Person 類,無法載入,於是就再反回來交給 ExtClassLoader;
- ExtClassLoader 也無法載入,於是交給了 AppClassLoader;
- AppClassLoader 可以載入 Person 類,載入結束。
4.2 非雙親委派模型類載入
上面演示了雙親委派模型載入一個類,如何破壞雙親委派模型呢?把上面的 loadClass 方法換成 findClass 就行,示例程式碼:
- 測試類載入 eg.1
private static void test2() throws Exception { MyClassLoader cl1 = new MyClassLoader(); // 載入自定義的 Person 類 Class<?> aClass1 = cl1.findClass("loader.Person"); // 實例化 Person 對象 aClass1.newInstance(); // Person init! MyClassLoader cl2 = new MyClassLoader(); Class<?> aClass2 = cl2.findClass("loader.Person"); aClass2.newInstance(); // Person init! System.out.println("--->" + aClass1); // class loader.Person System.out.println("--->" + aClass2); // class loader.Person System.out.println("--->" + aClass1.getClassLoader()); // loader.MyClassLoader@60e53b93 System.out.println("--->" + aClass2.getClassLoader()); // loader.MyClassLoader@1d44bcfa System.out.println("--->" + aClass1.equals(aClass2)); // false }
這裡創建了兩個自定類載入器 MyClassLoader 的實例,分別用它們來載入 Person 類。
雖然兩個列印結果都是 class loader.Person ,但類載入器不同,導致 equals 方法的結果是 false,原因就是二者使用了不同的類載入器。
根據 MyClassLoader 的程式碼,這裡實際並未按照雙親委派模型的層級結構去載入 Person 類,而是直接使用了 MyClassLoader 來載入的。
- 測試類載入 eg.2
上述程式碼中,如果使用同一個類載入器進行載入呢?修改程式碼如下:
private static void test3() throws Exception { MyClassLoader cl1 = new MyClassLoader(); Class<?> aClass1 = cl1.findClass("loader.Person"); aClass1.newInstance(); // 這裡改用上面的類載入進行載入呢? Class<?> aClass2 = cl1.findClass("loader.Person"); aClass2.newInstance(); System.out.println("--->" + aClass1); System.out.println("--->" + aClass2); System.out.println("--->" + aClass1.equals(aClass2)); // true ?? }
這樣的比較結果會是 true 嗎?似乎應該是的吧。。
然而,這樣會報錯的:
Exception in thread "main" java.lang.LinkageError: loader (instance of loader/MyClassLoader): attempted duplicate class definition for name: "loader/Person" at java.lang.ClassLoader.defineClass1(Native Method) at java.lang.ClassLoader.defineClass(ClassLoader.java:763) at java.lang.ClassLoader.defineClass(ClassLoader.java:642) at loader.MyClassLoader.findClass(MyClassLoader.java:21) at loader.TestClassLoader.test1(TestClassLoader.java:61) at loader.TestClassLoader.main(TestClassLoader.java:10)
原因是:一個類載入器不能多次載入同一個類。

