Java 類加載機制

2018-09-28 19:21 更新

類加載機制

類加載過程

類從被加載到虛擬機內(nèi)存中開始,到卸載出內(nèi)存為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用和卸載七個階段。它們開始的順序如下圖所示:


其中類加載的過程包括了加載、驗證、準備、解析、初始化五個階段。在這五個階段中,加載、驗證、準備和初始化這四個階段發(fā)生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之后開始,這是為了支持 Java 語言的運行時綁定(也成為動態(tài)綁定或晚期綁定)。另外注意這里的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執(zhí)行的過程中調(diào)用或激活另一個階段。

這里簡要說明下 Java 中的綁定:綁定指的是把一個方法的調(diào)用與方法所在的類(方法主體)關(guān)聯(lián)起來,對 Java 來說,綁定分為靜態(tài)綁定和動態(tài)綁定:

  • 靜態(tài)綁定:即前期綁定。在程序執(zhí)行前方法已經(jīng)被綁定,此時由編譯器或其它連接程序?qū)崿F(xiàn)。針對 Java,簡單的可以理解為程序編譯期的綁定。Java 當中的方法只有 final,static,private 和構(gòu)造方法是前期綁定的。
  • 動態(tài)綁定:即晚期綁定,也叫運行時綁定。在運行時根據(jù)具體對象的類型進行綁定。在 Java 中,幾乎所有的方法都是后期綁定的。

下面詳細講述類加載過程中每個階段所做的工作。

加載

加載時類加載過程的第一個階段,在加載階段,虛擬機需要完成以下三件事情:

  • 通過一個類的全限定名來獲取其定義的二進制字節(jié)流。
  • 將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu)。
  • 在 Java 堆中生成一個代表這個類的 java.lang.Class 對象,作為對方法區(qū)中這些數(shù)據(jù)的訪問入口。

注意,這里第 1 條中的二進制字節(jié)流并不只是單純地從 Class 文件中獲取,比如它還可以從 Jar 包中獲取、從網(wǎng)絡(luò)中獲?。ㄗ畹湫偷膽?yīng)用便是 Applet)、由其他文件生成(JSP 應(yīng)用)等。

相對于類加載的其他階段而言,加載階段(準確地說,是加載階段獲取類的二進制字節(jié)流的動作)是可控性最強的階段,因為開發(fā)人員既可以使用系統(tǒng)提供的類加載器來完成加載,也可以自定義自己的類加載器來完成加載。

加載階段完成后,虛擬機外部的 二進制字節(jié)流就按照虛擬機所需的格式存儲在方法區(qū)之中,而且在 Java 堆中也創(chuàng)建一個 java.lang.Class 類的對象,這樣便可以通過該對象訪問方法區(qū)中的這些數(shù)據(jù)。

說到加載,不得不提到類加載器,下面就具體講述下類加載器。

類加載器雖然只用于實現(xiàn)類的加載動作,但它在 Java 程序中起到的作用卻遠遠不限于類的加載階段。對于任意一個類,都需要由它的類加載器和這個類本身一同確定其在就 Java 虛擬機中的唯一性,也就是說,即使兩個類來源于同一個 Class 文件,只要加載它們的類加載器不同,那這兩個類就必定不相等。這里的“相等”包括了代表類的 Class 對象的 equals()、isAssignableFrom()、isInstance()等方法的返回結(jié)果,也包括了使用 instanceof 關(guān)鍵字對對象所屬關(guān)系的判定結(jié)果。

站在 Java 虛擬機的角度來講,只存在兩種不同的類加載器:

  • 啟動類加載器:它使用 C++ 實現(xiàn)(這里僅限于 Hotspot,也就是 JDK1.5 之后默認的虛擬機,有很多其他的虛擬機是用 Java 語言實現(xiàn)的),是虛擬機自身的一部分。
  • 所有其他的類加載器:這些類加載器都由 Java 語言實現(xiàn),獨立于虛擬機之外,并且全部繼承自抽象類 java.lang.ClassLoader,這些類加載器需要由啟動類加載器加載到內(nèi)存中之后才能去加載其他的類。

站在 Java 開發(fā)人員的角度來看,類加載器可以大致劃分為以下三類:

  • 啟動類加載器:Bootstrap ClassLoader,跟上面相同。它負責加載存放在JDK\jre\li(JDK 代表 JDK 的安裝目錄,下同)下,或被-Xbootclasspath參數(shù)指定的路徑中的,并且能被虛擬機識別的類庫(如 rt.jar,所有的java.*開頭的類均被 Bootstrap ClassLoader 加載)。啟動類加載器是無法被 Java 程序直接引用的。
  • 擴展類加載器:Extension ClassLoader,該加載器由sun.misc.Launcher$ExtClassLoader實現(xiàn),它負責加載JDK\jre\lib\ext目錄中,或者由 java.ext.dirs 系統(tǒng)變量指定的路徑中的所有類庫(如javax.*開頭的類),開發(fā)者可以直接使用擴展類加載器。
  • 應(yīng)用程序類加載器:Application ClassLoader,該類加載器由 sun.misc.Launcher$AppClassLoader 來實現(xiàn),它負責加載用戶類路徑(ClassPath)所指定的類,開發(fā)者可以直接使用該類加載器,如果應(yīng)用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。

應(yīng)用程序都是由這三種類加載器互相配合進行加載的,如果有必要,我們還可以加入自定義的類加載器。因為 JVM 自帶的 ClassLoader 只是懂得從本地文件系統(tǒng)加載標準的 java class 文件,因此如果編寫了自己的 ClassLoader,便可以做到如下幾點:

  • 在執(zhí)行非置信代碼之前,自動驗證數(shù)字簽名。

  • 動態(tài)地創(chuàng)建符合用戶特定需要的定制化構(gòu)建類。

  • 從特定的場所取得 java class,例如數(shù)據(jù)庫中和網(wǎng)絡(luò)中。

事實上當使用 Applet 的時候,就用到了特定的 ClassLoader,因為這時需要從網(wǎng)絡(luò)上加載 java class,并且要檢查相關(guān)的安全信息,應(yīng)用服務(wù)器也大都使用了自定義的 ClassLoader 技術(shù)。

這幾種類加載器的層次關(guān)系如下圖所示:

這種層次關(guān)系稱為類加載器的雙親委派模型。我們把每一層上面的類加載器叫做當前層類加載器的父加載器,當然,它們之間的父子關(guān)系并不是通過繼承關(guān)系來實現(xiàn)的,而是使用組合關(guān)系來復(fù)用父加載器中的代碼。該模型在 JDK1.2 期間被引入并廣泛應(yīng)用于之后幾乎所有的 Java 程序中,但它并不是一個強制性的約束模型,而是 Java 設(shè)計者們推薦給開發(fā)者的一種類的加載器實現(xiàn)方式。

雙親委派模型的工作流程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委托給父加載器去完成,依次向上,因此,所有的類加載請求最終都應(yīng)該被傳遞到頂層的啟動類加載器中,只有當父加載器在它的搜索范圍中沒有找到所需的類時,即無法完成該加載,子加載器才會嘗試自己去加載該類。

使用雙親委派模型來組織類加載器之間的關(guān)系,有一個很明顯的好處,就是 Java 類隨著它的類加載器(說白了,就是它所在的目錄)一起具備了一種帶有優(yōu)先級的層次關(guān)系,這對于保證 Java 程序的穩(wěn)定運作很重要。例如,類java.lang.Object 類存放在JDK\jre\lib下的 rt.jar 之中,因此無論是哪個類加載器要加載此類,最終都會委派給啟動類加載器進行加載,這邊保證了 Object 類在程序中的各種類加載器中都是同一個類。

驗證

驗證的目的是為了確保 Class 文件中的字節(jié)流包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現(xiàn)可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數(shù)據(jù)的驗證、字節(jié)碼驗證和符號引用驗證。

  • 文件格式的驗證:驗證字節(jié)流是否符合 Class 文件格式的規(guī)范,并且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節(jié)流能正確地解析并存儲于方法區(qū)之內(nèi)。經(jīng)過該階段的驗證后,字節(jié)流才會進入內(nèi)存的方法區(qū)中進行存儲,后面的三個驗證都是基于方法區(qū)的存儲結(jié)構(gòu)進行的。
  • 元數(shù)據(jù)驗證:對類的元數(shù)據(jù)信息進行語義校驗(其實就是對類中的各數(shù)據(jù)類型進行語法校驗),保證不存在不符合 Java 語法規(guī)范的元數(shù)據(jù)信息。
  • 字節(jié)碼驗證:該階段驗證的主要工作是進行數(shù)據(jù)流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行為。
  • 符號引用驗證:這是最后一個階段的驗證,它發(fā)生在虛擬機將符號引用轉(zhuǎn)化為直接引用的時候(解析階段中發(fā)生該轉(zhuǎn)化,后面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗。

準備

準備階段是正式為類變量分配內(nèi)存并設(shè)置類變量初始值的階段,這些內(nèi)存都將在方法區(qū)中分配。對于該階段有以下幾點需要注意:

  • 這時候進行內(nèi)存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨著對象一塊分配在 Java 堆中。
  • 這里所設(shè)置的初始值通常情況下是數(shù)據(jù)類型默認的零值(如 0、0L、null、false 等),而不是被在 Java 代碼中被顯式地賦予的值。

假設(shè)一個類變量的定義為:

public static int value = 3;

那么變量 value 在準備階段過后的初始值為 0,而不是 3,因為這時候尚未開始執(zhí)行任何 Java 方法,而把 value 賦值為 3 的 putstatic 指令是在程序編譯后,存放于類構(gòu)造器 ()方法之中的,所以把 value 賦值為 3 的動作將在初始化階段才會執(zhí)行。

下表列出了 Java 中所有基本數(shù)據(jù)類型以及 reference 類型的默認零值:

這里還需要注意如下幾點:

  • 對基本數(shù)據(jù)類型來說,對于類變量(static)和全局變量,如果不顯式地對其賦值而直接使用,則系統(tǒng)會為其賦予默認的零值,而對于局部變量來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
  • 對于同時被 static 和 final 修飾的常量,必須在聲明的時候就為其顯式地賦值,否則編譯時不通過;而只被 final 修飾的常量則既可以在聲明時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統(tǒng)不會為其賦予默認零值。
  • 對于引用數(shù)據(jù)類型 reference 來說,如數(shù)組引用、對象引用等,如果沒有對其進行顯式地賦值而直接使用,系統(tǒng)都會為其賦予默認的零值,即null。
  • 如果在數(shù)組初始化時沒有對數(shù)組中的各元素賦值,那么其中的元素將根據(jù)對應(yīng)的數(shù)據(jù)類型而被賦予默認的零值。

如果類字段的字段屬性表中存在 ConstantValue 屬性,即同時被 final 和 static 修飾,那么在準備階段變量 value 就會被初始化為 ConstValue 屬性所指定的值。

假設(shè)上面的類變量 value 被定義為:

public static final int value = 3;

編譯時 Javac 將會為 value 生成 ConstantValue 屬性,在準備階段虛擬機就會根據(jù) ConstantValue 的設(shè)置將 value 賦值為 3?;貞浬弦黄┪闹袑ο蟊粍右玫牡?2 個例子,便是這種情況。我們可以理解為 static final 常量在編譯期就將其結(jié)果放入了調(diào)用它的類的常量池中。

解析

解析階段是虛擬機將常量池中的符號引用轉(zhuǎn)化為直接引用的過程。在 Class 類文件結(jié)構(gòu)一文中已經(jīng)比較過了符號引用和直接引用的區(qū)別和關(guān)聯(lián),這里不再贅述。前面說解析階段可能開始于初始化之前,也可能在初始化之后開始,虛擬機會根據(jù)需要來判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析(初始化之前),還是等到一個符號引用將要被使用前才去解析它(初始化之后)。

對同一個符號引用進行多次解析請求時很常見的事情,虛擬機實現(xiàn)可能會對第一次解析的結(jié)果進行緩存(在運行時常量池中記錄直接引用,并把常量標示為已解析狀態(tài)),從而避免解析動作重復(fù)進行。

解析動作主要針對類或接口、字段、類方法、接口方法四類符號引用進行,分別對應(yīng)于常量池中的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info 四種常量類型。

1、類或接口的解析:判斷所要轉(zhuǎn)化成的直接引用是對數(shù)組類型,還是普通的對象類型的引用,從而進行不同的解析。

2、字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結(jié)束;如果沒有,則會按照繼承關(guān)系從上往下遞歸搜索該類所實現(xiàn)的各個接口和它們的父接口,還沒有,則按照繼承關(guān)系從上往下遞歸搜索其父類,直至查找結(jié)束,查找流程如下圖所示:

從下面一段代碼的執(zhí)行結(jié)果中很容易看出來字段解析的搜索順序:

class Super{  
    public static int m = 11;  
    static{  
        System.out.println("執(zhí)行了super類靜態(tài)語句塊");  
    }  
}  

class Father extends Super{  
    public static int m = 33;  
    static{  
        System.out.println("執(zhí)行了父類靜態(tài)語句塊");  
    }  
}  

class Child extends Father{  
    static{  
        System.out.println("執(zhí)行了子類靜態(tài)語句塊");  
    }  
}  

public class StaticTest{  
    public static void main(String[] args){  
        System.out.println(Child.m);  
    }  
}  

執(zhí)行結(jié)果如下:

 執(zhí)行了super類靜態(tài)語句塊
 執(zhí)行了父類靜態(tài)語句塊
 33

如果注釋掉 Father 類中對 m 定義的那一行,則輸出結(jié)果如下:

執(zhí)行了super類靜態(tài)語句塊
11

另外,很明顯這就是上篇博文中的第 1 個例子的情況,這里我們便可以分析如下:static 變量發(fā)生在靜態(tài)解析階段,也即是初始化之前,此時已經(jīng)將字段的符號引用轉(zhuǎn)化為了內(nèi)存引用,也便將它與對應(yīng)的類關(guān)聯(lián)在了一起,由于在子類中沒有查找到與 m 相匹配的字段,那么 m 便不會與子類關(guān)聯(lián)在一起,因此并不會觸發(fā)子類的初始化。

最后需要注意:理論上是按照上述順序進行搜索解析,但在實際應(yīng)用中,虛擬機的編譯器實現(xiàn)可能要比上述規(guī)范要求的更嚴格一些。如果有一個同名字段同時出現(xiàn)在該類的接口和父類中,或同時在自己或父類的接口中出現(xiàn),編譯器可能會拒絕編譯。如果對上面的代碼做些修改,將 Super 改為接口,并將 Child 類繼承 Father 類且實現(xiàn) Super 接口,那么在編譯時會報出如下錯誤:

StaticTest.java:24: 對 m 的引用不明確,F(xiàn)ather 中的 變量 m 和 Super 中的 變量 m
都匹配
                System.out.println(Child.m);
                                        ^
1 錯誤

3、類方法解析:對類方法的解析與對字段解析的搜索步驟差不多,只是多了判斷該方法所處的是類還是接口的步驟,而且對類方法的匹配搜索,是先搜索父類,再搜索接口。

4、接口方法解析:與類方法解析步驟類似,知識接口不會有父類,因此,只遞歸向上搜索父接口就行了。

初始化

初始化是類加載過程的最后一步,到了此階段,才真正開始執(zhí)行類中定義的 Java 程序代碼。在準備階段,類變量已經(jīng)被賦過一次系統(tǒng)要求的初始值,而在初始化階段,則是根據(jù)程序員通過程序指定的主觀計劃去初始化類變量和其他資源,或者可以從另一個角度來表達:初始化階段是執(zhí)行類構(gòu)造器()方法的過程。

這里簡單說明下()方法的執(zhí)行規(guī)則:

1、()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態(tài)語句塊中的語句合并產(chǎn)生的,編譯器收集的順序是由語句在源文件中出現(xiàn)的順序所決定的,靜態(tài)語句塊中只能訪問到定義在靜態(tài)語句塊之前的變量,定義在它之后的變量,在前面的靜態(tài)語句中可以賦值,但是不能訪問。

2、()方法與實例構(gòu)造器()方法(類的構(gòu)造函數(shù))不同,它不需要顯式地調(diào)用父類構(gòu)造器,虛擬機會保證在子類的()方法執(zhí)行之前,父類的()方法已經(jīng)執(zhí)行完畢。因此,在虛擬機中第一個被執(zhí)行的()方法的類肯定是java.lang.Object。

3、()方法對于類或接口來說并不是必須的,如果一個類中沒有靜態(tài)語句塊,也沒有對類變量的賦值操作,那么編譯器可以不為這個類生成()方法。

4、接口中不能使用靜態(tài)語句塊,但仍然有類變量(final static)初始化的賦值操作,因此接口與類一樣會生成()方法。但是接口魚類不同的是:執(zhí)行接口的()方法不需要先執(zhí)行父接口的()方法,只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現(xiàn)類在初始化時也一樣不會執(zhí)行接口的()方法。

5、虛擬機會保證一個類的()方法在多線程環(huán)境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那么只會有一個線程去執(zhí)行這個類的()方法,其他線程都需要阻塞等待,直到活動線程執(zhí)行()方法完畢。如果在一個類的()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應(yīng)用中這種阻塞往往是很隱蔽的。

下面給出一個簡單的例子,以便更清晰地說明如上規(guī)則:

class Father{  
    public static int a = 1;  
    static{  
        a = 2;  
    }  
}  

class Child extends Father{  
    public static int b = a;  
}  

public class ClinitTest{  
    public static void main(String[] args){  
        System.out.println(Child.b);  
    }  
}  

執(zhí)行上面的代碼,會打印出 2,也就是說 b 的值被賦為了 2。

我們來看得到該結(jié)果的步驟。首先在準備階段為類變量分配內(nèi)存并設(shè)置類變量初始值,這樣 A 和 B 均被賦值為默認值 0,而后再在調(diào)用()方法時給他們賦予程序中指定的值。當我們調(diào)用 Child.b 時,觸發(fā) Child 的()方法,根據(jù)規(guī)則 2,在此之前,要先執(zhí)行完其父類Father的()方法,又根據(jù)規(guī)則1,在執(zhí)行()方法時,需要按 static 語句或 static 變量賦值操作等在代碼中出現(xiàn)的順序來執(zhí)行相關(guān)的 static 語句,因此當觸發(fā)執(zhí)行 Fathe r的()方法時,會先將 a 賦值為 1,再執(zhí)行 static 語句塊中語句,將 a 賦值為 2,而后再執(zhí)行 Child 類的()方法,這樣便會將 b 的賦值為 2。

如果我們顛倒一下 Father 類中“public static int a = 1;”語句和“static語句塊”的順序,程序執(zhí)行后,則會打印出1。很明顯是根據(jù)規(guī)則 1,執(zhí)行 Father 的()方法時,根據(jù)順序先執(zhí)行了 static 語句塊中的內(nèi)容,后執(zhí)行了“public static int a = 1;”語句。

另外,在顛倒二者的順序之后,如果在 static 語句塊中對 a 進行訪問(比如將 a 賦給某個變量),在編譯時將會報錯,因為根據(jù)規(guī)則 1,它只能對 a 進行賦值,而不能訪問。

總結(jié)

整個類加載過程中,除了在加載階段用戶應(yīng)用程序可以自定義類加載器參與之外,其余所有的動作完全由虛擬機主導(dǎo)和控制。到了初始化才開始執(zhí)行類中定義的 Java 程序代碼(亦及字節(jié)碼),但這里的執(zhí)行代碼只是個開端,它僅限于()方法。類加載過程中主要是將 Class 文件(準確地講,應(yīng)該是類的二進制字節(jié)流)加載到虛擬機內(nèi)存中,真正執(zhí)行字節(jié)碼的操作,在加載完成后才真正開始。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號