【JVM】類加載機制

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;  
    }  
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章