JVM類生命週期概述:載入時機與載入過程
摘要:
我們知道,一個.java檔案在編譯後會形成相應的一個或多個Class檔案,這些Class檔案中描述了類的各種資訊,並且它們最終都需要被載入到虛擬機器中才能被執行和使用。事實上,虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別的過程就是虛擬機器的類載入機制。本文概述了JVM載入類的時機和生命週期,並結合典型案例重點介紹了類的初始化過程,揭開了JVM類載入機制的神祕面紗。
友情提示:
JVM類載入機制主要包括兩個問題:類載入的時機與步驟 和 類載入的方式。本文主要闡述了第一個問題,關於類載入的方式等方面的內容,包括JVM預定義的類載入器、雙親委派模型等知識點,請參見我的博文
一個Java物件的建立過程往往包括兩個階段:類初始化階段 和 類例項化階段。本文的姊妹篇《 深入理解Java物件的建立過程:類的初始化與例項化》在本文基礎上,詳細深入闡述了一個Java物件在JVM中的真實建立過程。
注意,本文內容是以HotSpot虛擬機器為基準的。
一、類載入機制概述
我們知道,一個.java檔案在編譯後會形成相應的一個或多個Class檔案(若一個類中含有內部類,則編譯後會產生多個Class檔案),但這些Class檔案中描述的各種資訊,最終都需要載入到虛擬機器中之後才能被執行和使用。事實上,虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗,轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別的過程就是虛擬機器的 類載入機制
與那些在編譯時需要進行連線工作的語言不同,在Java語言裡面,型別的載入和連線都是在程式執行期間完成,這樣會在類載入時稍微增加一些效能開銷,但是卻能為Java應用程式提供高度的靈活性,Java中天生可以動態擴充套件的語言特性多型就是依賴執行期動態載入和動態連結這個特點實現的。例如,如果編寫一個使用介面的應用程式,可以等到執行時再指定其實際的實現。這種組裝應用程式的方式廣泛應用於Java程式之中。
既然這樣,那麼,
虛擬機器什麼時候才會載入Class檔案並初始化類呢?(類載入和初始化時機)
虛擬機器如何載入一個Class檔案呢?
虛擬機器載入一個Class檔案要經歷那些具體的步驟呢?(類載入過程/步驟)
第一、三個問題就是本文要闡述的重點。特別地,Java類載入器和雙親委派機制等內容已在博文《深入理解Java類載入器(一):Java類載入原理解析》中說明,此不贅述。
二. 類載入的時機
Java類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的整個生命週期包括:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 解除安裝(Unloading)七個階段。其中準備、驗證、解析3個部分統稱為連線(Linking),如圖所示:
載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的執行時繫結(也稱為動態繫結或晚期繫結)。以下陳述的內容都已HotSpot為基準。特別需要注意的是,類的載入過程必須按照這種順序按部就班地“開始”,而不是按部就班的“進行”或“完成”,因為這些階段通常都是相互交叉地混合式進行的,也就是說通常會在一個階段執行的過程中呼叫或啟用另外一個階段。
瞭解了Java類的生命週期以後,那麼我們現在來回答第一個問題:虛擬機器什麼時候才會載入Class檔案並初始化類呢?
1、類載入時機
什麼情況下虛擬機器需要開始載入一個類呢?虛擬機器規範中並沒有對此進行強制約束,這點可以交給虛擬機器的具體實現來自由把握。
2、類初始化時機
那麼,什麼情況下虛擬機器需要開始初始化一個類呢?這在虛擬機器規範中是有嚴格規定的,虛擬機器規範指明 有且只有 五種情況必須立即對類進行初始化(而這一過程自然發生在載入、驗證、準備之後):
1) 遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令(注意,newarray指令觸發的只是陣列型別本身的初始化,而不會導致其相關型別的初始化,比如,new String[]只會直接觸發String[]類的初始化,也就是觸發對類[Ljava.lang.String的初始化,而直接不會觸發String類的初始化)時,如果類沒有進行過初始化,則需要先對其進行初始化。生成這四條指令的最常見的Java程式碼場景是:
使用new關鍵字例項化物件的時候;
讀取或設定一個類的靜態欄位(被final修飾,已在編譯器把結果放入常量池的靜態欄位除外)的時候;
呼叫一個類的靜態方法的時候。
2) 使用java.lang.reflect包的方法對類進行反射呼叫的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
3) 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
4) 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
5) 當使用jdk1.7動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getstatic,REF_putstatic,REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先出觸發其初始化。
注意,對於這五種會觸發類進行初始化的場景,虛擬機器規範中使用了一個很強烈的限定語:“有且只有”,這五種場景中的行為稱為對一個類進行 主動引用。除此之外,所有引用類的方式,都不會觸發初始化,稱為 被動引用。
特別需要指出的是,類的例項化與類的初始化是兩個完全不同的概念:
- 類的例項化是指建立一個類的例項(物件)的過程;
- 類的初始化是指為類中各個類成員(被static修飾的成員變數)賦初始值的過程,是類生命週期中的一個階段。
3、被動引用的幾種經典場景
1)、通過子類引用父類的靜態欄位,不會導致子類初始化
public class SSClass{
static{
System.out.println("SSClass");
}
}
public class SClass extends SSClass{
static{
System.out.println("SClass init!");
}
public static int value = 123;
public SClass(){
System.out.println("init SClass");
}
}
public class SubClass extends SClass{
static{
System.out.println("SubClass init");
}
static int a;
public SubClass(){
System.out.println("init SubClass");
}
}
public class NotInitialization{
public static void main(String[] args){
System.out.println(SubClass.value);
}
}/* Output:
SSClass
SClass init!
123
*///:~
對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。在本例中,由於value欄位是在類SClass中定義的,因此該類會被初始化;此外,在初始化類SClass時,虛擬機器會發現其父類SSClass還未被初始化,因此虛擬機器將先初始化父類SSClass,然後初始化子類SClass,而SubClass始終不會被初始化。
2)、通過陣列定義來引用類,不會觸發此類的初始化
public class NotInitialization{
public static void main(String[] args){
SClass[] sca = new SClass[10];
}
}
上述案例執行之後並沒有任何輸出,說明虛擬機器並沒有初始化類SClass。但是,這段程式碼觸發了另外一個名為[Lcn.edu.tju.rico.SClass的類的初始化。從類名稱我們可以看出,這個類代表了元素型別為SClass的一維陣列,它是由虛擬機器自動生成的,直接繼承於Object的子類,建立動作由位元組碼指令newarray觸發。
3)、常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化
public class ConstClass{
static{
System.out.println("ConstClass init!");
}
public static final String CONSTANT = "hello world";
}
public class NotInitialization{
public static void main(String[] args){
System.out.println(ConstClass.CONSTANT);
}
}/* Output:
hello world
*///:~
上述程式碼執行之後,只輸出 “hello world”,這是因為雖然在Java原始碼中引用了ConstClass類中的常量CONSTANT,但是編譯階段將此常量的值“hello world”儲存到了NotInitialization常量池中,對常量ConstClass.CONSTANT的引用實際都被轉化為NotInitialization類對自身常量池的引用了。也就是說,實際上NotInitialization的Class檔案之中並沒有ConstClass類的符號引用入口,這兩個類在編譯為Class檔案之後就不存在關係了。
三. 類載入過程
如下圖所示,我們在上文已經提到過一個類的生命週期包括載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 解除安裝(Unloading)七個階段。現在我們一一學習一下JVM在載入、驗證、準備、解析和初始化五個階段是如何對每個類進行操作的。
1、載入(Loading)
在載入階段(可以參考java.lang.ClassLoader的loadClass()方法),虛擬機器需要完成以下三件事情:
(1). 通過一個類的全限定名來獲取定義此類的二進位制位元組流(並沒有指明要從一個Class檔案中獲取,可以從其他渠道,譬如:網路、動態生成、資料庫等);
(2). 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構;
(3). 在記憶體中(對於HotSpot虛擬就而言就是方法區)生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口;
載入階段和連線階段(Linking)的部分內容(如一部分位元組碼檔案格式驗證動作)是交叉進行的,載入階段尚未完成,連線階段可能已經開始,但這些夾在載入階段之中進行的動作,仍然屬於連線階段的內容,這兩個階段的開始時間仍然保持著固定的先後順序。
特別地,第一件事情(通過一個類的全限定名來獲取定義此類的二進位制位元組流)是由類載入器完成的,具體涉及JVM預定義的類載入器、雙親委派模型等內容,詳情請參見我的轉載博文《深入理解Java類載入器(一):Java類載入原理解析》中的說明,此不贅述。
2、驗證(Verification)
驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。 驗證階段大致會完成4個階段的檢驗動作:
檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範(例如,是否以魔術0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別)
元資料驗證:對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求(例如:這個類是否有父類,除了java.lang.Object之外);
位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的;
符號引用驗證:確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程式執行期沒有影響。如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
3、準備(Preparation)
準備階段是正式為類變數(static 成員變數)分配記憶體並設定類變數初始值(零值)的階段,這些變數所使用的記憶體都將在方法區中進行分配。這時候進行記憶體分配的僅包括類變數,而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在堆中。其次,這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數的定義為:
public static int value = 123;
那麼,變數value在準備階段過後的值為0而不是123。因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器方法<clinit>()之中,所以把value賦值為123的動作將在初始化階段才會執行。至於“特殊情況”是指:當類欄位的欄位屬性是ConstantValue時,會在準備階段初始化為指定的值,所以標註為final之後,value的值在準備階段初始化為123而非0。
public static final int value = 123;
4、解析(Resolution)
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行。
5、初始化(Initialization)
類初始化階段是類載入過程的最後一步。在前面的類載入過程中,除了在載入階段使用者應用程式可以通過自定義類載入器參與之外,其餘動作完全由虛擬機器主導和控制。到了初始化階段,才真正開始執行類中定義的java程式程式碼(位元組碼)。
在準備階段,變數已經賦過一次系統要求的初始值(零值);而在初始化階段,則根據程式猿通過程式制定的主觀計劃去初始化類變數和其他資源,或者更直接地說:初始化階段是執行類構造器<clinit>()方法的過程。<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊static{}中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問。如下:
public class Test{
static{
i=0;
System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前應用)
}
static int i=1;
}
那麼註釋報錯的那行程式碼,改成下面情形,程式就可以編譯通過並可以正常運行了。
public class Test{
static{
i=0;
//System.out.println(i);
}
static int i=1;
public static void main(String args[]){
System.out.println(i);
}
}/* Output:
1
*///:~
類構造器<clinit>()與例項構造器<init>()不同,它不需要程式設計師進行顯式呼叫,虛擬機器會保證在子類類構造器<clinit>()執行之前,父類的類構造<clinit>()執行完畢。由於父類的構造器<clinit>()先執行,也就意味著父類中定義的靜態語句塊/靜態變數的初始化要優先於子類的靜態語句塊/靜態變數的初始化執行。特別地,類構造器<clinit>()對於類或者介面來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生產類構造器<clinit>()。
虛擬機器會保證一個類的類構造器<clinit>()在多執行緒環境中被正確的加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的類構造器<clinit>(),其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。特別需要注意的是,在這種情形下,其他執行緒雖然會被阻塞,但如果執行<clinit>()方法的那條執行緒退出後,其他執行緒在喚醒之後不會再次進入/執行<clinit>()方法,因為 在同一個類載入器下,一個型別只會被初始化一次。如果在一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是隱藏的,如下所示:
public class DealLoopTest {
static{
System.out.println("DealLoopTest...");
}
static class DeadLoopClass {
static {
if (true) {
System.out.println(Thread.currentThread()
+ "init DeadLoopClass");
while (true) { // 模擬耗時很長的操作
}
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() { // 匿名內部類
public void run() {
System.out.println(Thread.currentThread() + " start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
}/* Output:
DealLoopTest...
Thread[Thread-1,5,main] start
Thread[Thread-0,5,main] start
Thread[Thread-1,5,main]init DeadLoopClass
*///:~
如上述程式碼所示,在初始化DeadLoopClass類時,執行緒Thread-1得到執行並在執行這個類的類構造器<clinit>() 時,由於該方法包含一個死迴圈,因此久久不能退出。
四. 典型案例分析
我們知道,在Java中, 建立一個物件常常需要經歷如下幾個過程:父類的類構造器<clinit>() -> 子類的類構造器<clinit>() -> 父類的成員變數和例項程式碼塊 -> 父類的建構函式 -> 子類的成員變數和例項程式碼塊 -> 子類的建構函式。至於為什麼是這樣的一個過程,筆者在本文的姊妹篇《 深入理解Java物件的建立過程:類的初始化與例項化》很好的解釋了這個問題。
那麼,我們看看下面的程式的輸出結果:
public class StaticTest {
public static void main(String[] args) {
staticFunction();
}
static StaticTest st = new StaticTest();
static { //靜態程式碼塊
System.out.println("1");
}
{ // 例項程式碼塊
System.out.println("2");
}
StaticTest() { // 例項構造器
System.out.println("3");
System.out.println("a=" + a + ",b=" + b);
}
public static void staticFunction() { // 靜態方法
System.out.println("4");
}
int a = 110; // 例項變數
static int b = 112; // 靜態變數
}/* Output:
2
3
a=110,b=0
1
4
*///:~
大家能得到正確答案嗎?雖然筆者勉強猜出了正確答案,但總感覺怪怪的。因為在初始化階段,當JVM對類StaticTest進行初始化時,首先會執行下面的語句:
static StaticTest st = new StaticTest();
也就是例項化StaticTest物件,但這個時候類都沒有初始化完畢啊,能直接進行例項化嗎?事實上,這涉及到一個根本問題就是:例項初始化不一定要在類初始化結束之後才開始初始化。 下面我們結合類的載入過程說明這個問題。
我們知道,類的生命週期是:載入->驗證->準備->解析->初始化->使用->解除安裝,並且只有在準備階段和初始化階段才會涉及類變數的初始化和賦值,因此我們只針對這兩個階段進行分析:
首先,在類的準備階段需要做的是為類變數(static變數)分配記憶體並設定預設值(零值),因此在該階段結束後,類變數st將變為null、b變為0。特別需要注意的是,如果類變數是final的,那麼編譯器在編譯時就會為value生成ConstantValue屬性,並在準備階段虛擬機器就會根據ConstantValue的設定將變數設定為指定的值。也就是說,如果上述程度對變數b採用如下定義方式時:
static final int b=112
那麼,在準備階段b的值就是112,而不再是0了。
此外,在類的初始化階段需要做的是執行類構造器<clinit>(),需要指出的是,類構造器本質上是編譯器收集所有靜態語句塊和類變數的賦值語句按語句在原始碼中的順序合併生成類構造器<clinit>()。因此,對上述程式而言,JVM將先執行第一條靜態變數的賦值語句:
st = new StaticTest ()
此時,就碰到了筆者上面的疑惑,即“在類都沒有初始化完畢之前,能直接進行例項化相應的物件嗎?”。事實上,從Java角度看,我們知道一個類初始化的基本常識,那就是:在同一個類載入器下,一個型別只會被初始化一次。所以,一旦開始初始化一個型別,無論是否完成,後續都不會再重新觸發該型別的初始化階段了(只考慮在同一個類載入器下的情形)。因此,在例項化上述程式中的st變數時,實際上是把例項初始化嵌入到了靜態初始化流程中,並且在上面的程式中,嵌入到了靜態初始化的起始位置。這就導致了例項初始化完全發生在靜態初始化之前,當然,這也是導致a為110b為0的原因。
因此,上述程式的StaticTest類構造器<clinit>()的實現等價於:
public class StaticTest {
<clinit>(){
a = 110; // 例項變數
System.out.println("2"); // 例項程式碼塊
System.out.println("3"); // 例項構造器中程式碼的執行
System.out.println("a=" + a + ",b=" + b); // 例項構造器中程式碼的執行
類變數st被初始化
System.out.println("1"); //靜態程式碼塊
類變數b被初始化為112
}
}
因此,上述程式會有上面的輸出結果。下面,我們對上述程式稍作改動,如下所示:
public class StaticTest {
public static void main(String[] args) {
staticFunction();
}
static StaticTest st = new StaticTest();
static {
System.out.println("1");
}
{
System.out.println("2");
}
StaticTest() {
System.out.println("3");
System.out.println("a=" + a + ",b=" + b);
}
public static void staticFunction() {
System.out.println("4");
}
int a = 110;
static int b = 112;
static StaticTest st1 = new StaticTest();
}
在程式最後的一行,增加以下程式碼行:
static StaticTest st1 = new StaticTest();
那麼,此時程式的輸出又是什麼呢?如果你對上述的內容理解很好的話,不難得出結論(只有執行完上述程式碼行後,StaticTest類才被初始化完成),即:
2
3
a=110,b=0
1
2
3
a=110,b=112
4
另外,下面這道經典題目也很有意思,如下:
class Foo {
int i = 1;
Foo() {
System.out.println(i);
int x = getValue();
System.out.println(x);
}
{
i = 2;
}
protected int getValue() {
return i;
}
}
//子類
class Bar extends Foo {
int j = 1;
Bar() {
j = 2;
}
{
j = 3;
}
@Override
protected int getValue() {
return j;
}
}
public class ConstructorExample {
public static void main(String... args) {
Bar bar = new Bar();
System.out.println(bar.getValue());
}
}
那麼,這個程式的輸出又是什麼呢?當然,程式跑一下就知道結果。其實,對於這型別題目,我們只要真正理解類的例項化過程,就可以做到所向披靡。關於該題目的講解和Java物件建立過程的講解,我的下一篇博文《 深入理解Java物件的建立過程:類的初始化與例項化》進行了深入的闡述~~
五. 更多
更多關於類載入器等方面的內容,包括JVM預定義的類載入器、雙親委派模型等知識點,請參見我的轉載博文《深入理解Java類載入器(一):Java類載入原理解析》。