本文根據(jù)《深入理解java虛擬機(jī)》第7章內(nèi)容整理
一、基本概念
虛擬機(jī)把描述類的數(shù)據(jù)從Class文件加載到內(nèi)存,并對數(shù)據(jù)進(jìn)行校驗、轉(zhuǎn)換解析和初始化,最終形成可以被虛擬機(jī)直接使用的Java類型,這就是虛擬機(jī)的類加載機(jī)制。
與那些在編譯時需要進(jìn)行鏈接工作的語言不同,在Java語言里,類型的加載、連接和初始化過程都是在程序運(yùn)行期間完成的,這種策略雖然會令類加載時稍微增加一些性能開銷,但是會為Java應(yīng)用程序提供高度的靈活性,Java可以動態(tài)擴(kuò)展的語言特性就是依賴運(yùn)行期間動態(tài)加載和動態(tài)鏈接這個特點實現(xiàn)的。
類的生命周期:
類從被加載到虛擬機(jī)內(nèi)存中開始,到卸載出內(nèi)存為止,它的整個生命周期包括:加載、驗證、準(zhǔn)備、解析、初始化、使用和卸載7個階段。其中驗證、準(zhǔn)備、解析3個部分統(tǒng)稱為連接。

這些階段通常都是互相交叉地混合式進(jìn)行的,通常會在一個階段執(zhí)行的過程中調(diào)用、激活另一個階段。
例如:加載階段與連接階段的部分內(nèi)容(如一部分字節(jié)碼的文件格式驗證動作)是交叉進(jìn)行的,加載階段尚未完成,連接階段可能已經(jīng)開始,但這些夾在加載階段之中進(jìn)行的動作,仍然屬于連接階段的內(nèi)容,這兩個階段的開始時間仍然保持著固定的先后順序。
二、類加載的時機(jī)
Java虛擬機(jī)規(guī)范沒有強(qiáng)制性約束在什么時候開始類加載過程,但是對于初始化階段,虛擬機(jī)規(guī)范則嚴(yán)格規(guī)定了有且只有5種情況必需立即對類進(jìn)行“初始化”(而加載、驗證、準(zhǔn)備階段自然需要在此之前開始)。
遇到
new、getstatic、putstatic或invokestatic這4條字節(jié)碼指令時,如果類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。
生成這4條指令最常見的Java代碼場景是:使用new關(guān)鍵字實例化對象時、讀取或者設(shè)置一個類的靜態(tài)字段(被final修飾、已在編譯器把結(jié)果放入常量池的靜態(tài)字段除外)時、以及調(diào)用一個類的靜態(tài)方法的時候。使用
java.lang.reflect包的方法對類進(jìn)行反射調(diào)用的時候,如果類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。當(dāng)初始化一個類的時候,如果發(fā)現(xiàn)其父類還沒有進(jìn)行過初始化,則需要先觸發(fā)其父類的初始化。
當(dāng)虛擬機(jī)啟動時,用戶需要指定一個要執(zhí)行的主類(包含
main()方法的那個類),虛擬機(jī)會先初始化這個類。當(dāng)使用JDK1.7的動態(tài)語言支持時,如果一個
java.lang.invoke.MethodHandle實例最后的解析結(jié)果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且這個方法句柄所對應(yīng)的類沒有進(jìn)行過初始化,則需要先觸發(fā)其初始化。
對于這5種會觸發(fā)類進(jìn)行初始化的場景,在java虛擬機(jī)規(guī)范中限定了“有且只有”這5種場景會觸發(fā)。
這5種場景中的行為稱為對一個類的主動引用,除此以外的所有引用類的方式都不會觸發(fā)類的初始化,稱為被動引用。
被動引用示例:
- 通過子類引用父類的靜態(tài)字段,不會導(dǎo)致子類初始化。
public class SuperClass {
static{
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class SubClass extends SuperClass {
static{
System.out.println("SubClass init!");
}
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
SuperClass init!
123
對于靜態(tài)字段,只有直接定義這個字段的類才會被初始化,因此通過其子類來引用父類中定義的靜態(tài)字段,只會觸發(fā)父類的初始化而不會觸發(fā)子類的初始化。
- 通過數(shù)組定義來引用類,不會觸發(fā)此類的初始化。
public class SuperClass {
static{
System.out.println("SuperClass init!");
}
public static int value = 123;
}
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] scs = new SuperClass[10];
}
}
輸出結(jié)果為空
沒有輸出SuperClass init!說明沒有觸發(fā)類com.zm.classloading.SuperClass的初始化階段,但是這段代碼會觸發(fā)[Lcom.zm.classloading.SuperClass類的初始化階段。這個類是由虛擬機(jī)自動生成的,直接繼承于java.lang.Object的子類,創(chuàng)建動作由字節(jié)碼指令 newarray觸發(fā)。
- 常量在編譯階段會存入調(diào)用類的常量池中,本質(zhì)上并沒有直接引用到定義常量的類,因此不會觸發(fā)定義常量的類的初始化。
public class ConstClass {
static{
System.out.println("ConstClass init!");
}
public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLD);
}
}
hello world
雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段通過常量傳播優(yōu)化,已經(jīng)將此常量的值hello world存儲到了NotInitialization類的常量池中,以后NotInitialization對于常量ConstClass.HELLOWORLD的引用實際上都被轉(zhuǎn)化為NotInitialization類對自身常量池的引用了。實際上NotInitialization的Class文件之中已經(jīng)不存在ConstClass類的符號引用入口了。
接口的加載過程:
接口也有初始化過程,這與類是一致的,上述的代碼中都是使用靜態(tài)語句塊static{}來輸出初始化信息的,而接口中不能使用static{}語句塊,但編譯器仍然會為接口生成<clinit>()類構(gòu)造器,用于初始化接口中所定義的成員變量。
接口的加載過程與類加載的區(qū)別在于上面提到的5種“有且僅有”需要初始化場景中的第3種:當(dāng)一個類在初始化時要求其父類全部都已經(jīng)初始化過了,但是一個接口在初始化時,并不要求其父接口都全部完成了初始化,只有在真正用到父接口的時候(如引用父接口中定義的常量)才會初始化。
三、類加載的過程
下面我們來詳細(xì)了解類加載的全過程,也就是加載、驗證、準(zhǔn)備、解析和初始化這五個階段的過程。
-
加載
首先要說明的是“加載”(Loading)階段只是“類加載”(Class Loading)過程的一個階段。不要混淆了這兩個概念。在加載階段,虛擬機(jī)需要完成以下三件事情:1)通過一個類的全限定名來獲取定義此類的二進(jìn)制字節(jié)流。
2)將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運(yùn)行時數(shù)據(jù)結(jié)構(gòu)。
3)在內(nèi)存中生成一個代表這個類的java.lang.Class對象,作為方法區(qū)這個類各種數(shù)據(jù)的訪問入口。
相對于類加載過程的其他階段,一個非數(shù)組類的加載階段(準(zhǔn)確的說,是加載階段中獲取類的二進(jìn)制字節(jié)流的動作)是開發(fā)人員可控性最強(qiáng)的,因為該階段既可以使用系統(tǒng)提供的引導(dǎo)類加載器完成,也可以由用戶自定義的類加載器來完成,開發(fā)人員可以通過定義自己的類加載器去控制字節(jié)流的獲取方式。
對于數(shù)組類而言,數(shù)組類本身不通過類加載器創(chuàng)建,它是由虛擬機(jī)直接創(chuàng)建的。但是數(shù)組類的元數(shù)據(jù)類型最終還是要靠類加載器去創(chuàng)建的。
加載階段完成后,虛擬機(jī)外部的二進(jìn)制字節(jié)流就按照虛擬機(jī)所需的格式存儲在方法區(qū)之中,方法區(qū)中的數(shù)據(jù)存儲格式由JVM自行定義。然后在內(nèi)存中實例化一個java.lang.Class對象,這個對象將作為程序訪問方法區(qū)中這些類型數(shù)據(jù)的外部接口。(對于HotSpot虛擬機(jī)而言,Class對象比較特殊,它雖然是對象,但是存放在方法區(qū)里面)
-
驗證
驗證是連接階段的第一步,這一階段的目的是為了確保Class文件的字節(jié)流中包含的信息符合當(dāng)前虛擬機(jī)的要求,并且不會危害虛擬機(jī)自身的安全。
整體上看,驗證階段會完成下面4個階段的檢驗動作:文件格式驗證、元數(shù)據(jù)驗證、字節(jié)碼驗證和符號引用驗證。
- 1)文件格式驗證:這一階段要驗證字節(jié)流是否符合Class文件格式的規(guī)范,并且能被當(dāng)前版本的虛擬機(jī)處理。
- 是否以魔術(shù)0xCAFEBABE開頭;
- 主次版本號是否是在當(dāng)前虛擬機(jī)處理范圍之內(nèi);
- 常量池的常量中是否有不被支持的常量類型(檢查常量tag標(biāo)志);
. . .
第一階段的主要目的是保證輸入的字節(jié)流能正確地解析并存儲于方法區(qū)之內(nèi),格式上符合一個Java類型信息的要求。
這階段的驗證是基于二進(jìn)制字節(jié)流進(jìn)行的,只有通過了這個階段的驗證后,字節(jié)流才會進(jìn)入內(nèi)存的方法區(qū)中進(jìn)行存儲,所以后面的3個驗證階段全部是基于方法區(qū)的存儲結(jié)構(gòu)進(jìn)行的,不會再直接操作字節(jié)流。
- 2)元數(shù)據(jù)驗證:這一階段主要是對字節(jié)碼描述的信息進(jìn)行語義分析,以保證其描述的信息符合Java語言規(guī)范的要求。
- 這個類是否有父類;
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類);
- 如果這個類不是抽象類,是否實現(xiàn)了其父類或接口之中要求實現(xiàn)的所有方法;
- 類中的字段、方法是否與父類產(chǎn)生矛盾(如覆蓋了父類的final字段,不符合規(guī)則的重載);
. . .
第二階段的主要目的是對類的元數(shù)據(jù)信息進(jìn)行語義校驗,保證不存在不符合Java規(guī)范的元數(shù)據(jù)類型。
3)字節(jié)碼驗證:這一階段是整個驗證過程中最復(fù)雜的一個階段,主要目的是通過數(shù)據(jù)流和控制流分析,確定程序語義是合法的、符合邏輯的。
在第二階段對元數(shù)據(jù)信息中的數(shù)據(jù)類型做完校驗后,這階段將對類的方法體進(jìn)行校驗分析。保證被校驗類的方法在運(yùn)行時不會做出危害虛擬機(jī)安全的事件。保證跳轉(zhuǎn)指令不會跳轉(zhuǎn)到方法體之外的字節(jié)碼指令上;
保證方法體中的類型轉(zhuǎn)換是有效的,例如將子類對象賦給父類對象是安全的,但是把父類對象賦值給子類數(shù)據(jù)類型,甚至是和它毫無繼承關(guān)系的一個數(shù)據(jù)類型,則是危險和不安全的;
保證任意時刻操作數(shù)棧的數(shù)據(jù)類型與指令代碼序列都能配合工作,例如不會出現(xiàn)在操作數(shù)棧中放置了一個int類型數(shù)據(jù),使用時卻按long類型來加載人本地變量表中。
. . .4)符號引用驗證:這一階段主要是在虛擬機(jī)將符號引用轉(zhuǎn)化為直接引用的時候進(jìn)行校驗,這個轉(zhuǎn)化動作是發(fā)生在解析階段。符號引用可以看做是對類自身以外(常量池的各種符號引用)的信息進(jìn)行匹配性的校驗。
符號引用中通過字符串描述的全限定名是否能找到相應(yīng)的類;
在指定類中是否存在符合方法的字段描述符以及簡單名稱所描述方法和字段;
符號引用中的類、字段、方法的訪問性(
private、public、protected、default)是否可以被當(dāng)前類訪問;
. . .
符號引用驗證的目的是確保解析動作能正常執(zhí)行,如果無法通過符號引用驗證,那么將會拋出異常。
驗證階段對于虛擬機(jī)的類加載機(jī)制來說,是一個非常重要但不一定是必要的階段。如果所運(yùn)行的全部代碼都已經(jīng)被反復(fù)使用和驗證過,在實施階段就可以考慮使用-Xverify:none參數(shù)來關(guān)閉大部分的類驗證措施,從而縮短虛擬機(jī)類加載的時間。
-
準(zhǔn)備
準(zhǔn)備階段是正式為類變量分配內(nèi)存并設(shè)置類變量初始值的階段,這些內(nèi)存都將在方法區(qū)中進(jìn)行分配。
- 這個時候進(jìn)行內(nèi)存分配的僅包括類變量(被static修飾的變量),而不包括實例變量,實例變量將會在對象實例化時隨著對象一起被分配在Java堆中。
- 這里所說的初始值“通常情況”下是數(shù)據(jù)類型的零值,例如
public static int value = 123;value在準(zhǔn)備階段后的初始值是0而不是123,因為此時尚未執(zhí)行任何的Java方法,而把value賦值為123的putStatic指令是程序被編譯后,存放在類構(gòu)造器<clinit>()方法之中,把value賦值為123的動作將在初始化階段才會執(zhí)行。 - 通常情況下初始值為零值,相對的會存在特殊情況:如果類字段的字段屬性表中存在ConstantValue屬性,那在準(zhǔn)備階段變量就會被初始化為ConstantValue屬性所指定的值,例如
public static final int value = 123編譯時javac將會為value生成ConstantValue屬性,在準(zhǔn)備階段虛擬機(jī)就會根據(jù)ConstantValue的設(shè)置將變量賦值為123。
-
解析
解析階段是虛擬機(jī)將常量池內(nèi)的符號引用替換為直接引用的過程。
在Class文件中符號引用以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等類型的常量出現(xiàn)。
-
符號引用(Symbolic Reference):
符號引用以一組符號來描述所引用的目標(biāo),符號引用可以是任何形式的字面量,只要使用時能無歧義的定位到目標(biāo)即可。符號引用與虛擬機(jī)實現(xiàn)的內(nèi)存布局無關(guān),引用的目標(biāo)并不一定已經(jīng)加載在內(nèi)存中。各種虛擬機(jī)實現(xiàn)的內(nèi)存布局可以各不相同,但是他們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機(jī)規(guī)范的Class文件格式中。 -
直接引用(Direct Reference):
直接引用可以是直接指向目標(biāo)的指針、相對偏移量或是一個能間接定位到目標(biāo)的句柄。直接引用是與虛擬機(jī)實現(xiàn)的內(nèi)存布局相關(guān)的,同一個符號引用在不同的虛擬機(jī)實例上翻譯出來的直接引用一般都不相同,如果有了直接引用,那引用的目標(biāo)必定已經(jīng)在內(nèi)存中存在。
對于同一個符號引用可能會出現(xiàn)多次解析請求,虛擬機(jī)可能會對第一次解析的結(jié)果進(jìn)行緩存。
解析動作主要針對:類或接口、字段、類方法、接口方法、方法類型、方法句柄和調(diào)用點限定符7類符號引用進(jìn)行。
個人理解:一個java類將會編譯成一個class文件。在編譯時,java類并不知道引用類的實際內(nèi)存地址,因此只能使用符號引用來代替。比如org.simple.People類引用org.simple.Tool類,在編譯時People類并不知道Tool類的實際內(nèi)存地址,因此只能使用符號org.simple.Tool(假設(shè))來表示Tool類的地址。而在類加載器加載People類時,此時可以通過虛擬機(jī)獲取Tool類的實際內(nèi)存地址,因此便可以既將符號org.simple.Tool替換為Tool類的實際內(nèi)存地址,及直接引用地址。
-
初始化
類初始化階段是類加載過程的最后一步,前面的類加載過程中,除了加載階段用戶應(yīng)用程序可以通過自定義類加載器參與之外,其余動作完全由虛擬機(jī)主導(dǎo)和控制。到了初始化階段,才真正開始執(zhí)行類中定義的Java程序代碼。初始化階段是執(zhí)行類構(gòu)造器
<clinit>()方法的過程。對于<clinit>()方法具體介紹如下:
-
(1)
<clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態(tài)語句塊(static{}塊)中的語句合并產(chǎn)生的,編譯器收集的順序由語句在源文件中出現(xiàn)的順序所決定。
靜態(tài)語句塊中只能訪問到定義在靜態(tài)語句塊之前的變量,定義在它之后的變量,在前面的靜態(tài)語句塊可以賦值,但是不能訪問。
public class Test {
static{
i =0; //給變量賦值可以正常編譯通過
// System.out.println(i); //這句編譯器會提示“非法向前引用”
}
static int i = 1;
}
(2)
<clinit>()方法與類的構(gòu)造函數(shù)不同,它不需要顯式地調(diào)用父類構(gòu)造器,虛擬機(jī)會保證在子類的<clinit>()方法執(zhí)行之前,父類的<clinit>()方法已經(jīng)執(zhí)行完畢,因此在虛擬機(jī)中第一個執(zhí)行的<clinit>()方法的類一定是java.lang.Object。(3) 由于父類的
<clinit>()方法先執(zhí)行,也就意味著父類中定義的靜態(tài)語句塊要優(yōu)先于子類的變量賦值操作。如下面的例子所示,輸出結(jié)果為2而不是1。
public class Parent {
public static int A = 1;
static{
A = 2;
}
}
public class Sub extends Parent{
public static int B = A;
}
public class Test {
public static void main(String[] args) {
System.out.println(Sub.B);
}
}
(4)
<clinit>()方法對于類或者接口來說并不是必需的,如果一個類中沒有靜態(tài)語句塊也沒有對變量的賦值操作,那么編譯器可以不為這個類生成<clinit>()方法。(5) 接口中不能使用靜態(tài)語句塊,但仍然有變量賦值的初始化操作,因此接口也會生成
<clinit>()方法。但是接口與類不同,執(zhí)行接口的<clinit>()方法不需要先執(zhí)行父接口的<clinit>()方法。只有當(dāng)父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現(xiàn)類在初始化時也不會執(zhí)行接口的<clinit>()方法。(6) 虛擬機(jī)會保證一個類的
<clinit>()方法在多線程環(huán)境中被正確地加鎖和同步。如果有多個線程去同時初始化一個類,那么只會有一個線程去執(zhí)行這個類的<clinit>()方法,其它線程都需要阻塞等待,直到活動線程執(zhí)行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那么就可能造成多個進(jìn)程阻塞。
虛擬機(jī)把描述類的數(shù)據(jù)從Class文件加載到內(nèi)存,并對數(shù)據(jù)進(jìn)行校驗、轉(zhuǎn)換解析和初始化,最終形成可以被虛擬機(jī)直接使用的Java類型,這就是虛擬機(jī)的類加載機(jī)制。
[2015.08.31]