概述
深入了解了Class文件存儲格式的具體細節后,虛擬機如何加載這些Class文件?Class文件中的信息進入虛擬機后會發生什么變化?這是作者第七章講解的內容。
虛擬機把描述類的數據從Class文件加載到內存,并對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制。
類加載都是在程序運行期間完成的,雖然會增加程序一點性能開銷,但能為 Java 應用提供高度的靈活性。通過依賴運行期動態加載和動態連接特點使 Java 具備動態擴展的語言特性。例如:
- 編寫面向接口的應用程序,可以等到運行時再指定其實際的實現類
- 通過 Java 預定義的和自定義類加載器在運行時從其他地方加載二進制流作為程序代買的一部分(Applet、JSP、OSGi 技術)
類加載的時機
類的生命周期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)共 7 個階段。
其中驗證、準備、解析 3 個部分統稱為連接(Linking),這 7 個階段的發生順序如下圖所示:
什么時候開始類加載過程的第一個階段:加載?
Java 虛擬機規范并沒有強制規定加載(Loading)的時機。但嚴格規定有且只有在以下 5 種情況時如果類沒有初始化,則需要先觸發其初始化(Initialization)。
初始化之前,自然會進行加載和連接。
- 遇到 new(實例化對象)、getstatic(讀取除常量外靜態字段)、putstatic(設置讀取除常量外靜態字段) 或 invokestatic(調用類的靜態方法) 這 4 條字節碼指令時,所在的類需要初始化。
- 使用 java.lang.reflect 包的方法對類進行反射調用的時。
- 初始化一個類時,如果其父類沒有初始化,則需先初始化其父類(接口除外,只有使用到父接口的時候才會初始化)。
- 虛擬機啟動時會先初始化用戶指定的執行主類(包含 main 方法的類)。
- 使用 JDK1.7 動態語言支持時,java.lang.invoke.MethodHandle 實例最后解析的結果為 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄時,則這個方法句柄對應的類需要初始化。
上述 5 種場景中的行為成為對一個類主動引用。除了主動引用之外,所有引用類的方式都不會觸發類的初始化,稱為被動引用。
類加載的過程
接下來講解加載、驗證、準備、解析和初始化這 5 個階段所執行的具體動作。
加載
加載是類加載過程第一個階段。在加載階段虛擬機要做 3 件事:
- 通過一個類的全限定名來獲取定義此類的二進制流。
- 可以從壓縮包中讀取,如:JAR、EAR、WAR 格式。
- 從網絡讀取,如:Applet 。
- 運行時計算生成,如:動態代理技術在 java.lang.reflect.Proxy 中,通過 ProxyGenerator.generateProxyClass為特定接口生成形式為「*$Proxy」的代理類的二進制字節流。
- 由其他文件生成,如:JSP 應用通過 JSP 文件生成對應的 Class 類。
- 從數據庫中讀取,如:中間件服務器 SAP Netweaver 可以選擇把程序安裝到數據庫中來完成程序代碼在集群間分發。
……
- 將這個字節流所代表的靜態存儲結構轉化為方法區運行時數據結構。
- 在內存中生成一個代表這個類的 java.lang.Class 對象,作為方法區這個類的各種數據的訪問入口。
獲取類的二進制流是開發人員可控性最強的,既可以通過系統提供的啟動類加載器完成,也可以由用戶自定義類加載器來控制字節流的獲取方式(重寫類加載器的 loadClass 方法)。
數組類比較特殊,它不通過類加載器創建,而是由 Java 虛擬機直接創建。但數組的元素類型(Element Type)最終是要靠類加載器創建。
加載階段完成后,虛擬機外部的二進制字節流就會按照所需的格式存儲在方法區中,存儲格式由虛擬機自行定義。然后在內存中實例化一個 java.lang.Class 對象(并沒有在堆中,Class 對象雖然是對象,但在 HotSpot虛擬機中是存放在方法區里),這個對象將作為程序訪問方法區中這些類型數據的外部接口。
加載階段與連接階段的驗證中一部分字節碼文件格式驗證動作是交叉進行的,加載階段尚未完成,連接階段就可能已經開始。這兩個階段總體的開始時間仍然保持固定的先后順序。
驗證
驗證階段的目的是保證 Class 文件的字節流中包含的信息符合當前虛擬機的要求,并不會危害虛擬機的安全。
如加載階段所述,Class 文件并不一定是 Java 源碼編譯而來,甚至可以用 16 進制編輯器直接編寫。虛擬機如果不進行字節流驗證,可能因載入有害字節流而導致系統崩潰。
驗證階段大致上會完成 4 個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證
文件格式驗證
驗證字節流是否符合 Class 文件格式規范,能被當前版本的虛擬機處理。可能包括以下驗證點:
- 是否魔數以 0xCAFEBABE 開頭。
- 主、次版本號是否在當前虛擬機的處理范圍內。
- 常量池中是否有不支持的常量類型(檢查常量 tag 標志)。
- CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的數據。
- Class 文件中各個部分及文件本身是否有被刪除或附加的其他信息
……
此階段的驗證是基于二進制字節流,只有通過了這個階段后,字節流才會進入方法區內進行存儲。后面的 3 個階段驗證全都是基于方法區的存儲結構進行的,不會再操作字節流。
元數據驗證
對字節碼描述信息進行語義分析,確保其描述信息符合 Java 語言規范的要求。可能包括以下驗證點:
- 是否有父類(除了 java.lang.Object 之外所有類都有父類)。
- 父類是否繼承了不允許被繼承的類(被 final 修飾的類)。
- 如果不是抽象類,是否實現了父類或接口中要求實現的方法。
- 類中的字段、方法是否與父類產生矛盾(如覆蓋了父類的 final 字段,或出現不符合規則的方法重載,例如方法參數一樣,但返回值不同等)
……
字節碼驗證
驗證階段最復雜的階段,主要目的是通過數據流和控制流分析,確定程序的語義是合法的、符合邏輯的。在對元數據信息中的數據類型做完校驗后對方法體進行校驗分析,保證在運行時方法不會做出危害虛擬機安全的事件。
由于數據流驗證的高復雜性,為避免過多的時間消耗,JDK 1.6 以后 Javac 編譯器和 Java虛擬機進行了一項優化,給方法體的 Code 屬性的屬性表增加了一項名為 StackMapTable 屬性。這個屬性描述了方法體中所有的基本塊(按控制流拆分的代碼塊)開始時本地變量表和操作數棧應有的狀態。在字節碼驗證階段就不需要根據程序推導狀態合法性,只要檢查 StackMapTable 屬性中的記錄是否合法即可。
理論上 StackMapTable 屬性也存在被篡改的可能。有可能在惡意篡改 Code 屬性的同事生成相應的 StackMapTable屬性來騙過虛擬機類型校驗。
符號引用驗證
驗證目的是確保符號引用轉直接引用在解析階段能正常執行。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的信息進行匹配性校驗。通常需要校驗一下內容:
- 符號引用中通過字符串描述的全限定名是否能找到對應的類。
- 在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段。
- 符號引用中農的類、字段、方法的訪問性是否可被當前類訪問
……
如果無法通過符號引用驗證,將會拋出 java.lang.IncompatibleClassChangeError 異常的子類,如 java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError 等。
雖然驗證階段十分重要,但如果能保證自己編寫的以及第三方包中代碼都已經反復使用和驗證過,可以使用-Xverify:none
參數來關閉大部分的驗證措施,以縮短加載時間。
準備
準備階段是正式為類變量(除常量外,被 static 修飾的變量)在方法區分配內存并設置類變量的初始值(數字類型為 0,布爾類型為 false,引用類型為 null……)階段。實例變量在準備階段是不會設值的,而是在對象實例化時隨著對象一起分配在 Java
堆中。
例如在下面的代碼中,類變量 value 在準備階段后值為 0 而不是 123。因為此時尚未執行任何 Java 方法,把 123 賦值給 value 的 putstatic 指令是被程序編譯后存放在類構造器 <client>() 方法中的,這個方法只有在初始化階段才會執行。
public static int value = 123;
如果這個變量是常量,類字段的字段屬性表中存在 ConstantValue 屬性,那么準備階段變量 value 就會被初始化為 ConstantValue 屬性所指定的值。例如:
public final static int value = 123;
編譯時 Javac 將會為 value 生成 ConstantValue 屬性,在準備階段虛擬機就會根據 ConstantValue 值把 value 賦值為 123。
解析
解析階段是將虛擬機常量池內的符號引用替換為直接引用的過程。
符號引用(Symbolic Reference):以一組符號來描述引用的目標,符號的字面量形式明確的定義在 Java 虛擬機規范中的 Class 文件格式中。
直接引用(Direct Reference):直接引用可以是直接指向目標的指針、相對偏移量或者是一個間接能定位到目標的句柄。如果有了直接引用,那目標一定存在于內存中。
虛擬機規范沒有規定解析階段發生的具體時間,可以自行決定到底在類被加載器加載時就對常量池中的符號要引用進行解析,還是等到一個符號引用將要被使用前才去解析他。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行。后 3 種符號引用與 JDK 1.7 新增的動態語言支持息息相關。
類或接口的解析
完成類或接口中的符號引用解析,在對符號引用的類加載過程中可能觸發相關類的加載動作,所有類加載完成后如果沒有異常,還需要進行符號引用的訪問權限驗證。
字段解析
解析字段符號引用首先會解析字段所屬的類或接口的符號引用,如果沒有異常,虛擬機規范要求按如下步驟進行搜索:
- 如果所屬類本身就包含簡單名稱和字段描述符與該字段匹配的字段,結束并返回這個字段。
- 否則,按照繼承關系從下往上遞歸搜索各個父接口,如果包含了匹配字段,結束返回。
- 否則,按繼承關系從下往上遞歸搜索父類,如果包含了匹配字段,結束并返回。
- 否則,查找失敗拋出 java.lang.NoSuchFieldError 異常。
查找完成后會對字段進行訪問權限驗證,沒有權限時拋出 java.lang.IllegalAccessError 異常。
類方法解析
類方法解析與字段解析第一步一樣,先解析類方法所在的類或接口的符號引用,如果沒有異常,虛擬機規范要求按如下步驟進行搜索:
- 類方法與接口方法的常量類型定義不同,如果發現類方法表中 class_index 中索引的是接口方法,直接拋出 java.lang.IncompatibleClassChangeError 異常。
- 如果在所在類中找到與目標簡單名稱和描述符相同的方法(以下簡稱「匹配」),直接返回這個方法的直接引用,查找結束。
- 否則,在類的父類中遞歸查找是否有匹配的方法,如果有返回方法的直接引用,查找結束。
- 否則,在類實現的接口列表中及他們的父接口中遞歸查找是否有匹配的方法,如果有,證明此類是個抽象類,查找結束,拋出 java.lang.AbastractMethodError 異常。
- 否則,宣布查找方法失敗,拋出 java.lang.NoSuchMethodError 異常。
最后對類方法進行訪問權限驗證,沒有權限時拋出 java.lang.IllegalAccessError 異常。
接口方法解析
與類方法解析第一步一樣,先解析類方法所屬類或接口的符號引用。如果解析成功,虛擬機規范要求按如下步驟進行搜索:
- 與類方法解析不同,如果在接口方法表中發現 class_index 中索引的是類方法,那就直接拋出 java.lang.IncompatibleClassChangeError 異常。
- 否則,在接口中查找是否存在匹配的方法,如果存在直接返回接口方法的直接引用,查找結束。
- 否則,在接口的父接口中遞歸查找,直到 java.lang.Object 類(查找范圍可能會包括 Object 類)為止,搜索是否有匹配的方法,如果有返回這個接口發方法的直接引用,查找結束。
- 否則,宣告查找失敗,拋出 java.lang.NoSuchMethodError 異常。
由于接口方法都是 public 修飾的,因此不需要進行訪問權限判斷。
初始化
在整個類加載的過程中,除了加載階段用戶應用程序可以自定義類加載器進行控制,其余的階段都是有虛擬機主導完成的。到了初始化階段才真正開始執行類中定義的 Java 程序代碼(或者說是字節碼)。
在準備階段類變量已經賦過一次初始值。在初始化階段虛擬機會根據程序員的代碼去初始化類變量和其他資源。或者說初始化的過程是執行 <clinit>() 方法的過程。
<clinit>() 方法是由編譯器按照源文件中出現的順序,自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合并而成的。靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在他之后的變量只能賦值不能讀取。
// 非法向前引用變量代碼示例
public Class Test{
static {
i = 0; //靜態塊中給后面的類變量賦值可以通過編譯
System.out.println(i);//靜態塊中讀取定義在后面的類變量編譯器會提示「非法向前引用」
}
static int i = 1;
}
<clinit>() 方法和類的構造函數(<init>() 方法)不同,不需要顯示地調用父類構造器,虛擬機會保證子類的 <clinit>() 方法執行前父類的該方法已經執行完畢。因此,虛擬機中第一個執行的 <clinit>() 方法一定是 java.lang.Object 類。
由于父類的 <clinit>() 方法先執行,則父類定義的靜態代碼塊先于子類的變量賦值操作。
如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成 <clinit>() 方法。
虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確的加鎖、同步,如果有多個線程去初始化同一個類,那么只會有一個線程去執行這個類的 <clinit>() 方法,其他線程都需要阻塞等待。如果一個類的 <clinit>() 方法中存在耗時很長的操作,很可能造成多線程阻塞。同一個類加載器只會加載一次類,因此多線程下只會執行一次 <clinit>() 方法。
類加載器
在虛擬機外部,把實現通過一個類的全限定名來獲取這個類的二進制字節流的動作的代碼模塊成為類加載器。類加載器在類層次劃分、OSGi、熱部署、代碼加密等領域大放異彩,成為 Java 技術體系中的重要基石。
類與類加載器
在 Java 虛擬機中,任一一個類的唯一性是由該類與其類加載器共同確立的。也就是說,同一個類在同一個虛擬機中,但在不同的類加載器中,那這兩個類則不相等。「相等」的判斷依據是 Class 對象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括 instanceof 關鍵字對對象所屬關系的判定等情況。在使用自定義類加載器時需要注意這點。
雙親委派模型
從 Java 開發人員的角度看,系統提供的類加載器可劃分為以下 3 種:
- 啟動類加載器(Bootstrap ClassLoader):這個類負責將存放在 <JAVA_HOME>\lib 目錄中的或者被 -Xbootclasspath 參數指定的路徑中指定名稱(例如 rt.jar)的類庫。啟動類加載器無法被 Java 程序直接引用,在自定義類加載器中,如果需要啟動類加載器來加載類,在需要傳入 ClassLoader 做參數的方法中直接把 null 作為程序的類加載器代替即可。例如,在如下方法的第 3 個參數傳 null 即可。
public static Class<?> forName(String name, boolean initialize,ClassLoader loader) throws ClassNotFoundException{
…
}
- 擴展類加載器(Extension ClassLoader):這個加載器由 sun.misc.Launcher$ExtClassLoader 實現,負責加載 <JAVA_HOME>\lib\ext 目錄中的,或者被 java.ext.dirs 系統變量所指定的路徑中的所有類庫,開發者可以直接使用擴展類加載器。
- 應用程序類加載器(Application ClassLoader):這個類加載器由 sun.misc.Launcher$AppClassLoader 實現。這個類加載器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也成為系統類加載器。負責加載用戶類路徑(classpath)上所指定的類庫,開發者可以直接使用。如果應用程序中沒有指定類加載器一般就作為默認類加載器。
此外,我們也可以自己定義的類加載器。這些類加載器關系一般如下圖所示:
雙親委派模型并不是強制性的約束模型,而是一種 Java 設計者推薦的類加載器實現方式。雙親委派模型工作過程是:當某個類加載器收到加載類請求,首先會把這個請求委派給父類加載器去完成,每一層都如此,直到啟動類加載器中,只有當父類加載器反饋無法加載時才會交由子類加載器去加載。
破壞雙親委派模型
- JNDI 服務需要加載 SPI 提供的代碼
雙親委派模型很好的解決了各個類加載器的基礎類的統一問題(基礎類都是上層類加載器加載的),但如果基礎類想要調用用戶代碼就無法實現。典型的場景是 JNDI 服務(Java Naming and Directory Interface,Java 命名和目錄接口),JNDI 的目的是對資源進行集中管理和查找,它需要由獨立廠商實現并部署在應用程序的 Classpath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代碼。但啟動類加載器不能加載這些代碼!怎么辦?
Java 通過線程上下文類加載(Thread Context ClassLoader)這個類加載器,可以再 java.lang.Thread 類的 setContextClassLoader() 方法進行設置,如果線程沒有設置則從其父線程中繼承一個,如果應用程序沒有全局都沒有設置過,則這個類加載器默認就是應用程序類加載器。
JNDI 服務可以使用線程上下文類加載 SPI 代碼,也就是父類加載器請求子類加載器去完成類加載動作,這打破了雙親委派模型的類層次結構。Java 中所有涉及 SPI 的加載動作基本都采用了這種方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
- 代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)
開發者對程序動態性的追求一直十分火熱,希望應用能像鼠標在電腦上熱拔插一樣,即插即用,不用重啟電腦。對應軟件開發上是希望不用重啟應用程序即可完成發布,熱部署對企業級軟件開發者有很大吸引力。OSGi 是目前 Java 業界的模塊化標準,它實現模塊化熱部署的關鍵原則是自定義類加載器的實現。每一個模塊都有一個類加載器,當需要更換一個模塊時,連同類加載器一起換掉以實現熱替換。OSGi 的類加載器結構不是雙親委派模型那樣的樹形結構,而發展成更為復雜的網狀結構。
OSGi 中類加載器的使用時很值得學習的,弄懂了 OSGi的實現,就可以算掌握了類加載器的精髓。
小結
本章作者介紹了類加載過程的「加載」、「驗證」、「準備」、「解析」和「初始化
」5 個階段中虛擬機的動作,還介紹了類加載器的工作原理以及對虛擬機的意義。