文章首發於微信公眾號:BaronTalk
上一篇文章我們介紹了「類文件結構」,這一篇我們來看看虛擬機是如何加載類的。
我們的源代碼經過編譯器編譯成字節碼之后,最終都需要加載到虛擬機之后才能運行。虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制。
與編譯時需要進行連接工作的語言不同,Java 語言中類的加載、連接和初始化都是在程序運行期間完成的,這種策略雖然會讓類加載時增加一些性能開銷,但是會為 Java 應用程序提供高度的靈活性,Java 里天生可動態擴展的語言特性就是依賴運行期間動態加載和動態連接的特點實現的。
例如,一個面向接口的應用程序,可以等到運行時再指定實際的實現類;用戶可以通過 Java 預定義的和自定義的類加載器,讓一個本地的應用程序運行從網絡上或其它地方加載一個二進制流作為程序代碼的一部分。
一. 類加載時機
類從被虛擬機從加載到卸載,整個生命周期包含:加載(Loading)、驗證(Verification)、准備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸載(Unloading)7 個階段。其中驗證、准備、解析 3 個部分統稱為連接(Linking)。這 7 個階段的發生順序如下圖:

上圖中加載、驗證、准備、初始化和卸載 5 個階段的順序是確定的,類的加載過程必須按照這種順序按部就班的開始「注意,這里說的是按部就班的開始,並不要求前一階段執行完才能進入下一階段」,而解析階段則不一定:它在某些情況下可以在初始化階段之后再開始,這是為了支持 Java 的動態綁定。
虛擬機規范中對於什么時候開始類加載過程的第一節點「加載」並沒有強制約束。但是對於「初始化」階段,虛擬機則是嚴格規定了有且只有以下 5 種情況,如果類沒有進行初始化,則必須立即對類進行「初始化」(加載、驗證、准備自然需要在此之前開始):
- 遇到 new、getstatic、putstatic 或 invokestatic 這 4 條字節碼指令;
- 使用 java.lang.reflect 包的方法對類進行反射調用的時候;
- 當初始化一個類的時候,發現其父類還沒有進行初始化的時候,需要先觸發其父類的初始化;
- 當虛擬機啟動時,用戶需要指定一個要執行的主類,虛擬機會先初始化這個類;
- 當使用 JDK 1.7 的動態語言支持時,如果一個 java.lang.invoke.MethodHandle 實例最后的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,並且這個方法句柄所對應的類沒有初始化。
「有且只有」以上 5 種場景會觸發類的初始化,這 5 種場景中的行為稱為對一個類的主動引用。除此之外,所有引用類的方式都不會觸發初始化,稱為被動引用。比如如下幾種場景就是被動引用:
- 通過子類引用父類的靜態字段,不會導致子類的初始化;
- 通過數組定義來引用類,不會觸發此類的初始化;
- 常量在編譯階段會存入調用類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化;
二. 類加載過程
加載
這里的「加載」是指「類加載」過程的一個階段。在加載階段,虛擬機需要完成以下 3 件事:
- 通過一個類的全限定名來獲取定義此類的二進制字節流;
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構;
- 在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。
驗證
驗證是連接階段的第一步,這一階段的目的是為了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。驗證階段大致上會完成下面 4 個階段的檢驗動作:
-
文件格式驗證:第一階段要驗證字節流是否符合 Class 文件格式的規范,並且能夠被當前版本的虛擬機處理。驗證點主要包括:是否以魔數 0xCAFEBABE 開頭;主、次版本號是否在當前虛擬機處理范圍之內;常量池的常量中是否有不被支持的常量類型;Class 文件中各個部分及文件本身是否有被刪除的或者附加的其它信息等等。
-
元數據驗證:第二階段是對字節碼描述的信息進行語義分析,以保證其描述的信息符合 Java 語言規范的要求,這個階段的驗證點包括:這個類是否有父類;這個類的父類是否繼承了不允許被繼承的類;如果這個類不是抽象類,是否實現了其父類或者接口之中要求實現的所有方法;類中的字段、方法是否與父類產生矛盾等等。
-
字節碼驗證:第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流和控制流分析,確定程序語義是合法的、符合邏輯的。
-
符號引用驗證:最后一個階段的校驗發生在虛擬機將符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段--解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的形象進行匹配性校驗。
准備
准備階段是正式為類變量分配內存並設置類變量初始值的階段,這些變量所使用的內存都將在方法區進行分配。這個階段中有兩個容易產生混淆的概念需要強調下:
-
首先,這時候進行內存分配的僅包括類變量(被 static 修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨着對象一起分配在 Java 堆中;
-
其次這里所說的初始值「通常情況」下是數據類型的零值。假設一個類變量的定義為
public static int value = 123;
那么變量value
在准備階段過后的初始值為 0 而不是 123,因為這個時候尚未執行任何 Java 方法,而把 value 賦值為 123 的 putstatic 指令是程序被編譯之后,存放於類構造器 () 方法之中,所以把 value 賦值為 123 的動作將在初始化階段才會執行。
這里提到,在「通常情況」下初始值是零值,那相對的會有一些「特殊情況」:如果類字段的字段屬性表中存在 ConstantsValue 屬性,那在准備階段變量 value 就會被初始化為 ConstantValue 屬性所指的值。假設上面的類變量 value 的定義變為 public static final int value = 123;
,編譯時 JavaC 將會為 value 生成 ConstantValue 屬性,在准備階段虛擬機就會根據 ConstantValue 的設置將 value 賦值為 123。
解析
解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程。前面提到過很多次符號引用和直接引用,那么到底什么是符號引用和直接引用呢?
-
符號引用(Symbolic Reference):符號引用以一組符號來描述所引用的目標,符號可以上任何形式的字面量,只要使用時能無歧義地定位到目標即可。
-
直接引用(Direct Reference):直接引用可以是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。
初始化
類初始化階段是類加載過程中的最后一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其余動作完全是由虛擬機主導和控制的。到了初始化階段,才真正開始執行類中定義的 Java 程序代碼。初始階段是執行類構造器 () 方法的過程。
三. 類加載器
虛擬機設計團隊把類加載階段中的「通過一個類的全限定名來獲取描述此類的二進制字節流」這個動作放到 Java 虛擬機外部去實現,以便讓應用程序自己決定如何去獲取所需要的類。實現這個動作的代碼模塊稱為「類加載器」。
類與類加載器
對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在 Java 虛擬機的唯一性,每個類加載器都擁有一個獨立的類名稱空間。也就是說:比較兩個類是否「相等」,只要在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
雙親委派模型
從 Java 虛擬機的角度來講,只存在兩種不同的類加載器:一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器使用 C++ 來實現,是虛擬機自身的一部分;另一種就是所有其他的類加載器,這些類加載器都由 Java 來實現,獨立於虛擬機外部,並且全都繼承自抽象類 java.lang.ClassLoader
。
從 Java 開發者的角度來看,類加載器可以划分為:
-
啟動類加載器(Bootstrap ClassLoader):這個類加載器負責將存放在 <java_home>\lib 目錄中的類庫加載到虛擬機內存中。啟動類加載器無法被 Java 程序直接引用,用戶在編寫自定義類加載器時,如果需要把加載請求委派給啟動類加載器,那直接使用 null 代替即可;
-
擴展類加載器(Extension ClassLoader):這個類加載器由
sun.misc.Launcher$ExtClassLoader
實現,它負責加載 <java_home>\lib\ext 目錄中,或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器; -
應用程序類加載器(Application ClassLoader):這個類加載器由
sun.misc.Launcher$App-ClassLoader
實現。getSystemClassLoader()
方法返回的就是這個類加載器,因此也被稱為系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫。開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。
我們的應用程序都是由這 3 種類加載器互相配合進行加載的,在必要時還可以自己定義類加載器。它們的關系如下圖所示:

上圖中所呈現出的這種層次關系,稱為類加載器的雙親委派模型(Parents Delegation Model)。雙親委派模型要求除了頂層的啟動類加載器以外,其余的類加載器都應當有自己的父類加載器。
雙親委派模型的工作過程是這樣的:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都應該傳送到頂層的啟動類加載器中,只有當父類加載器反饋自己無法完成這個類加載請求(它的搜索范圍中沒有找到所需的類)時,子加載器才會嘗試自己去加載。
這樣做的好處就是 Java 類隨着它的類加載器一起具備了一種帶有優先級的層次關系。例如 java.lang.Object,它放在 rt.jar 中,無論哪一個類加載器要加載這個類,最終都是委派給處於模型頂端的啟動類加載器來加載,因此 Object 類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類加載器自行去加載的話,如果用戶自己編寫了一個稱為 java.lang.Object 的類,並放在程序的 ClassPath 中,那系統中將會出現多個不同的 Object 類,Java 類型體系中最基本的行為也就無法保證了。
雙親委派模型對於保證 Java 程序運行的穩定性很重要,但它的實現很簡單,實現雙親委派模型的代碼都集中在 java.lang.ClassLoader 的 loadClass() 方法中,邏輯很清晰:先檢查是否已經被加載過,若沒有則調用父類加載器的 loadClass() 方法,若父加載器為空則默認使用啟動類加載器作為父加載器。如果父類加載失敗,拋出 ClassNotFoundException 異常后,再調用自己的 findClass() 方法進行加載。
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 首先,檢查請求的類是不是已經被加載過 Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 如果父類拋出 ClassNotFoundException 說明父類加載器無法完成加載 } if (c == null) { // 如果父類加載器無法加載,則調用自己的 findClass 方法來進行類加載 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
關於類文件結構和類加載就通過連續的兩篇文章介紹到這里了,下一篇我們來聊聊「虛擬機的字節碼執行引擎」。
參考資料:
- 《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 2 版)》
如果你喜歡我的文章,就關注下我的公眾號 BaronTalk 、 知乎專欄 或者在 GitHub 上添個 Star 吧!
- 微信公眾號:BaronTalk
- 知乎專欄:https://zhuanlan.zhihu.com/baron
- GitHub:https://github.com/BaronZ88
