摘要:
我們知道,一個.java文件在編譯后會形成相應的一個或多個Class文件,這些Class文件中描述了類的各種信息,並且它們最終都需要被加載到虛擬機中才能被運行和使用。事實上,虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型的過程就是虛擬機的類加載機制。本文概述了JVM加載類的時機和生命周期,並結合典型案例重點介紹了類的初始化過程,揭開了JVM類加載機制的神秘面紗。
友情提示:
JVM類加載機制主要包括兩個問題:類加載的時機與步驟 和 類加載的方式。本文主要闡述了第一個問題,關於類加載的方式等方面的內容,包括JVM預定義的類加載器、雙親委派模型等知識點,請參見我的博文《深入理解Java類加載器(一):Java類加載原理解析》。
一個Java對象的創建過程往往包括兩個階段:類初始化階段 和 類實例化階段。本文的姊妹篇《 深入理解Java對象的創建過程:類的初始化與實例化》在本文基礎上,詳細深入闡述了一個Java對象在JVM中的真實創建過程。
注意,本文內容是以HotSpot虛擬機為基准的。
一、類加載機制概述
我們知道,一個.java文件在編譯后會形成相應的一個或多個Class文件(若一個類中含有內部類,則編譯后會產生多個Class文件),但這些Class文件中描述的各種信息,最終都需要加載到虛擬機中之后才能被運行和使用。事實上,虛擬機把描述類的數據從Class文件加載到內存,並對數據進行校驗,轉換解析和初始化,最終形成可以被虛擬機直接使用的Java類型的過程就是虛擬機的 類加載機制。
與那些在編譯時需要進行連接工作的語言不同,在Java語言里面,類型的加載和連接都是在程序運行期間完成,這樣會在類加載時稍微增加一些性能開銷,但是卻能為Java應用程序提供高度的靈活性,Java中天生可以動態擴展的語言特性多態就是依賴運行期動態加載和動態鏈接這個特點實現的。例如,如果編寫一個使用接口的應用程序,可以等到運行時再指定其實際的實現。這種組裝應用程序的方式廣泛應用於Java程序之中。
既然這樣,那么,
-
虛擬機什么時候才會加載Class文件並初始化類呢?(類加載和初始化時機)
-
虛擬機如何加載一個Class文件呢?(Java類加載的方式:類加載器、雙親委派機制,詳見博文《深入理解Java類加載器(一):Java類加載原理解析》)
-
虛擬機加載一個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類加載原理解析》。
關於一個Java對象在JVM中的真實創建過程,請移步本文的姊妹篇《 深入理解Java對象的創建過程:類的初始化與實例化》。
引用:
《深入理解java虛擬機》周志明著
Java虛擬機類加載機制
Java虛擬機類加載機制——案例分析