本系列主要記錄筆者在學(xué)習(xí) [深入理解Java虛擬機(jī)] 一書時(shí)的理解
我們都知道在Java中,我們并不需要過多的在意內(nèi)存的管理,這一切都交給了虛擬機(jī)自動管理,我們并不需要操心何時(shí)需要去釋放一個(gè)對象的內(nèi)存。
當(dāng)然,如果出現(xiàn)了內(nèi)存溢出或泄漏,我們就必須去了解一下Java虛擬機(jī)的內(nèi)存管理機(jī)制以便于我們解決問題
[筆者仍為Android初學(xué)者。如有解釋錯誤的地方,歡迎評論區(qū)指正探討]
本篇為該系列第五篇,深入了解Jvm的類加載機(jī)制。
類加載
虛擬機(jī)把描述類的數(shù)據(jù)從Class文件加載到內(nèi)存,并對數(shù)據(jù)進(jìn)行校驗(yàn)、轉(zhuǎn)換解析和初始化,最終形成可以被虛擬機(jī)直接使用的Java類型,這就是虛擬機(jī)的類加載機(jī)制。
類加載的過程
類從被加載到虛擬機(jī)內(nèi)存中開始,到卸載出內(nèi)存為止,它的整個(gè)生命周期包括:加載(Loading)、驗(yàn)證(Verification)、準(zhǔn)備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個(gè)階段。其中驗(yàn)證、準(zhǔn)備、解析3個(gè)部分統(tǒng)稱為連接(Linking)。

在整個(gè)過程中,加載,驗(yàn)證,準(zhǔn)備,初始化和卸載這五個(gè)步驟的開始順序是固定這樣的。而解析則不一定,他在某些情況下可以在初始化階段之后再開始,這是為了支持Java語言的運(yùn)行時(shí)綁定。
PS:這里的順序指的是開始順序,而不是進(jìn)行或完成,是因?yàn)檫@些階段通常都是互相交叉地混合式進(jìn)行的,通常會在一個(gè)階段執(zhí)行的過程中調(diào)用、激活另外一個(gè)階段。
Java虛擬機(jī)規(guī)范中并沒有進(jìn)行強(qiáng)制約束什么情況下需要開始第一個(gè)階段加載,這部分可以交由各個(gè)虛擬機(jī)的具體實(shí)現(xiàn)控制,而對于初始化階段則不同,虛擬機(jī)規(guī)范則是嚴(yán)格規(guī)定了有且只有5種情況必須立即對類進(jìn)行“初始化”(而加載、驗(yàn)證、準(zhǔn)備自然需要在此之前開始):
- 遇到
new、getstatic、putstatic或invokestatic這4條字節(jié)碼指令時(shí),如果類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關(guān)鍵字實(shí)例化對象的時(shí)候、讀取或設(shè)置一個(gè)類的靜態(tài)字段(被final修飾、已在編譯期把結(jié)果放入常量池的靜態(tài)字段除外)的時(shí)候,以及調(diào)用一個(gè)類的靜態(tài)方法的時(shí)候。 - 使用
java.lang.reflect包的方法對類進(jìn)行反射調(diào)用的時(shí)候,如果類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。 - 當(dāng)初始化一個(gè)類的時(shí)候,如果發(fā)現(xiàn)其父類還沒有進(jìn)行過初始化,則需要先觸發(fā)其父類的初始化。
- 當(dāng)虛擬機(jī)啟動時(shí),用戶需要指定一個(gè)要執(zhí)行的主類(包含main()方法的那個(gè)類),虛擬機(jī)會先初始化這個(gè)主類。
- 當(dāng)使用JDK 1.7的動態(tài)語言支持時(shí),如果一個(gè)
java.lang.invoke.MethodHandle實(shí)例最后的解析結(jié)果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個(gè)方法句柄所對應(yīng)的類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。
這5種場景中的行為稱為對一個(gè)類進(jìn)行主動引用。除此之外,所有引用類的方式都不會觸發(fā)初始化,稱為被動引用。
列舉一些被動引用的例子:
/**
*通過子類引用父類的靜態(tài)字段,不會導(dǎo)致子類初始化
**/
public class SuperClass{
public static int value=123;
static{
System.out.println("SuperClass init!");
}
}
public class SubClass extends SuperClass{
static{
System.out.println("SubClass init!");
}
}
/**
*非主動使用類字段演示
**/
public class MainClass {
public static void main(String[]args){
System.out.println(SubClass.value);
}
}
在HotSpot虛擬機(jī)的默認(rèn)情況下:
上述代碼運(yùn)行之后,只會輸出“SuperClass init!”,而不會輸出“SubClass init!”。
對于靜態(tài)字段,只有直接定義這個(gè)字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態(tài)字段,只會觸發(fā)父類的初始化而不會觸發(fā)子類的初始化。
/**
*通過數(shù)組定義來引用類,不會觸發(fā)此類的初始化
**/
public class SuperClass{
static{
System.out.println("SuperClass init!");
}
}
public class MainClass{
public static void main(String[]args){
SuperClass[]sca=new SuperClass[10];
}
}
通過數(shù)組定義來引用類,并不會觸發(fā)此類的初始化,當(dāng)卻會觸發(fā)另一個(gè)名為[LpackageName.SuperClass的初始化,有沒有覺得個(gè)類很熟悉,這不就是我們直接打印一個(gè)數(shù)組時(shí)出來的東西嗎?它是一個(gè)由虛擬機(jī)自動生成的、直接繼承于java.lang.Object的子類,創(chuàng)建動作由字節(jié)碼指令newarray觸發(fā)。這個(gè)類代表了一個(gè)元素類型為SuperClass的一維數(shù)組,數(shù)組中應(yīng)有的屬性和方法(用戶可直接使用的只有被修飾為public的length屬性和clone()方法)都實(shí)現(xiàn)在這個(gè)類里。Java語言中對數(shù)組的訪問比C/C++相對安全是因?yàn)檫@個(gè)類封裝了數(shù)組元素的訪問方法。
/**
*被動使用類字段演示三:
*常量在編譯階段會存入調(diào)用類的常量池中,本質(zhì)上并沒有直接引用到定義常量的類,因此不會觸發(fā)定義常量的類的初始化。
**/
public class ConstClass {
public static final String HELLOWORLD="hello world";
static{
System.out.println("ConstClass init!");
}
}
/**
*非主動使用類字段演示
**/
public class MainClass {
public static void main(String[]args){
System.out.println(ConstClass.HELLOWORLD);
}
}
上述代碼運(yùn)行之后,也沒有輸出“ConstClass init!”,這是因?yàn)殡m然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實(shí)在編譯階段通過常量傳播優(yōu)化,已經(jīng)將此常量的值“hello world”存儲到了MainClass類的常量池中,以后MainClass對常量ConstClass.HELLOWORLD的引用實(shí)際都被轉(zhuǎn)化為MainClass類對自身常量池的引用了。
接口的加載和類加載大致相同,一個(gè)比較大的區(qū)別在于,類加載要求父類都加載過才可加載子類,而接口并不需要父接口都加載過,只有在真正使用到父接口的時(shí)候(如引用接口中定義的常量)才會初始化。
大致了解完類加載的流程順序之后,我們來逐步了解每一步都做了什么。
加載
加載,是類加載的第一個(gè)過程,在加載階段,虛擬機(jī)會完成一下3件事情:
- 通過類的全限定名來獲取定義此類的的二進(jìn)制字節(jié)流。
- 將這個(gè)字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運(yùn)行時(shí)存儲結(jié)構(gòu)
- 在內(nèi)存中生成一個(gè)代表這個(gè)類的Class對象,作為方法區(qū)這個(gè)類的各種數(shù)據(jù)的訪問入口。
對于開發(fā)人員來說,這一步驟是相當(dāng)自由,虛擬機(jī)只限制了要通過一個(gè)類的全限定名來獲取此類的二進(jìn)制字節(jié)流,并沒有限制,這部分流應(yīng)該從哪獲取。
對于一個(gè)非數(shù)組類的加載階段,開發(fā)人員即可以使用系統(tǒng)提供的類加載器去完成,也可以自定義一個(gè)類加載器去完成,(重寫一個(gè)加載的loadClass方法)。也就是說我們可以自己解析各種類型的文件或者其他數(shù)據(jù)來獲取一個(gè)類。
類加載器這部分將在后面的篇章講解。
而對于數(shù)組類(指的是[LPackageName.YourClass,而不是數(shù)組中的元素),則有所不同,因?yàn)閿?shù)組類本身不通過類加載器構(gòu)建,他是由虛擬機(jī)直接創(chuàng)建。
PS:數(shù)組的元素最終還是會通過類加載器加載。
加載階段完成之后,虛擬機(jī)外部的二進(jìn)制字節(jié)流就按照虛擬機(jī)所需的格式存儲在方法區(qū)之中。然后在內(nèi)存中實(shí)例化一個(gè)java.lang.Class類的對象(在HotSpot虛擬機(jī)中,這個(gè)對象存放在方法區(qū)),作為程序訪問方法區(qū)中的這些類型數(shù)據(jù)的外部接口。
加載階段與連接階段的部分內(nèi)容(如一部分字節(jié)碼文件格式驗(yàn)證動作)是交叉進(jìn)行的,加載階段尚未完成,連接階段可能已經(jīng)開始,但這些夾在加載階段之中進(jìn)行的動作,仍然屬于連接階段的內(nèi)容,這兩個(gè)階段的開始時(shí)間仍然保持著固定的先后順序。
驗(yàn)證
驗(yàn)證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節(jié)流中包含的信息符合當(dāng)前虛擬機(jī)的要求,并且不會危害虛擬機(jī)自身的安全。
我們前面提到,加載的時(shí)候,可以構(gòu)建自定義的類加載器,從指定的數(shù)據(jù)源中獲取一個(gè)類,那么就存在一定的問題,我們完全可以無視Java語言層的語法,自己生成class文件,實(shí)現(xiàn)Java語法不允許的操作,很可能會因?yàn)檩d入了有害的字節(jié)流而導(dǎo)致系統(tǒng)崩潰,所以驗(yàn)證是虛擬機(jī)對自身保護(hù)的一項(xiàng)重要工作。
驗(yàn)證階段十分重要,需要檢驗(yàn)的東西也很多,但從整體上看,驗(yàn)證階段大致上會完成下面4個(gè)階段的檢驗(yàn)動作:文件格式驗(yàn)證、元數(shù)據(jù)驗(yàn)證、字節(jié)碼驗(yàn)證、符號引用驗(yàn)證。
-
文件格式驗(yàn)證
這一階段要驗(yàn)證字節(jié)流是否符合Class文件格式的規(guī)范,主要基于二進(jìn)制文件流進(jìn)行,該階段的主要目的是保證輸入的字節(jié)流能正確的解析并存儲于方法區(qū)內(nèi)。只有過了這個(gè)階段的檢驗(yàn),字節(jié)流才會進(jìn)行內(nèi)存的方法區(qū)中進(jìn)行存儲。而后的三個(gè)檢驗(yàn)都是基于方法區(qū)的存儲結(jié)構(gòu)進(jìn)行的,不會再操作字節(jié)流。 -
元數(shù)據(jù)驗(yàn)證
第二階段是對字節(jié)碼描述的信息進(jìn)行語義分析,以保證其描述的信息符合Java語言規(guī)范的要求。的主要目的是對類的元數(shù)據(jù)信息進(jìn)行語義校驗(yàn),保證不存在不符合Java語言規(guī)范的元數(shù)據(jù)信息。 -
字節(jié)碼驗(yàn)證
第三階段是整個(gè)驗(yàn)證過程中最復(fù)雜的一個(gè)階段,主要目的是通過數(shù)據(jù)流和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數(shù)據(jù)信息中的數(shù)據(jù)類型做完校驗(yàn)后,這個(gè)階段將對類的方法體進(jìn)行校驗(yàn)分析,保證被校驗(yàn)類的方法在運(yùn)行時(shí)不會做出危害虛擬機(jī)安全的事件,譬如在操作棧放置了一個(gè)int類型的數(shù)據(jù),使用時(shí)卻按long類型來加載入本地變量表中。這顯然是危險(xiǎn)的。 -
符號引用驗(yàn)證
最后一個(gè)階段的校驗(yàn)發(fā)生在虛擬機(jī)將符號引用轉(zhuǎn)化為直接引用的時(shí)候,這個(gè)轉(zhuǎn)化動作將在連接的第三階段——解析階段中發(fā)生。符號引用驗(yàn)證可以看做是對類自身以外(常量池中的各種符號引用)的信息進(jìn)行匹配性校驗(yàn),確保了解析動作能夠正常執(zhí)行,否則將有可能出現(xiàn)根據(jù)符號引用無法找到對應(yīng)類的情況。
準(zhǔn)備
準(zhǔn)備階段是正式為類變量分配內(nèi)存并設(shè)置類變量初始值的階段,這些變量所使用的內(nèi)存都將在方法區(qū)中進(jìn)行分配。
PS:這里提到的是類變量,指的是靜態(tài)變量,而普通變量并不會在這一階段賦值。而且這里提到的初始值,值得是零值,具體設(shè)置的值將在初始化階段設(shè)置。
特殊的是,如果這是個(gè)靜態(tài)常量,那么就不是直接賦予零值,而是直接設(shè)置具體的值。
解析
解析是虛擬機(jī)將常量池內(nèi)的符號引用替換為直接引用的過程。
- 符號引用指的是以一組符號來描述所引用的目標(biāo),符號可以是任何形式的字面量,只要使用時(shí)能無歧義地定位到目標(biāo)即可。
- 直接引用可以是直接指向目標(biāo)的指針、相對偏移量或是一個(gè)能間接定位到目標(biāo)的句柄。
前面已經(jīng)提到,解析階段可以先執(zhí)行也可以慢執(zhí)行,所以虛擬機(jī)實(shí)現(xiàn)可
以根據(jù)需要來判斷到底是在類被加載器加載時(shí)就對常量池中的符號引用進(jìn)行解析,還是等到一個(gè)符號引用將要被使用前才去解析它。
對于一個(gè)符號引用進(jìn)行多次解析是很正常的事情,所以虛擬機(jī)會對第一次的解析結(jié)果進(jìn)行緩存,如果一個(gè)符號引用之前已經(jīng)被成功解析過,那么后續(xù)的引用解析請求就應(yīng)當(dāng)一直成功;同樣的,如果第一次解析失敗了,那么其他指令對這個(gè)符號的解析請求也應(yīng)該收到相同的異常。
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調(diào)用點(diǎn)限定符7類符號引用進(jìn)行,分別對應(yīng)于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7種常量類型。
初始化
初始化時(shí)類加載的最后一步,前面的類加載過程中,除了在加載階段用戶應(yīng)用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機(jī)主導(dǎo)和控制。到了初始化階段,才真正開始執(zhí)行類中定義的Java程序代碼(或者說是字節(jié)碼)。
在準(zhǔn)備階段,變量已經(jīng)賦過一次系統(tǒng)要求的初始值(零值),而在初始化階段,則根據(jù)程序員通過程序制定的主觀計(jì)劃去初始化類變量和其他資源。
從代碼的角度來說,初始化階段是執(zhí)行類構(gòu)造器<clinit>()方法的過程。(不同構(gòu)造函數(shù)<init>())
虛擬機(jī)會保證,在子類的<clinit>()方法執(zhí)行之前,父類的<clinit>()方法已經(jīng)執(zhí)行完畢。因此在虛擬機(jī)中第一個(gè)被執(zhí)行的<clinit>()方法的類肯定是java.lang.Object。特殊的是,接口并不需要先執(zhí)行父接口的<clinit>(),而是等到父接口被使用時(shí)才初始化。
<clinit>()方法對于類或接口來說并不是必需的,如果一個(gè)類中沒有靜態(tài)語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個(gè)類生成<clinit>()方法。
小結(jié)
簡單了解一下類加載的流程,加載、驗(yàn)證、準(zhǔn)備、解析、初始化、使用和卸載?,F(xiàn)在是不是對于一個(gè)如何載入虛擬機(jī)更加清晰?
由于類加載部分在之前已經(jīng)做過筆記,這里不在重復(fù)施工。