淺談 JVM 4:類加載機(jī)制

前文中我們了解了如何閱讀字節(jié)碼和字節(jié)碼執(zhí)行引擎中的運(yùn)行時(shí)棧幀結(jié)構(gòu),字節(jié)碼執(zhí)行引擎中的方法調(diào)用等內(nèi)容還未涉及。Java 中多態(tài)的特性離不開 JVM 的動態(tài)綁定,動態(tài)綁定技術(shù)是方法動態(tài)調(diào)用的關(guān)鍵。而如果要介紹方法調(diào)用機(jī)制,需要先了解字節(jié)碼在 JVM 中從加載到卸載的生命周期過程。
字節(jié)碼在 JVM 中從加載到卸載,經(jīng)歷了以下過程。

我們目前掌握了如何閱讀外部 class 字節(jié)碼的結(jié)構(gòu),和運(yùn)行時(shí)的部分內(nèi)容。前者屬于 “加載” 之前的內(nèi)容,后者屬于 “使用” 階段。本次我們就來看看從加載到使用之前的這個(gè)過程,這就是 JVM 的類加載機(jī)制。
外部字節(jié)碼的輸入有多種方式,比如本地 class 文件、網(wǎng)絡(luò)字節(jié)流、程序生成的字節(jié)碼內(nèi)容等。
Java 虛擬機(jī)將外部的 class 字節(jié)碼加載到內(nèi)存中,需要經(jīng)過加載、鏈接、初始化三個(gè)階段。
在了解類加載機(jī)制之前,我們先來簡單回顧下 JVM 邏輯區(qū)域的劃分。包括 5 部分:
?方法區(qū)?Java 堆?程序計(jì)數(shù)器?Java 棧?本地方法棧

加載
加載階段主要負(fù)責(zé)讀取外部的字節(jié)流,將字節(jié)流的存儲結(jié)構(gòu)轉(zhuǎn)化為運(yùn)行時(shí)數(shù)據(jù)結(jié)構(gòu),存儲在方法區(qū)中。同時(shí)在 Java 堆中創(chuàng)建對應(yīng)類的 java.lang.Class 對象,作為方法區(qū)數(shù)據(jù)的訪問入口。此時(shí)方法區(qū)中的運(yùn)行時(shí)數(shù)據(jù)結(jié)構(gòu)由虛擬機(jī)各自實(shí)現(xiàn)。
除了數(shù)組類字節(jié)流由虛擬機(jī)直接生成之外,其他類的加載主要由 ClassLoader 完成。ClassLoader 有啟動類加載器、擴(kuò)展類加載器、應(yīng)用類加載器,其結(jié)構(gòu)符合雙親委派模型,是指子類加載器在加載類時(shí)需要先交給父類加載器加載,如此層層傳遞,如果父類不處理則再交給子類處理。

啟動類加載器由 C++ 編寫,沒有 Java 對象。擴(kuò)展類加載器、應(yīng)用類加載器等均為 java.lang.ClassLoader 子類,需要由其他類加載器,如啟動類加載器,加載進(jìn)來。
啟動類加載器主要負(fù)責(zé)加載最為基礎(chǔ)、重要的類,如 JRE 下 lib 目錄中的 jar 包。擴(kuò)展類加載器負(fù)責(zé)加載 JRE 下 lib/ext 目錄中的通用、擴(kuò)展類。應(yīng)用類加載器主要加載應(yīng)用路徑下的類。
雙親委派模型保證了類加載過程的安全性和唯一性,因?yàn)槿魏巫宇惣虞d器(包括開發(fā)者自己實(shí)現(xiàn)的類加載器)在加載類之前,都需要先將類上交給父類加載器。一方面這保證了不同屬性類的加載功能劃分,比如 Object 類總會交給啟動類加載器加載,這讓無論哪個(gè)類加載器加載 Object,該類都能夠保證相同和唯一。另一方面,這保證了類加載的安全,開發(fā)者無法去偽造諸如 java.lang.Object 等基礎(chǔ)類來騙過 JVM,因?yàn)閮?nèi)置的 ClassLoader 做了相關(guān)安全校驗(yàn)工作。
需要注意的是,如果同一個(gè)字節(jié)碼交給不同的類加載實(shí)例加載,會得到兩個(gè)不同的類。
Java 9 模塊化的支持對 ClassLoader 做了些許修改,在此不再詳述。
鏈接
鏈接階段又分為驗(yàn)證、準(zhǔn)備、解析三個(gè)階段。
驗(yàn)證階段
驗(yàn)證階段主要負(fù)責(zé)驗(yàn)證上一步加載進(jìn)來的字節(jié)碼是否符合規(guī)范,保證不會危害 JVM 的安全。這一步是程序安全的重要保障,此階段的工作也在整個(gè)類加載過程中占有相當(dāng)?shù)谋戎?,主要包括字?jié)碼格式、語法、語義的驗(yàn)證工作。
準(zhǔn)備階段
準(zhǔn)備階段主要負(fù)責(zé)為類中的靜態(tài)字段分配內(nèi)存,并初始化為零值。此時(shí)僅會對類變量分配內(nèi)存,在 JDK 8 及之后,類變量內(nèi)存會在 Class 對象所處的 Java 堆中進(jìn)行分配。實(shí)例變量的內(nèi)存分配要等到對象實(shí)例化時(shí)。
解析階段
解析階段是將常量池內(nèi)的符號引用轉(zhuǎn)換為直接引用的過程。
在前文我們學(xué)習(xí)閱讀字節(jié)碼時(shí),常量池的常量使用索引表示,常量間的引用關(guān)系、方法字節(jié)碼對變量的獲取等操作都是通過引用索引來完成,這些索引稱為符號引用。符號引用只是表達(dá)變量間的邏輯引用關(guān)系,而實(shí)際運(yùn)行時(shí)變量、方法所處的內(nèi)存位置都不是固定的,所以在運(yùn)行字節(jié)碼前,需要對這些符號引用進(jìn)行解析成指向目標(biāo)的指針、偏移或句柄才行,這和運(yùn)行時(shí)具體的內(nèi)存布局有關(guān)。
當(dāng)然,解析階段并未完成所有符號引用的解析過程,對于類或接口、字段、類方法、接口方法的解析可以在此階段完成。但是對于方法類型、方法句柄和調(diào)用點(diǎn)限定符的解析則和動態(tài)語言的特性支持密切相關(guān),并不會在此階段完成。這是因?yàn)榉椒ǖ恼{(diào)用具體對應(yīng)哪個(gè)方法在此時(shí)還并未確定,要等到真正執(zhí)行時(shí)才能確定。
初始化
初始化階段是虛擬機(jī)開始執(zhí)行應(yīng)用程序代碼的開始,是執(zhí)行類構(gòu)造器??方法的過程。該方法由 Javac 編譯時(shí),通過收集類變量賦值語句和 static 代碼塊合并產(chǎn)生。
以如下 Java 代碼及其對應(yīng)??字節(jié)碼為例,可以發(fā)現(xiàn)類構(gòu)造器只會處理類變量和靜態(tài)代碼塊中內(nèi)容,而忽略實(shí)例變量。
// javaprivate static int a = 100;static {a = 1000;}private int mThisIsInt = 1024;// bytecodebipush 100putstatic #28sipush 1000putstatic #28return
在此我們可以提一下靜態(tài)內(nèi)部類單例模式的實(shí)現(xiàn)方式。
public class Singleton {private Singleton() {}private static class Holder {static final Singleton INSTANCE = new Singleton();}public static Singleton getInstance() {return Holder.INSTANCE;}}
內(nèi)部類?Holder?的初始化時(shí)機(jī)是該類的靜態(tài)字段調(diào)用之時(shí),此時(shí)觸發(fā)??方法的調(diào)用,進(jìn)而創(chuàng)建單例對象。JVM 調(diào)用該方法時(shí)會加鎖處理,保證該方法只會調(diào)用一次。該特性保證了單例的延遲加載和多線程安全。
總結(jié)
本次我們掌握了 JVM 類加載過程的三個(gè)階段:加載、鏈接、初始化。JVM 在邏輯上分為方法區(qū)、Java 堆、程序計(jì)數(shù)器、Java 棧、本地方法棧。方法區(qū)和 Java 堆主要用于存儲數(shù)據(jù),包括字節(jié)碼對應(yīng)數(shù)據(jù)結(jié)構(gòu)和運(yùn)行時(shí)的對象。其他部分則主要負(fù)責(zé)運(yùn)行時(shí)方法調(diào)用、計(jì)算等功能。
在了解了類加載機(jī)制、JVM 邏輯區(qū)域劃分和運(yùn)行時(shí)棧幀及方法字節(jié)碼執(zhí)行過程之后,下次有時(shí)間我們來聊聊方法間的調(diào)用是如何發(fā)生的。
