1. 程式人生 > >【深入理解Java虛擬機器】類載入機制

【深入理解Java虛擬機器】類載入機制

本文內容來源於《深入理解Java虛擬機器》一書,非常推薦大家去看一下這本書。本系列其他文章:
【深入理解Java虛擬機器】垃圾回收機制

1、類載入機制概述

虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類載入機制。在java中,型別的載入、連線和初始化過程都是在程式執行期間完成的,這種策略雖然會帶來一些效能開銷,但是卻為java應用程式提供了高度的靈活性,java動態擴充套件的語言特性就是依賴執行期動態載入和動態連結這個特點形成的,所謂java動態擴充套件,比如,如果編寫了一個面向介面的應用程式,可以等到執行時再指定其實際的實現類。

2、類載入的時機

類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,整個生命週期包括:載入、驗證、準備、解析、初始化、使用、解除安裝,共七個階段。其中,驗證、準備、解析3個階段稱為連線(Linking),7個過程發生順序如下:
上面這七個過程,除了解析這個過程外,其餘過程必須按部就班地執行,即順序是確定的,而解析過程不一定,在某些情況下可以在初始化階段之後再執行,這是為了支援java語言的執行時繫結(也稱為動態繫結或晚期繫結)。java虛擬機器規範中,並沒有規定類載入過程中的第一個階段(即載入階段)的執行時機,但是對於初始化階段,虛擬機器規範中嚴格規定了“有且只有”下面5種情況下必須立即對類進行初始化(而這時,載入、驗證、準備自然需要在此之前開始):(1)遇到new、getstatic、putstatic、invokestatic這四條指令時,必須觸發其初始化。這四條指令最常見的場景是:使用new關鍵字例項化物件、讀取或設定一個類的靜態欄位(被final修飾、已經在編譯期把結果放入常量池的靜態欄位除外,即常量除外)、呼叫一個類的靜態方法的時候;(2)進行反射呼叫的時候;(3)初始化一個類的時候,如果其父類還沒有初始化,則需要先觸發其父類的初始化;(4)當虛擬機器啟動時,需要先初始化那個包含main方法的要執行的主類;(5)當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果為
REF_getStatic 、REF_putStatic、REF_invokeStatic的方法控制代碼,控制代碼對應的類會被初始化;上面五種場景觸發類進行初始化的行為稱為對一個類進行“主動引用”,除此之外,所有其他引用類的方式都不會觸發初始化步驟(注意,此時已經是引用了,只不過不會觸發初始化,其他階段是否觸發要看具體虛擬機器的實現),這些引用稱為“被動引用”。被動引用的幾個例子:(1)對於靜態欄位,只有直接定義這個欄位的類才會被初始化,因此通過其子類來引用父類中定義的靜態欄位,只會觸發父類的初始化而不會觸發子類的初始化。至於是否要出發子類的載入、驗證需要看具體虛擬機器實現;如下:
class SuperClass{
    static{
        System.out.println("SuperClass init!");
    }
    public static int value = 123;
}

class SubClass extends SuperClass{
    static{
        System.out.println("SubClass init!");//子類中引用父類的靜態欄位,不會導致類初始化
    }
}

public class Test {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}
執行結果:SuperClass init!123可以看到,只會打印出父類的初始化語句。(2)通過陣列定義來引用類,不會觸發此類的初始化。如 A[] ints = new A[10] ,  不會觸發A 類的初始化。而是會觸發名為 LA的類初始化。它是一個由虛擬機器自動生成的、直接繼承於Object 的子類,建立動作由位元組碼指令 newarray 觸發。這個類代表了一個元素型別為 A 的一位陣列,陣列中的屬性和方法都實現在這個類中。Java 語言中陣列的訪問比C/C++ 安全是因為這個類封裝了陣列元素的訪問方法。如下:public class Test {    public static void main(String[] args) {        SuperClass[] sca = new SuperClass[10];    }}SuperClass類為上面的那個,執行後發現並沒有打印出SuperClass init!,說明沒有觸發SuperClass類的初始化階段。
(3)常量在編譯階段會存入呼叫類的常量池中,本質上並沒有直接引用到定義常量的類,因此不會觸發定義常量的類的初始化,如下:
class ConstClass{
    static{
        System.out.println("ConstClass init!");
    }
    public static final String HELLOWORLD = "hello world";
}

public class Test {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}
執行結果:hello world只是輸出了hello world,並沒有輸出ConstClass init!,可見ConstClass類並沒有被初始化。注意:上面講的三個例子是被動引用的情況,很多情況下我們會通過new來初始化一個類,這個情形它屬於上面提到的5種主動引用的場景,因此會觸發這個類的初始化,如果這個類有父類的話,會先觸發父類的初始化。注意不要和上面的被動引用搞混了。介面的初始化上面程式碼中用static語句塊進行初始化,而結構中不能使用static語句塊,但是編譯器仍然回味介面生成<clinit>()類構造器來初始化介面中的成員變數(常量);介面與類初始化的區別主要是在上面五種主動引用中的第三種:當一個類在初始化時,要求其父類全部已經初始化過了,但是對於介面的初始化來說,並不要求父介面全部都完成了初始化,只有在真正使用到付介面的時候(如引用介面中定義的常量)才會初始化

3、類載入過程

3.1 載入

在載入階段,需要完成三件事情:

(1)通過一個類的全限定名來獲取其定義的二進位制位元組流。

(2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。

(3)在記憶體中生成一個代表這個類的java.lang.Class物件(並沒有明確規定是在java堆中,對於HotSpot虛擬機器來說,Class物件比較特殊,它雖然是物件,但是存放在方法區裡面),作為對方法區中這些資料的訪問入口。

對於(1),並沒有指明二進位制位元組流的獲取途徑,也即不一定都是從一個Class檔案中獲取,還可以從如下方式獲取:

    1)從壓縮包中獲取,比如 JAR包、EAR、WAR包等    2)從網路中獲取,比如紅極一時的Applet技術    3)從執行過程中動態生成,最出名的便是動態代理技術,在java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 來為特定介面生成形式為“$Proxy”的代理類的二進位制流    4)從其它檔案生成,如JSP檔案生成Class 類    5)從資料庫中讀取,比如說有些中介軟體伺服器,通過資料庫完成程式程式碼在叢集之間的分發相對於類載入過程的其他階段,載入這一步驟是開發人員可控的,即可以通過自定義類載入器來控制載入過程。對於陣列來說,陣列類本身不通過類載入器建立,它是由Java虛擬機器直接建立的,但是陣列的元素型別,最終是要靠類載入器去建立。

3.2 驗證

驗證階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。Java語言本身是相對安全的,因為使用純粹的java程式碼無法做到諸如訪問陣列邊界意外的資料、講一個物件轉型為它並未實現的型別、跳轉到不存在的程式碼行之類的事情,如果我們這樣做了,那編譯器將拒絕編譯,也就保證了安全。但是前面說過,Class檔案並不一定要用Java原始碼編譯而來,它還可以從很多途徑產生,在位元組碼層面,其他方式可能能做到java程式碼無法做到的事情,因此虛擬機器需要對載入儘量的位元組流進行驗證。驗證過程分為四步:(1)檔案格式驗證這一階段是要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。包括以下這些驗證點:    - 是否以魔數0xCAFEBABE開頭    - 主、次版本號是否在當前虛擬機器處理範圍之內    - 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)    - 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量    - CONSTANT_Utf8_info 型的常量中是否有不符合UTF8 編碼的資料    - Class 檔案中各個部分以及檔案本身是否有被刪除的或被附加的其它資訊    ...這一階段驗證的目的是保證輸入的位元組流能正確的解析並存儲到方法區中,這階段是基於二進位制位元組流進行的,通過驗證後,位元組流才會進入到記憶體的方法區中進行儲存。因此,後面的3個驗證階段是基於方法區的儲存結構進行分析的,不會再直接操作位元組流了。(2)元資料驗證對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求,主要是驗證類的繼承關係、資料型別是否符合,驗證點包括:    - 這個類是否有父類(除Object類外,其他所有類都應當有父類)    - 這個類的父類是否繼承了不允許被繼承的類(final 修飾的類)    - 這個類如果不是抽象類,是否實現了其父類或介面之中要求實現的所有方法    - 類中的欄位、方法是否和父類產生矛盾(如覆蓋了父類final 欄位,出現了非法的方法過載,如方法引數一致,但返回型別卻不同)(3)位元組碼驗證最複雜的一個階段,主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。在元資料驗證階段對資料型別做完校驗後,這個階段將對類的方法體進行校驗分析,以保證被校驗類的方法在執行時不會做出危害虛擬機器安全的事件,有如下一些驗證點:    - 保證任何時候,運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似這樣的情況:在操作棧放入了一個int型別資料,使用時卻按 long 型別載入到本地變量表中    - 保證跳轉指令不會跳轉到方法體外的位元組碼指令上    - 保證方法體中型別轉換是有效的(4)符號引用驗證這一階段發生在虛擬機器將符號引用轉化為直接引用的時候,而這個轉化動作發生在解析階段,符號引用可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗,驗證點如下:    - 符號引用中通過字串描述的全限定名是否能找到相應的類    - 在指定類中對否存在符合方法的欄位描述符以及簡單名稱所描述的方法和欄位    - 符號引用中的類、欄位、方法的訪問性(private、protected、public、default)是否可被當前類訪問這一階段驗證的目的是確保解析動作能正常執行。對於虛擬機器來說,驗證階段是一個非常重要的,但不是一定必要(因為對程式執行期沒有影響)的的階段。

3.3 準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。有兩點需要注意:(1)這階段進行記憶體分配的僅包括類變數(即被static修飾的變數),不包括例項變數,例項變數會在物件例項化時隨著物件一起分配在Java堆中;(2)這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數的定義如下:    public static int value = 123;那變數value在準備階段過後的零值為0而不是123,因為這時候並未執行任何Java方法,把value賦值為123的動作是在初始化階段才會進行。對於“非通常情況”,是指定義為常量的那些變數(即final修飾的),會在這一階段就被賦值,如:    public static final int value = 123;此時在準備階段過後,value的值將會被賦值為123。

3.4 解析

解析階段是虛擬機器將常量池中的符號引用轉化為直接引用的過程。
    - 符號引用(Symbolic References):即用一組符號來描述所引用的目標。它與虛擬機器的記憶體佈局無關,引用的目標不一定已經載入到記憶體中。    - 直接引用(Direct References):直接引用可以是指向目標的指標、相對偏移量或是一個能間接定位到目標的控制代碼。它是和虛擬機器記憶體佈局相關的,如果有了直接引用,那引用的目標必定已經在記憶體中存在了。解析動作主要針對 類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼 和 呼叫限定符 7類符號引用進行。
(1)類或介面的解析判斷所要轉化成的直接引用是對陣列型別,還是普通的物件型別的引用,從而進行不同的解析。
(2)欄位解析在對欄位進行解析前,會先檢視該欄位所屬的類或介面的符號引用是否已經解析過,沒有就先對欄位所屬的介面或類進行解析。在對欄位進行解析的時候,先查詢本類或介面中是否有該欄位,有就直接返回;否則,再對實現的介面進行遍歷,會按照繼承關係從下往上遞迴(也就是說,每個父介面都會走一遍)搜尋各個介面和它的父介面,返回最近一個介面的直接引用;再對繼承的父類進行遍歷,會按照繼承關係從下往上遞迴(也就是說,每個父類都會走一遍)搜尋各個父類,返回最近一個父類的直接引用。(3)類方法解析和欄位解析搜尋步驟差不多,只不過是先搜尋父類,再搜尋介面。(4)介面方法解析和類方法解析差不多,只不過介面中不會有父類,因此只需要對父介面進行搜尋即可。

3.5 初始化

初始化是類載入過程的最後一步,此階段才開始真正執行類中定義的Java程式程式碼(或者說位元組碼,也僅限與執行<clinit>()方法)。在準備階段,我們已經給變數付過一次系統要求的初始值(零值)而在初始化階段,則會根據程式設計師的意願給類變數和其他資源賦值。主要是通過<clinit>()方法來執行的: (1)<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數定義在它之後的變數,在前面的靜態語句中可以賦值,但是不能訪問如下:
public class Test {
    static{
        i = 0;//可以給變數賦值,編譯通過
        System.out.println(i);//編譯不通過!!不能進行訪問後面的靜態變數
    }
    static int i =1;
}
有點與我們平常的認知相反,這裡是可以下賦值,卻不能訪問... (2)<clinit>()方法與例項構造器<init>()方法(類的建構函式)不同,它不需要顯式地呼叫父類構造器,虛擬機器會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此,在虛擬機器中第一個被執行的<clinit>()方法的類肯定是java.lang.Object。 (3)<clinit>()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。 (4)介面中不能使用靜態語句塊,但仍然有類變數(final static)初始化的賦值操作,因此介面與類一樣會生成<clinit>()方法。但是介面與類不同的是:執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,只有當父介面中定義的變數被使用時,父接口才會被初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。 (5)虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個執行緒阻塞,在實際應用中這種阻塞往往是很隱蔽的。

4、類載入器

前面說過,在類載入過程的第一個階段:載入階段,除了可以使用系統提供的引導類載入器外,還可以使用使用者自定義的類載入器,以便讓使用者決定如何去獲取所需要的類(是從Class檔案中?還是從jar、或者其他方式...可以自由決定)。

4.1 類和類載入器

任意一個類,都需要由載入它的類載入器這個類本身共同確定其在Java 虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。這句話可以表達的更通俗一些:比較兩個類是否相等,只有在這兩個類是同一個類載入器載入的前提下才意義。否則,即使這兩個類來自同一個Class檔案,被同一個虛擬機器載入,但只要載入他們的類載入器不同,那這兩個類就必定不相等

這裡的“相等”,包括代表類的 Class 物件的equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括 instanceof 關鍵字對物件所屬關係判定等情況。下面程式碼演示了不同類載入器對 instanceof 關鍵字運算的結果的影響。

public class ClassLoaderTest {  
    public static void main(String[] args) throws Exception {  
        ClassLoader myLoader = new ClassLoader() {  
            @Override  
            public Class<?> loadClass(String name)  
                    throws ClassNotFoundException {  
                try {  
                    String fileName = name.substring(name.lastIndexOf(".") + 1)  
                            + ".class";  
                    InputStream is = getClass().getResourceAsStream(fileName);  
                    if (is == null) {  
                        return super.loadClass(name);  
                    }  
                    byte[] b = new byte[is.available()];  
                    is.read(b);  
                    return defineClass(name, b, 0, b.length);  
                } catch (IOException e) {  
                    throw new ClassNotFoundException(name);  
                }  
            }  
        };  

        Class c = myLoader.loadClass("org.bupt.xiaoye.blog.ClassLoaderTest");  
        Object obj = c.newInstance();  
        System.out.println(obj.getClass());  
        System.out.println(ClassLoaderTest.class);  
        System.out.println(obj instanceof ClassLoaderTest);  

    }  
}
執行結果如下:class org.bupt.xiaoye.blog.ClassLoaderTest  class org.bupt.xiaoye.blog.ClassLoaderTest  false我們使用了一個自定義的類載入器去載入ClassLoaderTest,由第一句也可以看出這個物件也的確是ClassLoaderTest例項化出來的物件,但是這個物件在與類class org.bupt.xiaoye.blog.ClassLoaderTest 做屬性檢查的時候卻反悔了false,這就是因為虛擬機器中存在了兩個ClassLoaderTest類,一個由系統應用程式類載入器載入,一個由我們自定義的類載入器載入,雖然是 來自同一個Class檔案,但依然是兩個獨立的類。因此,類是否相等,取決於類本身載入該類的類載入器是否是同一個類載入器

4.2 雙親委派模型

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

    一種是啟動類載入器(Bootstrap ClassLoader),這個類載入器用 C++  語言實現, 是虛擬機器自身的一部分:

    另一種就是所有其它的類載入器, 這些類載入器用Java 語言實現,獨立於虛擬機器外部,並且全都繼承與抽象類 java.lang.ClassLoader。

從Java 開發人員的角度來看,類載入器還可以劃分的更細緻一些,絕大多數Java 程式都會用到以下3種系統提供的類載入器:

   (1)啟動類載入器(Bootstrap ClassLoader) : 這個類載入器負責將存放在 <JAVA_HOME>\lib 目錄中的,或者被 -Xbootclasspath 引數指定的路徑中的,並且是虛擬機器識別的(僅按照檔名識別,如rt.jar ,名字不符合類庫不會載入) 類庫載入到虛擬機器記憶體中。啟動類載入器無法被 java 程式直接引用,如需要,直接使用 null 代替即可。   (2)擴充套件類載入器(Extension ClassLoader):這個載入器由sun.misc.Launcher$ExtClassLoader 實現,它負責載入<JAVA_HOME>\lib\ext 目錄中的,或者被 java.ext.dirs 系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。   (3)應用程式類載入器(Application ClassLoader):這個類載入器由 sun.misc.Launcher$AppClassLoader 實現。這個這個類載入器是 ClassLoader 中的getSystemClassLoader() 方法的返回值,所以一般稱它為系統類載入器。它負責載入用戶路徑(ClassPath)上所指定的類庫,開發者可以使用這個類載入器,如果應用程式沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器我們的應用程式都是由這3中類載入器互相配合進行載入的,如果有必要,還可以加入自己定義的類載入器。這些類載入器之間的關係一般如下圖所示:


圖中的類載入器之間的這種層次關係,稱為類載入器的雙親委派模型。雙親委派模型要求除了頂層的啟動類載入器,其餘的類載入器都應該有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承關係來實現,而是使用組合關係來複用父載入器的程式碼。
雙親委派模型的工作過程是如果一個類載入器收到了類載入器的請求,它首先不會自己嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類時),子載入類才會嘗試自己去載入使用雙親委派模型的好處:就是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。比如對於類Object來說,它存放在rt.jar中,無論哪一個類載入器要載入這個類,最終都是委派給處於模型最頂端的啟動類載入器去載入,因此Object類在程式中的各種類載入器環境中都是同一個類。相反,如果沒有使用雙親委派模型,由各個類自己去載入的話,按照我們前面說的,如果使用者自己編寫了一個Object類,並放在程式的ClassPath中,那系統中將會出現多個不同的Object類,此時Java型別提醒中最基礎的行為也就無法保證了,應用程式也將變得混亂。因此,雙親委派模型對於保證Java程式的穩定運作很重要,但是他的實現其實很簡單,實現雙親委派模型的程式碼幾種在java.lang.ClassLoader的loadClass()方法之中,邏輯清晰易懂:先檢查類是否被載入過,若沒有則呼叫父載入器的loadClass() 方法,若父載入器為空則預設使用啟動類載入器作為父載入器。如果父載入器失敗,丟擲 ClassNotFoundException 異常後,再呼叫自己的 finClass() 方法進行載入,如下:
protected Class<?> loadClass(String name, boolean resolve)  
        throws ClassNotFoundException {  
    synchronized (getClassLoadingLock(name)) {  
        // 首先檢查類是否已經被載入過  
        Class c = findLoadedClass(name);  
        if (c == null) {  
            long t0 = System.nanoTime();  
            try {  
                if (parent != null) {  
                    // 呼叫父類載入器載入  
                    c = parent.loadClass(name, false);  
                } else {  
                    c = findBootstrapClassOrNull(name);  
                }  
            } catch (ClassNotFoundException e) {  
                // ClassNotFoundException thrown if class not found  
                // from the non-null parent class loader  
            }  

            if (c == null) {  
                // If still not found, then invoke findClass in order  
                // to find the class.  
                //父類載入器無法完成載入,呼叫本身的載入器載入
                long t1 = System.nanoTime();  
                c = findClass(name);  

                // this is the defining class loader; record the stats  
                sun.misc.PerfCounter.getParentDelegationTime().addTime(  
                        t1 - t0);  
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(  
                        t1);  
                sun.misc.PerfCounter.getFindClasses().increment();  
            }  
        }  
        if (resolve) {  
            resolveClass(c);  
        }  
        return c;  
    }  
}
(注:文中圖片來源於:)